diff --git a/TESTING.md b/TESTING.md index cd01a5f..b33d7e5 100644 --- a/TESTING.md +++ b/TESTING.md @@ -134,7 +134,8 @@ xcrun simctl launch booted de.rfc1437.gotcha issue and pull-request icons in turn; each shows only matching recent activity or the native empty state, then the clock restores all rows. - [ ] Pull-request activity includes older creation and close events beyond the - first activity-feed page. + first activity-feed page without requiring a manual pull-up; the empty + state appears only after every available page has been checked. - [ ] Tap repository, issue, pull-request, and commit activity. Each opens the matching native tab and destination. - [ ] Non-linkable server activity does not navigate or appear tappable. diff --git a/crates/app/src/api.rs b/crates/app/src/api.rs index 35360b0..7dcfa8a 100644 --- a/crates/app/src/api.rs +++ b/crates/app/src/api.rs @@ -355,8 +355,12 @@ pub async fn load_pull_diff( Ok(diff::parse_file(&diff, path)) } -pub async fn load_home(server: &Server, page: i32) -> Result { - client(server)?.home(page).await.map_err(message) +pub async fn load_home( + server: &Server, + page: i32, + filter: gotcha_gitea::ActivityFilter, +) -> Result { + client(server)?.home(page, filter).await.map_err(message) } fn client(server: &Server) -> Result { diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 65db511..3f88e12 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -171,12 +171,16 @@ impl GotchaCore { save_preferences(&state.preferences).map_err(Into::into) } - pub async fn home(&self, page: u32) -> Result { + pub async fn home( + &self, + page: u32, + filter: HomeActivityFilter, + ) -> Result { let server = self.server()?; let name = server.name.clone(); Ok(home_page( name, - load_home(&server, valid_page(page)?).await?, + load_home(&server, valid_page(page)?, filter.into()).await?, )) } diff --git a/crates/app/src/presentation.rs b/crates/app/src/presentation.rs index 91ce7eb..85eee48 100644 --- a/crates/app/src/presentation.rs +++ b/crates/app/src/presentation.rs @@ -245,6 +245,23 @@ pub struct ActivityRow { pub sha: String, } +#[derive(Clone, Copy, Debug, uniffi::Enum)] +pub enum HomeActivityFilter { + All, + Issues, + PullRequests, +} + +impl From for gotcha_gitea::ActivityFilter { + fn from(filter: HomeActivityFilter) -> Self { + match filter { + HomeActivityFilter::All => Self::All, + HomeActivityFilter::Issues => Self::Issues, + HomeActivityFilter::PullRequests => Self::PullRequests, + } + } +} + #[derive(Clone, uniffi::Record)] pub struct HeatCell { pub level: u32, @@ -255,11 +272,9 @@ pub struct HeatCell { pub struct HomePage { pub server_name: String, pub activities: Vec, - pub issue_activities: Vec, - pub pull_activities: Vec, pub heat_cells: Vec, pub contribution_count: i64, - pub has_more: bool, + pub next_page: Option, } pub fn repository_rows( @@ -776,52 +791,13 @@ pub fn home_page(server_name: String, home: HomeData) -> HomePage { let (heat_cells, contribution_count) = heat_cells(&home.heatmap); HomePage { server_name, - issue_activities: home - .activities - .iter() - .filter(|activity| is_issue_activity(activity)) - .map(activity_row) - .collect(), - pull_activities: home - .activities - .iter() - .filter(|activity| is_pull_activity(activity)) - .map(activity_row) - .collect(), activities: home.activities.iter().map(activity_row).collect(), heat_cells, contribution_count, - has_more: home.has_more, + next_page: home.next_page.map(|page| page as u32), } } -fn is_issue_activity(activity: &models::Activity) -> bool { - use models::activity::OpType; - - matches!( - activity.op_type, - Some(OpType::CreateIssue | OpType::CloseIssue | OpType::ReopenIssue | OpType::CommentIssue) - ) -} - -fn is_pull_activity(activity: &models::Activity) -> bool { - use models::activity::OpType; - - matches!( - activity.op_type, - Some( - OpType::CreatePullRequest - | OpType::MergePullRequest - | OpType::AutoMergePullRequest - | OpType::ClosePullRequest - | OpType::ReopenPullRequest - | OpType::CommentPull - | OpType::ApprovePullRequest - | OpType::RejectPullRequest - ) - ) -} - fn activity_row(activity: &models::Activity) -> ActivityRow { use models::activity::OpType; @@ -1263,31 +1239,26 @@ mod tests { } #[test] - fn categorizes_home_activity_before_crossing_the_bridge() { + fn presents_filtered_home_activity_and_continuation() { use models::activity::OpType; let page = home_page( "Gitea".into(), HomeData { - activities: [ - OpType::CreateIssue, - OpType::CreatePullRequest, - OpType::CreateRepo, - ] - .into_iter() - .map(|op_type| models::Activity { - op_type: Some(op_type), - ..Default::default() - }) - .collect(), + activities: [OpType::CreatePullRequest, OpType::ClosePullRequest] + .into_iter() + .map(|op_type| models::Activity { + op_type: Some(op_type), + ..Default::default() + }) + .collect(), heatmap: Vec::new(), - has_more: false, + next_page: Some(4), }, ); - assert_eq!(page.activities.len(), 3); - assert_eq!(page.issue_activities.len(), 1); - assert_eq!(page.pull_activities.len(), 1); + assert_eq!(page.activities.len(), 2); + assert_eq!(page.next_page, Some(4)); } #[test] diff --git a/crates/gitea/src/activity.rs b/crates/gitea/src/activity.rs index 71e13e7..94c3fff 100644 --- a/crates/gitea/src/activity.rs +++ b/crates/gitea/src/activity.rs @@ -5,6 +5,48 @@ use crate::{ }; use gitea_openapi::apis; +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum ActivityFilter { + #[default] + All, + Issues, + PullRequests, +} + +impl ActivityFilter { + fn matches(self, activity: &models::Activity) -> bool { + use models::activity::OpType; + + match self { + Self::All => true, + Self::Issues => matches!( + activity.op_type, + Some( + OpType::CreateIssue + | OpType::CloseIssue + | OpType::ReopenIssue + | OpType::CommentIssue + ) + ), + Self::PullRequests => matches!( + activity.op_type, + Some( + OpType::CreatePullRequest + | OpType::MergePullRequest + | OpType::AutoMergePullRequest + | OpType::ClosePullRequest + | OpType::ReopenPullRequest + | OpType::CommentPull + | OpType::ApprovePullRequest + | OpType::RejectPullRequest + | OpType::PullReviewDismissed + | OpType::PullRequestReadyForReview + ) + ), + } + } +} + #[derive(Debug, Eq, PartialEq)] pub enum Target { Repository { @@ -29,7 +71,7 @@ pub enum Target { } impl Client { - pub async fn home(&self, page: i32) -> Result { + pub async fn home(&self, page: i32, filter: ActivityFilter) -> Result { if page < 1 { return Err(Error::InvalidInput("page must be positive".into())); } @@ -39,27 +81,51 @@ impl Client { .await? .login .ok_or_else(|| Error::Generated("The server account has no username.".into()))?; - let activities = apis::user_api::user_list_activity_feeds( - &configuration, - &login, - Some(true), - None, - Some(page), - Some(DEFAULT_PAGE_SIZE), - ); + let activities = activity_page(&configuration, &login, page); let (activities, heatmap) = tokio::join!( activities, apis::user_api::user_get_heatmap_data(&configuration, &login), ); - let activities = activities.map_err(Error::generated)?; - Ok(HomeData { - has_more: activities.len() == DEFAULT_PAGE_SIZE as usize, - activities, - heatmap: heatmap.map_err(Error::generated)?, - }) + let mut activities = activities?; + let heatmap = heatmap.map_err(Error::generated)?; + let mut page = page; + + loop { + let has_more = activities.len() == DEFAULT_PAGE_SIZE as usize; + let filtered: Vec<_> = activities + .into_iter() + .filter(|activity| filter.matches(activity)) + .collect(); + if filter == ActivityFilter::All || !filtered.is_empty() || !has_more { + return Ok(HomeData { + activities: filtered, + heatmap, + next_page: has_more.then_some(page + 1), + }); + } + page += 1; + activities = activity_page(&configuration, &login, page).await?; + } } } +async fn activity_page( + configuration: &apis::configuration::Configuration, + login: &str, + page: i32, +) -> Result> { + apis::user_api::user_list_activity_feeds( + configuration, + login, + Some(true), + None, + Some(page), + Some(DEFAULT_PAGE_SIZE), + ) + .await + .map_err(Error::generated) +} + pub fn target(activity: &models::Activity) -> Option { use models::activity::OpType; @@ -205,4 +271,29 @@ mod tests { }) ); } + + #[test] + fn filters_issue_and_pull_request_activity() { + let activities = [ + activity(OpType::CreateIssue, ""), + activity(OpType::CreatePullRequest, ""), + activity(OpType::PullRequestReadyForReview, ""), + activity(OpType::CreateRepo, ""), + ]; + + assert_eq!( + activities + .iter() + .filter(|activity| ActivityFilter::Issues.matches(activity)) + .count(), + 1 + ); + assert_eq!( + activities + .iter() + .filter(|activity| ActivityFilter::PullRequests.matches(activity)) + .count(), + 2 + ); + } } diff --git a/crates/gitea/src/domain.rs b/crates/gitea/src/domain.rs index 3bc8138..8c3490a 100644 --- a/crates/gitea/src/domain.rs +++ b/crates/gitea/src/domain.rs @@ -164,7 +164,7 @@ pub struct HistoryCommit { pub struct HomeData { pub activities: Vec, pub heatmap: Vec, - pub has_more: bool, + pub next_page: Option, } pub type PullRefs = HashMap>; diff --git a/crates/gitea/src/lib.rs b/crates/gitea/src/lib.rs index f7ed3ee..9e6e60f 100644 --- a/crates/gitea/src/lib.rs +++ b/crates/gitea/src/lib.rs @@ -19,6 +19,7 @@ mod milestones; mod pulls; mod repositories; +pub use activity::ActivityFilter; pub use domain::{ CreateIssue, DEFAULT_PAGE_SIZE, EditIssue, HistoryCommit, HomeData, IssueDetails, IssueDraft, IssueEditorData, IssueQuery, MilestoneDetails, MilestoneDraft, Page, PullDetails, RepositoryId, diff --git a/ios/Generated/gotcha_core.modulemap b/ios/Generated/gotcha_core.modulemap index 8a89753..f1a6b8b 100644 --- a/ios/Generated/gotcha_core.modulemap +++ b/ios/Generated/gotcha_core.modulemap @@ -4,4 +4,4 @@ module gotcha_core { use "Darwin" use "_Builtin_stdbool" use "_Builtin_stdint" -} \ No newline at end of file +} diff --git a/ios/Generated/gotcha_core.swift b/ios/Generated/gotcha_core.swift index e949656..e1361d3 100644 --- a/ios/Generated/gotcha_core.swift +++ b/ios/Generated/gotcha_core.swift @@ -615,7 +615,7 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable { func commits(owner: String, repository: String, branch: String?, path: String, pages: UInt32) async throws -> CommitPage - func home(page: UInt32) async throws -> HomePage + func home(page: UInt32, filter: HomeActivityFilter) async throws -> HomePage func issue(owner: String, repository: String, number: Int64, page: UInt32) async throws -> IssuePage @@ -819,12 +819,12 @@ open func commits(owner: String, repository: String, branch: String?, path: Stri ) } -open func home(page: UInt32)async throws -> HomePage { +open func home(page: UInt32, filter: HomeActivityFilter)async throws -> HomePage { return try await uniffiRustCallAsync( rustFutureFunc: { uniffi_gotcha_core_fn_method_gotchacore_home( - self.uniffiCloneHandle(),FfiConverterUInt32.lower(page) + self.uniffiCloneHandle(),FfiConverterUInt32.lower(page),FfiConverterTypeHomeActivityFilter_lower(filter) ) }, pollFunc: ffi_gotcha_core_rust_future_poll_rust_buffer, @@ -1839,22 +1839,18 @@ public func FfiConverterTypeHeatCell_lower(_ value: HeatCell) -> RustBuffer { public struct HomePage: Equatable, Hashable { public var serverName: String public var activities: [ActivityRow] - public var issueActivities: [ActivityRow] - public var pullActivities: [ActivityRow] public var heatCells: [HeatCell] public var contributionCount: Int64 - public var hasMore: Bool + public var nextPage: UInt32? // Default memberwise initializers are never public by default, so we // declare one manually. - public init(serverName: String, activities: [ActivityRow], issueActivities: [ActivityRow], pullActivities: [ActivityRow], heatCells: [HeatCell], contributionCount: Int64, hasMore: Bool) { + public init(serverName: String, activities: [ActivityRow], heatCells: [HeatCell], contributionCount: Int64, nextPage: UInt32?) { self.serverName = serverName self.activities = activities - self.issueActivities = issueActivities - self.pullActivities = pullActivities self.heatCells = heatCells self.contributionCount = contributionCount - self.hasMore = hasMore + self.nextPage = nextPage } @@ -1875,22 +1871,18 @@ public struct FfiConverterTypeHomePage: FfiConverterRustBuffer { try HomePage( serverName: FfiConverterString.read(from: &buf), activities: FfiConverterSequenceTypeActivityRow.read(from: &buf), - issueActivities: FfiConverterSequenceTypeActivityRow.read(from: &buf), - pullActivities: FfiConverterSequenceTypeActivityRow.read(from: &buf), heatCells: FfiConverterSequenceTypeHeatCell.read(from: &buf), contributionCount: FfiConverterInt64.read(from: &buf), - hasMore: FfiConverterBool.read(from: &buf) + nextPage: FfiConverterOptionUInt32.read(from: &buf) ) } public static func write(_ value: HomePage, into buf: inout [UInt8]) { FfiConverterString.write(value.serverName, into: &buf) FfiConverterSequenceTypeActivityRow.write(value.activities, into: &buf) - FfiConverterSequenceTypeActivityRow.write(value.issueActivities, into: &buf) - FfiConverterSequenceTypeActivityRow.write(value.pullActivities, into: &buf) FfiConverterSequenceTypeHeatCell.write(value.heatCells, into: &buf) FfiConverterInt64.write(value.contributionCount, into: &buf) - FfiConverterBool.write(value.hasMore, into: &buf) + FfiConverterOptionUInt32.write(value.nextPage, into: &buf) } } @@ -3352,6 +3344,79 @@ public func FfiConverterTypeGotchaError_lower(_ value: GotchaError) -> RustBuffe return FfiConverterTypeGotchaError.lower(value) } + + +public enum HomeActivityFilter: Equatable, Hashable { + + case all + case issues + case pullRequests + + + + + +} + +#if compiler(>=6) +extension HomeActivityFilter: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeHomeActivityFilter: FfiConverterRustBuffer { + typealias SwiftType = HomeActivityFilter + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> HomeActivityFilter { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .all + + case 2: return .issues + + case 3: return .pullRequests + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: HomeActivityFilter, into buf: inout [UInt8]) { + switch value { + + + case .all: + writeInt(&buf, Int32(1)) + + + case .issues: + writeInt(&buf, Int32(2)) + + + case .pullRequests: + writeInt(&buf, Int32(3)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeHomeActivityFilter_lift(_ buf: RustBuffer) throws -> HomeActivityFilter { + return try FfiConverterTypeHomeActivityFilter.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeHomeActivityFilter_lower(_ value: HomeActivityFilter) -> RustBuffer { + return FfiConverterTypeHomeActivityFilter.lower(value) +} + + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -3955,7 +4020,7 @@ private let initializationResult: InitializationResult = { if (uniffi_gotcha_core_checksum_method_gotchacore_commits() != 60062) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_method_gotchacore_home() != 38990) { + if (uniffi_gotcha_core_checksum_method_gotchacore_home() != 2797) { return InitializationResult.apiChecksumMismatch } if (uniffi_gotcha_core_checksum_method_gotchacore_issue() != 3108) { @@ -4065,4 +4130,4 @@ public func uniffiEnsureGotchaCoreInitialized() { } } -// swiftlint:enable all +// swiftlint:enable all \ No newline at end of file diff --git a/ios/Generated/gotcha_coreFFI.h b/ios/Generated/gotcha_coreFFI.h index 2ae6d18..14a2159 100644 --- a/ios/Generated/gotcha_coreFFI.h +++ b/ios/Generated/gotcha_coreFFI.h @@ -291,7 +291,7 @@ uint64_t uniffi_gotcha_core_fn_method_gotchacore_commits(uint64_t ptr, RustBuffe #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_HOME #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_HOME -uint64_t uniffi_gotcha_core_fn_method_gotchacore_home(uint64_t ptr, uint32_t page +uint64_t uniffi_gotcha_core_fn_method_gotchacore_home(uint64_t ptr, uint32_t page, RustBuffer filter ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ISSUE diff --git a/ios/Sources/ListScreens.swift b/ios/Sources/ListScreens.swift index 2221763..85ee31e 100644 --- a/ios/Sources/ListScreens.swift +++ b/ios/Sources/ListScreens.swift @@ -321,21 +321,21 @@ final class HomeViewController: RefreshingTableViewController { case all case issues case pulls + + var coreValue: HomeActivityFilter { + switch self { + case .all: return .all + case .issues: return .issues + case .pulls: return .pullRequests + } + } } private let context: AppContext private var page: HomePage? private var filter = ActivityFilter.all - private var currentPage: UInt32 = 0 - - private var activities: [ActivityRow] { - guard let page else { return [] } - switch filter { - case .all: return page.activities - case .issues: return page.issueActivities - case .pulls: return page.pullActivities - } - } + private var nextPage: UInt32? + private var activities: [ActivityRow] { page?.activities ?? [] } init(context: AppContext) { self.context = context @@ -381,7 +381,8 @@ final class HomeViewController: RefreshingTableViewController { } override func loadMoreContent() { - loadPage(currentPage + 1, refreshing: false) + guard let nextPage else { return } + loadPage(nextPage, refreshing: false) } private func loadPage(_ requestedPage: UInt32, refreshing: Bool) { @@ -392,23 +393,25 @@ final class HomeViewController: RefreshingTableViewController { loadingTask?.cancel() loadingTask = Task { do { - let result = try await context.core.home(page: requestedPage) + let result = try await context.core.home( + page: requestedPage, + filter: filter.coreValue + ) if requestedPage == 1 { page = result } else { page?.activities.append(contentsOf: result.activities) - page?.issueActivities.append(contentsOf: result.issueActivities) - page?.pullActivities.append(contentsOf: result.pullActivities) - page?.hasMore = result.hasMore + page?.nextPage = result.nextPage } - currentPage = requestedPage - finishPagination(hasMore: result.hasMore) + nextPage = result.nextPage + finishPagination(hasMore: result.nextPage != nil) title = page?.serverName if requestedPage == 1 { tableView.tableHeaderView = page.map { page in HeatmapView(page: page, selectedFilter: filter.rawValue) { [weak self] index in guard let self, let filter = ActivityFilter(rawValue: index) else { return } + guard filter != self.filter else { return } self.filter = filter - self.updateActivities() + self.loadPage(1, refreshing: false) } } } updateActivities()