Add full-server activity to iOS home (#65)

This commit is contained in:
Georg Bauer
2026-08-15 17:25:25 +02:00
parent 6d271e7402
commit f84ab774e6
15 changed files with 492 additions and 15 deletions

View File

@@ -11,6 +11,6 @@ rust-version = "1.92"
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] } reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync"] }
rpassword = "7" rpassword = "7"
serde_yaml = "0.9" serde_yaml = "0.9"

View File

@@ -243,7 +243,15 @@ source update are published, and installs or updates Gotcha through AltStore PAL
background with no border or pill and defaults to the clock. Select the background with no border or pill and defaults to the clock. Select the
issue and pull-request icons in turn; each shows only matching recent issue and pull-request icons in turn; each shows only matching recent
activity or the native empty state, then the clock restores all rows. activity or the native empty state, then the clock restores all rows.
The bell remains a separate, accessible Notifications destination. The people and bell icons remain separate, accessible Server Activity
and Notifications destinations.
- [ ] Tap **Server Activity**. The pushed native list combines activity from
every user visible to the selected server account in newest-first order,
identifies each actor, supports pull-to-refresh and pagination without
duplicates, and uses a native empty state when no activity is visible.
Tap another user's repository, issue, pull request, and commit events;
each opens the corresponding native detail screen and Back returns to
Server Activity.
- [ ] Pull-request activity includes older creation and close events beyond the - [ ] Pull-request activity includes older creation and close events beyond the
first activity-feed page without requiring a manual pull-up; the empty first activity-feed page without requiring a manual pull-up; the empty
state appears only after every available page has been checked. state appears only after every available page has been checked.

View File

@@ -459,6 +459,21 @@ pub async fn load_activities(server: &Server, page: i32) -> Result<Page<models::
.map_err(message) .map_err(message)
} }
pub async fn load_server_activities(
pager: &mut gotcha_gitea::ServerActivityPager,
) -> Result<Page<models::Activity>, String> {
pager.next_page().await.map_err(message)
}
pub async fn server_activity_pager(
server: &Server,
) -> Result<gotcha_gitea::ServerActivityPager, String> {
client(server)?
.server_activity_pager()
.await
.map_err(message)
}
fn client(server: &Server) -> Result<Client, String> { fn client(server: &Server) -> Result<Client, String> {
Client::with_provider(&server.url, Some(&server.token), server.provider).map_err(message) Client::with_provider(&server.url, Some(&server.token), server.provider).map_err(message)
} }

View File

@@ -22,6 +22,7 @@ impl GotchaCore {
active_server, active_server,
..Default::default() ..Default::default()
}), }),
server_activity: tokio::sync::Mutex::new(None),
startup_error, startup_error,
}) })
} }
@@ -275,6 +276,35 @@ impl GotchaCore {
load_home(&server, valid_page(page)?, filter.into()).await?, load_home(&server, valid_page(page)?, filter.into()).await?,
)) ))
} }
pub async fn server_activity(&self, page: u32) -> Result<ServerActivityPage, GotchaError> {
valid_page(page)?;
let server = self.server()?;
let mut session = self.server_activity.lock().await;
if page == 1
|| session
.as_ref()
.is_none_or(|session| session.server_id != server.credential_account)
{
*session = Some(ServerActivitySession {
server_id: server.credential_account.clone(),
next_page: 1,
pager: server_activity_pager(&server).await?,
});
}
let session = session.as_mut().unwrap();
if session.next_page != page {
return Err("Refresh server activity before loading this page.".into());
}
let result = load_server_activities(&mut session.pager).await?;
if result.has_more {
session.next_page = session
.next_page
.checked_add(1)
.ok_or("Invalid page number.")?;
}
Ok(server_activity_page(result))
}
} }
fn rollback_added_server_token(server: &Server, error: String) -> String { fn rollback_added_server_token(server: &Server, error: String) -> String {

View File

@@ -86,9 +86,16 @@ pub struct Settings {
#[derive(uniffi::Object)] #[derive(uniffi::Object)]
pub struct GotchaCore { pub struct GotchaCore {
state: Mutex<State>, state: Mutex<State>,
server_activity: tokio::sync::Mutex<Option<ServerActivitySession>>,
startup_error: Option<String>, startup_error: Option<String>,
} }
struct ServerActivitySession {
server_id: String,
next_page: u32,
pager: gotcha_gitea::ServerActivityPager,
}
fn validate_repository<'a>( fn validate_repository<'a>(
owner: &'a str, owner: &'a str,
repository: &'a str, repository: &'a str,

View File

@@ -359,6 +359,12 @@ pub struct HomePage {
pub next_page: Option<u32>, pub next_page: Option<u32>,
} }
#[derive(Clone, uniffi::Record)]
pub struct ServerActivityPage {
pub rows: Vec<ActivityRow>,
pub has_more: bool,
}
#[derive(Clone, Copy, Debug, uniffi::Enum)] #[derive(Clone, Copy, Debug, uniffi::Enum)]
pub enum NotificationStatus { pub enum NotificationStatus {
Open, Open,

View File

@@ -15,6 +15,26 @@ pub fn activity_rows(activities: &[models::Activity]) -> Vec<ActivityRow> {
activities.iter().map(activity_row).collect() activities.iter().map(activity_row).collect()
} }
pub fn server_activity_page(page: gotcha_gitea::Page<models::Activity>) -> ServerActivityPage {
ServerActivityPage {
rows: page
.items
.iter()
.map(|activity| {
let mut row = activity_row(activity);
let actor = activity
.act_user
.as_ref()
.and_then(|user| user.login.as_deref())
.unwrap_or("unknown user");
row.title = format!("@{actor} · {}", row.title);
row
})
.collect(),
has_more: page.has_more,
}
}
fn activity_row(activity: &models::Activity) -> ActivityRow { fn activity_row(activity: &models::Activity) -> ActivityRow {
use models::activity::OpType; use models::activity::OpType;

View File

@@ -157,6 +157,38 @@ fn presents_filtered_home_activity_and_continuation() {
assert_eq!(page.next_page, Some(4)); assert_eq!(page.next_page, Some(4));
} }
#[test]
fn presents_server_activity_with_actor_and_navigation_target() {
use models::activity::OpType;
let page = server_activity_page(gotcha_gitea::Page {
items: vec![models::Activity {
act_user: Some(Box::new(models::User {
login: Some("apple".into()),
..Default::default()
})),
op_type: Some(OpType::CreateIssue),
content: Some("1|Test issue".into()),
repo: Some(Box::new(models::Repository {
full_name: Some("apple/SimpleDemoRepo".into()),
..Default::default()
})),
..Default::default()
}],
has_more: true,
});
assert_eq!(
page.rows[0].title,
"@apple · Opened an issue in apple/SimpleDemoRepo"
);
assert_eq!(page.rows[0].target, ActivityTargetKind::Issue);
assert_eq!(page.rows[0].owner, "apple");
assert_eq!(page.rows[0].repository, "SimpleDemoRepo");
assert_eq!(page.rows[0].number, 1);
assert!(page.has_more);
}
#[test] #[test]
fn issue_page_exposes_state_and_owned_comment_editing() { fn issue_page_exposes_state_and_owned_comment_editing() {
let page = issue_page(IssueDetails { let page = issue_page(IssueDetails {

View File

@@ -1,3 +1,5 @@
use std::collections::VecDeque;
use crate::{ use crate::{
Client, Error, Result, Client, Error, Result,
domain::{DEFAULT_PAGE_SIZE, HomeData, Page}, domain::{DEFAULT_PAGE_SIZE, HomeData, Page},
@@ -84,6 +86,18 @@ pub struct ActivityCommit {
pub timestamp: String, pub timestamp: String,
} }
pub struct ServerActivityPager {
configuration: apis::configuration::Configuration,
feeds: Vec<UserActivityFeed>,
}
struct UserActivityFeed {
login: String,
activities: VecDeque<models::Activity>,
next_page: i32,
complete: bool,
}
impl Client { impl Client {
pub async fn activities( pub async fn activities(
&self, &self,
@@ -123,6 +137,101 @@ impl Client {
next_page: activities.has_more.then_some(page + 1), next_page: activities.has_more.then_some(page + 1),
}) })
} }
pub async fn server_activity_pager(&self) -> Result<ServerActivityPager> {
let configuration = self.configuration();
let users = activity_users(&configuration).await?;
Ok(ServerActivityPager {
configuration,
feeds: users
.into_iter()
.map(|login| UserActivityFeed {
login,
activities: VecDeque::new(),
next_page: 1,
complete: false,
})
.collect(),
})
}
}
impl ServerActivityPager {
pub async fn next_page(&mut self) -> Result<Page<models::Activity>> {
let mut items = Vec::with_capacity(DEFAULT_PAGE_SIZE as usize);
while items.len() < DEFAULT_PAGE_SIZE as usize {
for feed in &mut self.feeds {
feed.fill(&self.configuration).await?;
}
let Some(feed) = self.feeds.iter_mut().max_by(|left, right| {
activity_order(left.activities.front(), right.activities.front())
}) else {
break;
};
let Some(activity) = feed.activities.pop_front() else {
break;
};
items.push(activity);
}
let has_more = self
.feeds
.iter()
.any(|feed| !feed.complete || !feed.activities.is_empty());
Ok(Page { items, has_more })
}
}
impl UserActivityFeed {
async fn fill(&mut self, configuration: &apis::configuration::Configuration) -> Result<()> {
if self.complete || !self.activities.is_empty() {
return Ok(());
}
let mut activities = activity_page(configuration, &self.login, self.next_page).await?;
self.complete = activities.len() < DEFAULT_PAGE_SIZE as usize;
self.next_page = self
.next_page
.checked_add(1)
.ok_or_else(|| Error::InvalidInput("activity feed has too many pages".into()))?;
activities.sort_unstable_by(|left, right| activity_order(Some(right), Some(left)));
self.activities = activities.into();
Ok(())
}
}
fn activity_order(
left: Option<&models::Activity>,
right: Option<&models::Activity>,
) -> std::cmp::Ordering {
left.map(|activity| (&activity.created, &activity.id))
.cmp(&right.map(|activity| (&activity.created, &activity.id)))
}
async fn activity_users(configuration: &apis::configuration::Configuration) -> Result<Vec<String>> {
let mut users = Vec::new();
let mut page = 1;
loop {
let response = apis::user_api::user_search(
configuration,
None,
None,
Some(page),
Some(DEFAULT_PAGE_SIZE),
)
.await
.map_err(Error::generated)?;
if response.ok == Some(false) {
return Err(Error::Generated("The server user search failed.".into()));
}
let batch = response.data.unwrap_or_default();
let complete = batch.len() < DEFAULT_PAGE_SIZE as usize;
users.extend(batch.into_iter().filter_map(|user| user.login));
if complete {
return Ok(users);
}
page = page
.checked_add(1)
.ok_or_else(|| Error::InvalidInput("user search has too many pages".into()))?;
}
} }
async fn filtered_activity_page( async fn filtered_activity_page(
@@ -419,4 +528,42 @@ mod tests {
2 2
); );
} }
#[tokio::test]
async fn merges_server_activity_incrementally_in_global_pages() {
let mut feeds = [VecDeque::new(), VecDeque::new()];
for id in 1..=65 {
feeds[id as usize % 2].push_front(models::Activity {
id: Some(id),
created: Some(format!("2026-08-15T12:{:02}:{:02}Z", id / 60, id % 60)),
..Default::default()
});
}
let mut pager = ServerActivityPager {
configuration: Default::default(),
feeds: feeds
.into_iter()
.enumerate()
.map(|(index, activities)| UserActivityFeed {
login: format!("user-{index}"),
activities,
next_page: 1,
complete: true,
})
.collect(),
};
let first = pager.next_page().await.unwrap();
let second = pager.next_page().await.unwrap();
let third = pager.next_page().await.unwrap();
assert_eq!(first.items.first().and_then(|row| row.id), Some(65));
assert_eq!(first.items.last().and_then(|row| row.id), Some(36));
assert!(first.has_more);
assert_eq!(second.items.first().and_then(|row| row.id), Some(35));
assert_eq!(second.items.last().and_then(|row| row.id), Some(6));
assert!(second.has_more);
assert_eq!(third.items.len(), 5);
assert!(!third.has_more);
}
} }

View File

@@ -23,7 +23,7 @@ mod pulls;
mod repositories; mod repositories;
pub use actions::{ActionJobLog, ActionRunDetails}; pub use actions::{ActionJobLog, ActionRunDetails};
pub use activity::ActivityFilter; pub use activity::{ActivityFilter, ServerActivityPager};
pub use config::{Config, Selection, ServerProfile, TuiPreferences, server_url}; pub use config::{Config, Selection, ServerProfile, TuiPreferences, server_url};
pub use domain::{ pub use domain::{
CreateIssue, DEFAULT_PAGE_SIZE, EditIssue, HistoryCommit, HomeData, IssueDetails, IssueDraft, CreateIssue, DEFAULT_PAGE_SIZE, EditIssue, HistoryCommit, HomeData, IssueDetails, IssueDraft,

View File

@@ -683,6 +683,8 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable {
func selectServer(index: UInt32) throws func selectServer(index: UInt32) throws
func serverActivity(page: UInt32) async throws -> ServerActivityPage
func serverEditor(index: UInt32) throws -> ServerEditor func serverEditor(index: UInt32) throws -> ServerEditor
func servers() -> [ServerRow] func servers() -> [ServerRow]
@@ -1348,6 +1350,22 @@ open func selectServer(index: UInt32)throws {try rustCallWithError(FfiConverte
} }
} }
open func serverActivity(page: UInt32)async throws -> ServerActivityPage {
return
try await uniffiRustCallAsync(
rustFutureFunc: {
uniffi_gotcha_core_fn_method_gotchacore_server_activity(
self.uniffiCloneHandle(),FfiConverterUInt32.lower(page)
)
},
pollFunc: ffi_gotcha_core_rust_future_poll_rust_buffer,
completeFunc: ffi_gotcha_core_rust_future_complete_rust_buffer,
freeFunc: ffi_gotcha_core_rust_future_free_rust_buffer,
liftFunc: FfiConverterTypeServerActivityPage_lift,
errorHandler: FfiConverterTypeGotchaError_lift
)
}
open func serverEditor(index: UInt32)throws -> ServerEditor { open func serverEditor(index: UInt32)throws -> ServerEditor {
return try FfiConverterTypeServerEditor_lift(try rustCallWithError(FfiConverterTypeGotchaError_lift) { return try FfiConverterTypeServerEditor_lift(try rustCallWithError(FfiConverterTypeGotchaError_lift) {
uniffiCallStatus in uniffiCallStatus in
@@ -3709,6 +3727,60 @@ public func FfiConverterTypeRepositoryRow_lower(_ value: RepositoryRow) -> RustB
} }
public struct ServerActivityPage: Equatable, Hashable {
public var rows: [ActivityRow]
public var hasMore: Bool
// Default memberwise initializers are never public by default, so we
// declare one manually.
public init(rows: [ActivityRow], hasMore: Bool) {
self.rows = rows
self.hasMore = hasMore
}
}
#if compiler(>=6)
extension ServerActivityPage: Sendable {}
#endif
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public struct FfiConverterTypeServerActivityPage: FfiConverterRustBuffer {
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ServerActivityPage {
return
try ServerActivityPage(
rows: FfiConverterSequenceTypeActivityRow.read(from: &buf),
hasMore: FfiConverterBool.read(from: &buf)
)
}
public static func write(_ value: ServerActivityPage, into buf: inout [UInt8]) {
FfiConverterSequenceTypeActivityRow.write(value.rows, into: &buf)
FfiConverterBool.write(value.hasMore, into: &buf)
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeServerActivityPage_lift(_ buf: RustBuffer) throws -> ServerActivityPage {
return try FfiConverterTypeServerActivityPage.lift(buf)
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeServerActivityPage_lower(_ value: ServerActivityPage) -> RustBuffer {
return FfiConverterTypeServerActivityPage.lower(value)
}
public struct ServerEditor: Equatable, Hashable { public struct ServerEditor: Equatable, Hashable {
public var name: String public var name: String
public var url: String public var url: String
@@ -5589,6 +5661,9 @@ private let initializationResult: InitializationResult = {
if (uniffi_gotcha_core_checksum_method_gotchacore_select_server() != 10204) { if (uniffi_gotcha_core_checksum_method_gotchacore_select_server() != 10204) {
return InitializationResult.apiChecksumMismatch return InitializationResult.apiChecksumMismatch
} }
if (uniffi_gotcha_core_checksum_method_gotchacore_server_activity() != 35959) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_gotcha_core_checksum_method_gotchacore_server_editor() != 36193) { if (uniffi_gotcha_core_checksum_method_gotchacore_server_editor() != 36193) {
return InitializationResult.apiChecksumMismatch return InitializationResult.apiChecksumMismatch
} }

View File

@@ -458,6 +458,11 @@ uint64_t uniffi_gotcha_core_fn_method_gotchacore_home(uint64_t ptr, uint32_t pag
void uniffi_gotcha_core_fn_method_gotchacore_select_server(uint64_t ptr, uint32_t index, RustCallStatus *_Nonnull out_status void uniffi_gotcha_core_fn_method_gotchacore_select_server(uint64_t ptr, uint32_t index, RustCallStatus *_Nonnull out_status
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SERVER_ACTIVITY
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SERVER_ACTIVITY
uint64_t uniffi_gotcha_core_fn_method_gotchacore_server_activity(uint64_t ptr, uint32_t page
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SERVER_EDITOR #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SERVER_EDITOR
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SERVER_EDITOR #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SERVER_EDITOR
RustBuffer uniffi_gotcha_core_fn_method_gotchacore_server_editor(uint64_t ptr, uint32_t index, RustCallStatus *_Nonnull out_status RustBuffer uniffi_gotcha_core_fn_method_gotchacore_server_editor(uint64_t ptr, uint32_t index, RustCallStatus *_Nonnull out_status
@@ -1011,6 +1016,12 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_home(void
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SELECT_SERVER #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SELECT_SERVER
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_select_server(void uint16_t uniffi_gotcha_core_checksum_method_gotchacore_select_server(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SERVER_ACTIVITY
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SERVER_ACTIVITY
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_server_activity(void
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SERVER_EDITOR #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SERVER_EDITOR

View File

@@ -8,6 +8,7 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
0020E57CC7B0C5CBBF04291C /* HomeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35C7ECBC3EEC8B4659234AE /* HomeScreen.swift */; }; 0020E57CC7B0C5CBBF04291C /* HomeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35C7ECBC3EEC8B4659234AE /* HomeScreen.swift */; };
1279D8E7C8A76F0D7F45F97E /* ServerActivityScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C028DA45E7CB9E1CD56754AC /* ServerActivityScreen.swift */; };
130339B2D7AEAC791E50140F /* RepositoryDirectoryScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6BB62D12708255650508B2 /* RepositoryDirectoryScreen.swift */; }; 130339B2D7AEAC791E50140F /* RepositoryDirectoryScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6BB62D12708255650508B2 /* RepositoryDirectoryScreen.swift */; };
1EDCCB5DE286C1DA407F00F1 /* MilestoneEditorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA582099DD57D35C748EFB02 /* MilestoneEditorViewController.swift */; }; 1EDCCB5DE286C1DA407F00F1 /* MilestoneEditorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA582099DD57D35C748EFB02 /* MilestoneEditorViewController.swift */; };
25B30DCE869158C89021EFC6 /* DiffScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41E0F726C9668B50418D9ADF /* DiffScreen.swift */; }; 25B30DCE869158C89021EFC6 /* DiffScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41E0F726C9668B50418D9ADF /* DiffScreen.swift */; };
@@ -86,6 +87,7 @@
9C0921C76676A14A024BA417 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 9C0921C76676A14A024BA417 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
ACD6449327E1A51FCD8E3BD4 /* IssueScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueScreens.swift; sourceTree = "<group>"; }; ACD6449327E1A51FCD8E3BD4 /* IssueScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueScreens.swift; sourceTree = "<group>"; };
BA5F9B46F07F3EB2333CD4DF /* NotificationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCoordinator.swift; sourceTree = "<group>"; }; BA5F9B46F07F3EB2333CD4DF /* NotificationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCoordinator.swift; sourceTree = "<group>"; };
C028DA45E7CB9E1CD56754AC /* ServerActivityScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerActivityScreen.swift; sourceTree = "<group>"; };
C13A39F3C39C1F353D58C307 /* ServerScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerScreens.swift; sourceTree = "<group>"; }; C13A39F3C39C1F353D58C307 /* ServerScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerScreens.swift; sourceTree = "<group>"; };
CA582099DD57D35C748EFB02 /* MilestoneEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MilestoneEditorViewController.swift; sourceTree = "<group>"; }; CA582099DD57D35C748EFB02 /* MilestoneEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MilestoneEditorViewController.swift; sourceTree = "<group>"; };
D35C7ECBC3EEC8B4659234AE /* HomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreen.swift; sourceTree = "<group>"; }; D35C7ECBC3EEC8B4659234AE /* HomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreen.swift; sourceTree = "<group>"; };
@@ -140,6 +142,7 @@
FD6BB62D12708255650508B2 /* RepositoryDirectoryScreen.swift */, FD6BB62D12708255650508B2 /* RepositoryDirectoryScreen.swift */,
181AE294D07DB4EAC9C9A0FF /* RepositoryFileScreens.swift */, 181AE294D07DB4EAC9C9A0FF /* RepositoryFileScreens.swift */,
000E810FCB39B9916CA5CAB8 /* RepositoryListScreen.swift */, 000E810FCB39B9916CA5CAB8 /* RepositoryListScreen.swift */,
C028DA45E7CB9E1CD56754AC /* ServerActivityScreen.swift */,
C13A39F3C39C1F353D58C307 /* ServerScreens.swift */, C13A39F3C39C1F353D58C307 /* ServerScreens.swift */,
8F145F13B8ED83A5AB0009A4 /* SettingsViewController.swift */, 8F145F13B8ED83A5AB0009A4 /* SettingsViewController.swift */,
F75B3E4FFB9C9992517C4D69 /* Support.swift */, F75B3E4FFB9C9992517C4D69 /* Support.swift */,
@@ -363,6 +366,7 @@
130339B2D7AEAC791E50140F /* RepositoryDirectoryScreen.swift in Sources */, 130339B2D7AEAC791E50140F /* RepositoryDirectoryScreen.swift in Sources */,
DC155F5DEB86D95FAF709E16 /* RepositoryFileScreens.swift in Sources */, DC155F5DEB86D95FAF709E16 /* RepositoryFileScreens.swift in Sources */,
DFB34862C386662D6EE7E6BE /* RepositoryListScreen.swift in Sources */, DFB34862C386662D6EE7E6BE /* RepositoryListScreen.swift in Sources */,
1279D8E7C8A76F0D7F45F97E /* ServerActivityScreen.swift in Sources */,
9B2206DF1263080B9B25B82C /* ServerScreens.swift in Sources */, 9B2206DF1263080B9B25B82C /* ServerScreens.swift in Sources */,
96C4AFC206A1DBAE3D3E00CE /* SettingsViewController.swift in Sources */, 96C4AFC206A1DBAE3D3E00CE /* SettingsViewController.swift in Sources */,
F55A89489B2758D694F3B27D /* Support.swift in Sources */, F55A89489B2758D694F3B27D /* Support.swift in Sources */,

View File

@@ -119,6 +119,13 @@ final class HomeViewController: RefreshingTableViewController {
NotificationsViewController(context: self.context), NotificationsViewController(context: self.context),
animated: true animated: true
) )
},
onServerActivity: { [weak self] in
guard let self else { return }
self.navigationController?.pushViewController(
ServerActivityViewController(context: self.context),
animated: true
)
} }
) )
} } } }
@@ -148,7 +155,7 @@ final class HomeViewController: RefreshingTableViewController {
cell, cell,
title: row.title, title: row.title,
detail: "\(row.detail)\n\(row.meta)", detail: "\(row.detail)\n\(row.meta)",
image: context.symbol(symbolName(for: row.icon)) image: context.symbol(activitySymbolName(for: row.icon))
) )
cell.accessoryType = row.target == .none ? .none : .disclosureIndicator cell.accessoryType = row.target == .none ? .none : .disclosureIndicator
return cell return cell
@@ -173,16 +180,17 @@ final class HomeViewController: RefreshingTableViewController {
: nil : nil
} }
private func symbolName(for icon: ActivityIcon) -> String { }
switch icon {
case .pullRequest: return "arrow.triangle.pull" func activitySymbolName(for icon: ActivityIcon) -> String {
case .issue: return "exclamationmark.circle" switch icon {
case .branch: return "arrow.triangle.branch" case .pullRequest: return "arrow.triangle.pull"
case .tag: return "tag" case .issue: return "exclamationmark.circle"
case .push: return "arrow.up.circle" case .branch: return "arrow.triangle.branch"
case .release: return "shippingbox" case .tag: return "tag"
case .repository: return "books.vertical" case .push: return "arrow.up.circle"
} case .release: return "shippingbox"
case .repository: return "books.vertical"
} }
} }
@@ -194,7 +202,8 @@ final class HeatmapView: UIView {
page: HomePage, page: HomePage,
selectedFilter: Int, selectedFilter: Int,
onFilter: @escaping (Int) -> Void, onFilter: @escaping (Int) -> Void,
onNotifications: @escaping () -> Void onNotifications: @escaping () -> Void,
onServerActivity: @escaping () -> Void
) { ) {
cells = page.heatCells cells = page.heatCells
super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 180)) super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 180))
@@ -241,6 +250,15 @@ final class HeatmapView: UIView {
button.widthAnchor.constraint(equalToConstant: 44).isActive = true button.widthAnchor.constraint(equalToConstant: 44).isActive = true
filters.addArrangedSubview(button) filters.addArrangedSubview(button)
} }
let serverActivity = UIButton(
type: .custom,
primaryAction: UIAction { _ in onServerActivity() }
)
serverActivity.setImage(UIImage(systemName: "person.3"), for: .normal)
serverActivity.tintColor = .secondaryLabel
serverActivity.accessibilityLabel = "Server Activity"
serverActivity.widthAnchor.constraint(equalToConstant: 44).isActive = true
filters.addArrangedSubview(serverActivity)
let notifications = UIButton( let notifications = UIButton(
type: .custom, type: .custom,
primaryAction: UIAction { _ in onNotifications() } primaryAction: UIAction { _ in onNotifications() }

View File

@@ -0,0 +1,104 @@
import UIKit
@MainActor
final class ServerActivityViewController: RefreshingTableViewController {
private let context: AppContext
private var rows: [ActivityRow] = []
private var nextPage: UInt32?
private var loaded = false
init(context: AppContext) {
self.context = context
super.init()
title = "Server Activity"
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
guard !loaded else { return }
loadContent(refreshing: false)
}
override func loadContent(refreshing: Bool) {
loadPage(1, refreshing: refreshing)
}
override func loadMoreContent() {
guard let nextPage else { return }
loadPage(nextPage, refreshing: false)
}
private func loadPage(_ requestedPage: UInt32, refreshing: Bool) {
if requestedPage == 1 {
resetPagination()
beginLoading(refreshing: refreshing)
}
loadingTask?.cancel()
loadingTask = Task {
do {
let page = try await context.core.serverActivity(page: requestedPage)
if requestedPage == 1 {
rows = page.rows
loaded = true
} else {
rows.append(contentsOf: page.rows)
}
nextPage = page.hasMore ? requestedPage + 1 : nil
finishPagination(hasMore: page.hasMore)
updateRows()
} catch {
if !Task.isCancelled {
show(error: error)
failPagination()
}
}
if requestedPage == 1 { endLoading() }
}
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
rows.count
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "server-activity")
?? UITableViewCell(style: .subtitle, reuseIdentifier: "server-activity")
let row = rows[indexPath.row]
configureTextCell(
cell,
title: row.title,
detail: "\(row.detail)\n\(row.meta)",
image: context.symbol(activitySymbolName(for: row.icon))
)
cell.accessoryType = row.target == .none ? .none : .disclosureIndicator
cell.selectionStyle = row.target == .none ? .none : .default
return cell
}
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
92
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let row = rows[indexPath.row]
guard row.target != .none else { return }
tableView.deselectRow(at: indexPath, animated: true)
context.route(row)
}
private func updateRows() {
tableView.reloadData()
tableView.backgroundView = rows.isEmpty
? EmptyBackgroundView(
title: "No server activity",
detail: "No activity is visible to this server account."
)
: nil
}
}