diff --git a/Cargo.toml b/Cargo.toml index 8783457..e952c2b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,6 @@ rust-version = "1.92" reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync"] } rpassword = "7" serde_yaml = "0.9" diff --git a/TESTING.md b/TESTING.md index e6763c9..1c76eef 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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 issue and pull-request icons in turn; each shows only matching recent 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 first activity-feed page without requiring a manual pull-up; the empty state appears only after every available page has been checked. diff --git a/crates/app/src/api.rs b/crates/app/src/api.rs index d0d2a97..1a0b135 100644 --- a/crates/app/src/api.rs +++ b/crates/app/src/api.rs @@ -459,6 +459,21 @@ pub async fn load_activities(server: &Server, page: i32) -> Result Result, String> { + pager.next_page().await.map_err(message) +} + +pub async fn server_activity_pager( + server: &Server, +) -> Result { + client(server)? + .server_activity_pager() + .await + .map_err(message) +} + fn client(server: &Server) -> Result { Client::with_provider(&server.url, Some(&server.token), server.provider).map_err(message) } diff --git a/crates/app/src/core/servers.rs b/crates/app/src/core/servers.rs index ea7014b..bd6002f 100644 --- a/crates/app/src/core/servers.rs +++ b/crates/app/src/core/servers.rs @@ -22,6 +22,7 @@ impl GotchaCore { active_server, ..Default::default() }), + server_activity: tokio::sync::Mutex::new(None), startup_error, }) } @@ -275,6 +276,35 @@ impl GotchaCore { load_home(&server, valid_page(page)?, filter.into()).await?, )) } + + pub async fn server_activity(&self, page: u32) -> Result { + 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 { diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 7bd194a..8236e8e 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -86,9 +86,16 @@ pub struct Settings { #[derive(uniffi::Object)] pub struct GotchaCore { state: Mutex, + server_activity: tokio::sync::Mutex>, startup_error: Option, } +struct ServerActivitySession { + server_id: String, + next_page: u32, + pager: gotcha_gitea::ServerActivityPager, +} + fn validate_repository<'a>( owner: &'a str, repository: &'a str, diff --git a/crates/app/src/presentation.rs b/crates/app/src/presentation.rs index 0cb1da4..c343af8 100644 --- a/crates/app/src/presentation.rs +++ b/crates/app/src/presentation.rs @@ -359,6 +359,12 @@ pub struct HomePage { pub next_page: Option, } +#[derive(Clone, uniffi::Record)] +pub struct ServerActivityPage { + pub rows: Vec, + pub has_more: bool, +} + #[derive(Clone, Copy, Debug, uniffi::Enum)] pub enum NotificationStatus { Open, diff --git a/crates/app/src/presentation/home.rs b/crates/app/src/presentation/home.rs index 9eee7a9..388d76f 100644 --- a/crates/app/src/presentation/home.rs +++ b/crates/app/src/presentation/home.rs @@ -15,6 +15,26 @@ pub fn activity_rows(activities: &[models::Activity]) -> Vec { activities.iter().map(activity_row).collect() } +pub fn server_activity_page(page: gotcha_gitea::Page) -> 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 { use models::activity::OpType; diff --git a/crates/app/src/presentation/tests.rs b/crates/app/src/presentation/tests.rs index b5fd2c7..8c3c375 100644 --- a/crates/app/src/presentation/tests.rs +++ b/crates/app/src/presentation/tests.rs @@ -157,6 +157,38 @@ fn presents_filtered_home_activity_and_continuation() { 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] fn issue_page_exposes_state_and_owned_comment_editing() { let page = issue_page(IssueDetails { diff --git a/crates/gitea/src/activity.rs b/crates/gitea/src/activity.rs index 336ee5a..3026b7a 100644 --- a/crates/gitea/src/activity.rs +++ b/crates/gitea/src/activity.rs @@ -1,3 +1,5 @@ +use std::collections::VecDeque; + use crate::{ Client, Error, Result, domain::{DEFAULT_PAGE_SIZE, HomeData, Page}, @@ -84,6 +86,18 @@ pub struct ActivityCommit { pub timestamp: String, } +pub struct ServerActivityPager { + configuration: apis::configuration::Configuration, + feeds: Vec, +} + +struct UserActivityFeed { + login: String, + activities: VecDeque, + next_page: i32, + complete: bool, +} + impl Client { pub async fn activities( &self, @@ -123,6 +137,101 @@ impl Client { next_page: activities.has_more.then_some(page + 1), }) } + + pub async fn server_activity_pager(&self) -> Result { + 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> { + 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> { + 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( @@ -419,4 +528,42 @@ mod tests { 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); + } } diff --git a/crates/gitea/src/lib.rs b/crates/gitea/src/lib.rs index b76292f..a59ceb9 100644 --- a/crates/gitea/src/lib.rs +++ b/crates/gitea/src/lib.rs @@ -23,7 +23,7 @@ mod pulls; mod repositories; 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 domain::{ CreateIssue, DEFAULT_PAGE_SIZE, EditIssue, HistoryCommit, HomeData, IssueDetails, IssueDraft, diff --git a/ios/Generated/gotcha_core.swift b/ios/Generated/gotcha_core.swift index d004812..317e134 100644 --- a/ios/Generated/gotcha_core.swift +++ b/ios/Generated/gotcha_core.swift @@ -683,6 +683,8 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable { func selectServer(index: UInt32) throws + func serverActivity(page: UInt32) async throws -> ServerActivityPage + func serverEditor(index: UInt32) throws -> ServerEditor 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 { return try FfiConverterTypeServerEditor_lift(try rustCallWithError(FfiConverterTypeGotchaError_lift) { 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 var name: String public var url: String @@ -5589,6 +5661,9 @@ private let initializationResult: InitializationResult = { if (uniffi_gotcha_core_checksum_method_gotchacore_select_server() != 10204) { 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) { return InitializationResult.apiChecksumMismatch } diff --git a/ios/Generated/gotcha_coreFFI.h b/ios/Generated/gotcha_coreFFI.h index 928671c..99fd06d 100644 --- a/ios/Generated/gotcha_coreFFI.h +++ b/ios/Generated/gotcha_coreFFI.h @@ -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 ); #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 #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 @@ -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 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 #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SERVER_EDITOR diff --git a/ios/Gotcha.xcodeproj/project.pbxproj b/ios/Gotcha.xcodeproj/project.pbxproj index ca881c9..60d6d18 100644 --- a/ios/Gotcha.xcodeproj/project.pbxproj +++ b/ios/Gotcha.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 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 */; }; 1EDCCB5DE286C1DA407F00F1 /* MilestoneEditorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA582099DD57D35C748EFB02 /* MilestoneEditorViewController.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 = ""; }; ACD6449327E1A51FCD8E3BD4 /* IssueScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueScreens.swift; sourceTree = ""; }; BA5F9B46F07F3EB2333CD4DF /* NotificationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCoordinator.swift; sourceTree = ""; }; + C028DA45E7CB9E1CD56754AC /* ServerActivityScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerActivityScreen.swift; sourceTree = ""; }; C13A39F3C39C1F353D58C307 /* ServerScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerScreens.swift; sourceTree = ""; }; CA582099DD57D35C748EFB02 /* MilestoneEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MilestoneEditorViewController.swift; sourceTree = ""; }; D35C7ECBC3EEC8B4659234AE /* HomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreen.swift; sourceTree = ""; }; @@ -140,6 +142,7 @@ FD6BB62D12708255650508B2 /* RepositoryDirectoryScreen.swift */, 181AE294D07DB4EAC9C9A0FF /* RepositoryFileScreens.swift */, 000E810FCB39B9916CA5CAB8 /* RepositoryListScreen.swift */, + C028DA45E7CB9E1CD56754AC /* ServerActivityScreen.swift */, C13A39F3C39C1F353D58C307 /* ServerScreens.swift */, 8F145F13B8ED83A5AB0009A4 /* SettingsViewController.swift */, F75B3E4FFB9C9992517C4D69 /* Support.swift */, @@ -363,6 +366,7 @@ 130339B2D7AEAC791E50140F /* RepositoryDirectoryScreen.swift in Sources */, DC155F5DEB86D95FAF709E16 /* RepositoryFileScreens.swift in Sources */, DFB34862C386662D6EE7E6BE /* RepositoryListScreen.swift in Sources */, + 1279D8E7C8A76F0D7F45F97E /* ServerActivityScreen.swift in Sources */, 9B2206DF1263080B9B25B82C /* ServerScreens.swift in Sources */, 96C4AFC206A1DBAE3D3E00CE /* SettingsViewController.swift in Sources */, F55A89489B2758D694F3B27D /* Support.swift in Sources */, diff --git a/ios/Sources/HomeScreen.swift b/ios/Sources/HomeScreen.swift index 351e773..9b3ce3a 100644 --- a/ios/Sources/HomeScreen.swift +++ b/ios/Sources/HomeScreen.swift @@ -119,6 +119,13 @@ final class HomeViewController: RefreshingTableViewController { NotificationsViewController(context: self.context), 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, title: row.title, 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 return cell @@ -173,16 +180,17 @@ final class HomeViewController: RefreshingTableViewController { : nil } - private func symbolName(for icon: ActivityIcon) -> String { - switch icon { - case .pullRequest: return "arrow.triangle.pull" - case .issue: return "exclamationmark.circle" - case .branch: return "arrow.triangle.branch" - case .tag: return "tag" - case .push: return "arrow.up.circle" - case .release: return "shippingbox" - case .repository: return "books.vertical" - } +} + +func activitySymbolName(for icon: ActivityIcon) -> String { + switch icon { + case .pullRequest: return "arrow.triangle.pull" + case .issue: return "exclamationmark.circle" + case .branch: return "arrow.triangle.branch" + case .tag: return "tag" + 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, selectedFilter: Int, onFilter: @escaping (Int) -> Void, - onNotifications: @escaping () -> Void + onNotifications: @escaping () -> Void, + onServerActivity: @escaping () -> Void ) { cells = page.heatCells 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 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( type: .custom, primaryAction: UIAction { _ in onNotifications() } diff --git a/ios/Sources/ServerActivityScreen.swift b/ios/Sources/ServerActivityScreen.swift new file mode 100644 index 0000000..5eda52d --- /dev/null +++ b/ios/Sources/ServerActivityScreen.swift @@ -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 + } +}