Load filtered home activity streams

This commit is contained in:
Georg Bauer
2026-07-31 17:33:45 +02:00
parent 822cf1f769
commit dba6359d30
11 changed files with 258 additions and 118 deletions

View File

@@ -134,7 +134,8 @@ xcrun simctl launch booted de.rfc1437.gotcha
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.
- [ ] 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. 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 - [ ] Tap repository, issue, pull-request, and commit activity. Each opens the
matching native tab and destination. matching native tab and destination.
- [ ] Non-linkable server activity does not navigate or appear tappable. - [ ] Non-linkable server activity does not navigate or appear tappable.

View File

@@ -355,8 +355,12 @@ pub async fn load_pull_diff(
Ok(diff::parse_file(&diff, path)) Ok(diff::parse_file(&diff, path))
} }
pub async fn load_home(server: &Server, page: i32) -> Result<HomeData, String> { pub async fn load_home(
client(server)?.home(page).await.map_err(message) server: &Server,
page: i32,
filter: gotcha_gitea::ActivityFilter,
) -> Result<HomeData, String> {
client(server)?.home(page, filter).await.map_err(message)
} }
fn client(server: &Server) -> Result<Client, String> { fn client(server: &Server) -> Result<Client, String> {

View File

@@ -171,12 +171,16 @@ impl GotchaCore {
save_preferences(&state.preferences).map_err(Into::into) save_preferences(&state.preferences).map_err(Into::into)
} }
pub async fn home(&self, page: u32) -> Result<HomePage, GotchaError> { pub async fn home(
&self,
page: u32,
filter: HomeActivityFilter,
) -> Result<HomePage, GotchaError> {
let server = self.server()?; let server = self.server()?;
let name = server.name.clone(); let name = server.name.clone();
Ok(home_page( Ok(home_page(
name, name,
load_home(&server, valid_page(page)?).await?, load_home(&server, valid_page(page)?, filter.into()).await?,
)) ))
} }

View File

@@ -245,6 +245,23 @@ pub struct ActivityRow {
pub sha: String, pub sha: String,
} }
#[derive(Clone, Copy, Debug, uniffi::Enum)]
pub enum HomeActivityFilter {
All,
Issues,
PullRequests,
}
impl From<HomeActivityFilter> 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)] #[derive(Clone, uniffi::Record)]
pub struct HeatCell { pub struct HeatCell {
pub level: u32, pub level: u32,
@@ -255,11 +272,9 @@ pub struct HeatCell {
pub struct HomePage { pub struct HomePage {
pub server_name: String, pub server_name: String,
pub activities: Vec<ActivityRow>, pub activities: Vec<ActivityRow>,
pub issue_activities: Vec<ActivityRow>,
pub pull_activities: Vec<ActivityRow>,
pub heat_cells: Vec<HeatCell>, pub heat_cells: Vec<HeatCell>,
pub contribution_count: i64, pub contribution_count: i64,
pub has_more: bool, pub next_page: Option<u32>,
} }
pub fn repository_rows( 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); let (heat_cells, contribution_count) = heat_cells(&home.heatmap);
HomePage { HomePage {
server_name, 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(), activities: home.activities.iter().map(activity_row).collect(),
heat_cells, heat_cells,
contribution_count, 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 { fn activity_row(activity: &models::Activity) -> ActivityRow {
use models::activity::OpType; use models::activity::OpType;
@@ -1263,31 +1239,26 @@ mod tests {
} }
#[test] #[test]
fn categorizes_home_activity_before_crossing_the_bridge() { fn presents_filtered_home_activity_and_continuation() {
use models::activity::OpType; use models::activity::OpType;
let page = home_page( let page = home_page(
"Gitea".into(), "Gitea".into(),
HomeData { HomeData {
activities: [ activities: [OpType::CreatePullRequest, OpType::ClosePullRequest]
OpType::CreateIssue, .into_iter()
OpType::CreatePullRequest, .map(|op_type| models::Activity {
OpType::CreateRepo, op_type: Some(op_type),
] ..Default::default()
.into_iter() })
.map(|op_type| models::Activity { .collect(),
op_type: Some(op_type),
..Default::default()
})
.collect(),
heatmap: Vec::new(), heatmap: Vec::new(),
has_more: false, next_page: Some(4),
}, },
); );
assert_eq!(page.activities.len(), 3); assert_eq!(page.activities.len(), 2);
assert_eq!(page.issue_activities.len(), 1); assert_eq!(page.next_page, Some(4));
assert_eq!(page.pull_activities.len(), 1);
} }
#[test] #[test]

View File

@@ -5,6 +5,48 @@ use crate::{
}; };
use gitea_openapi::apis; 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)] #[derive(Debug, Eq, PartialEq)]
pub enum Target { pub enum Target {
Repository { Repository {
@@ -29,7 +71,7 @@ pub enum Target {
} }
impl Client { impl Client {
pub async fn home(&self, page: i32) -> Result<HomeData> { pub async fn home(&self, page: i32, filter: ActivityFilter) -> Result<HomeData> {
if page < 1 { if page < 1 {
return Err(Error::InvalidInput("page must be positive".into())); return Err(Error::InvalidInput("page must be positive".into()));
} }
@@ -39,27 +81,51 @@ impl Client {
.await? .await?
.login .login
.ok_or_else(|| Error::Generated("The server account has no username.".into()))?; .ok_or_else(|| Error::Generated("The server account has no username.".into()))?;
let activities = apis::user_api::user_list_activity_feeds( let activities = activity_page(&configuration, &login, page);
&configuration,
&login,
Some(true),
None,
Some(page),
Some(DEFAULT_PAGE_SIZE),
);
let (activities, heatmap) = tokio::join!( let (activities, heatmap) = tokio::join!(
activities, activities,
apis::user_api::user_get_heatmap_data(&configuration, &login), apis::user_api::user_get_heatmap_data(&configuration, &login),
); );
let activities = activities.map_err(Error::generated)?; let mut activities = activities?;
Ok(HomeData { let heatmap = heatmap.map_err(Error::generated)?;
has_more: activities.len() == DEFAULT_PAGE_SIZE as usize, let mut page = page;
activities,
heatmap: heatmap.map_err(Error::generated)?, 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<Vec<models::Activity>> {
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<Target> { pub fn target(activity: &models::Activity) -> Option<Target> {
use models::activity::OpType; 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
);
}
} }

View File

@@ -164,7 +164,7 @@ pub struct HistoryCommit {
pub struct HomeData { pub struct HomeData {
pub activities: Vec<models::Activity>, pub activities: Vec<models::Activity>,
pub heatmap: Vec<models::UserHeatmapData>, pub heatmap: Vec<models::UserHeatmapData>,
pub has_more: bool, pub next_page: Option<i32>,
} }
pub type PullRefs = HashMap<String, Vec<String>>; pub type PullRefs = HashMap<String, Vec<String>>;

View File

@@ -19,6 +19,7 @@ mod milestones;
mod pulls; mod pulls;
mod repositories; mod repositories;
pub use activity::ActivityFilter;
pub use domain::{ pub use domain::{
CreateIssue, DEFAULT_PAGE_SIZE, EditIssue, HistoryCommit, HomeData, IssueDetails, IssueDraft, CreateIssue, DEFAULT_PAGE_SIZE, EditIssue, HistoryCommit, HomeData, IssueDetails, IssueDraft,
IssueEditorData, IssueQuery, MilestoneDetails, MilestoneDraft, Page, PullDetails, RepositoryId, IssueEditorData, IssueQuery, MilestoneDetails, MilestoneDraft, Page, PullDetails, RepositoryId,

View File

@@ -4,4 +4,4 @@ module gotcha_core {
use "Darwin" use "Darwin"
use "_Builtin_stdbool" use "_Builtin_stdbool"
use "_Builtin_stdint" use "_Builtin_stdint"
} }

View File

@@ -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 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 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 return
try await uniffiRustCallAsync( try await uniffiRustCallAsync(
rustFutureFunc: { rustFutureFunc: {
uniffi_gotcha_core_fn_method_gotchacore_home( 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, 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 struct HomePage: Equatable, Hashable {
public var serverName: String public var serverName: String
public var activities: [ActivityRow] public var activities: [ActivityRow]
public var issueActivities: [ActivityRow]
public var pullActivities: [ActivityRow]
public var heatCells: [HeatCell] public var heatCells: [HeatCell]
public var contributionCount: Int64 public var contributionCount: Int64
public var hasMore: Bool public var nextPage: UInt32?
// Default memberwise initializers are never public by default, so we // Default memberwise initializers are never public by default, so we
// declare one manually. // 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.serverName = serverName
self.activities = activities self.activities = activities
self.issueActivities = issueActivities
self.pullActivities = pullActivities
self.heatCells = heatCells self.heatCells = heatCells
self.contributionCount = contributionCount self.contributionCount = contributionCount
self.hasMore = hasMore self.nextPage = nextPage
} }
@@ -1875,22 +1871,18 @@ public struct FfiConverterTypeHomePage: FfiConverterRustBuffer {
try HomePage( try HomePage(
serverName: FfiConverterString.read(from: &buf), serverName: FfiConverterString.read(from: &buf),
activities: FfiConverterSequenceTypeActivityRow.read(from: &buf), activities: FfiConverterSequenceTypeActivityRow.read(from: &buf),
issueActivities: FfiConverterSequenceTypeActivityRow.read(from: &buf),
pullActivities: FfiConverterSequenceTypeActivityRow.read(from: &buf),
heatCells: FfiConverterSequenceTypeHeatCell.read(from: &buf), heatCells: FfiConverterSequenceTypeHeatCell.read(from: &buf),
contributionCount: FfiConverterInt64.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]) { public static func write(_ value: HomePage, into buf: inout [UInt8]) {
FfiConverterString.write(value.serverName, into: &buf) FfiConverterString.write(value.serverName, into: &buf)
FfiConverterSequenceTypeActivityRow.write(value.activities, 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) FfiConverterSequenceTypeHeatCell.write(value.heatCells, into: &buf)
FfiConverterInt64.write(value.contributionCount, 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) 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) #if swift(>=5.8)
@_documentation(visibility: private) @_documentation(visibility: private)
#endif #endif
@@ -3955,7 +4020,7 @@ private let initializationResult: InitializationResult = {
if (uniffi_gotcha_core_checksum_method_gotchacore_commits() != 60062) { if (uniffi_gotcha_core_checksum_method_gotchacore_commits() != 60062) {
return InitializationResult.apiChecksumMismatch return InitializationResult.apiChecksumMismatch
} }
if (uniffi_gotcha_core_checksum_method_gotchacore_home() != 38990) { if (uniffi_gotcha_core_checksum_method_gotchacore_home() != 2797) {
return InitializationResult.apiChecksumMismatch return InitializationResult.apiChecksumMismatch
} }
if (uniffi_gotcha_core_checksum_method_gotchacore_issue() != 3108) { if (uniffi_gotcha_core_checksum_method_gotchacore_issue() != 3108) {
@@ -4065,4 +4130,4 @@ public func uniffiEnsureGotchaCoreInitialized() {
} }
} }
// swiftlint:enable all // swiftlint:enable all

View File

@@ -291,7 +291,7 @@ uint64_t uniffi_gotcha_core_fn_method_gotchacore_commits(uint64_t ptr, RustBuffe
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_HOME #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_HOME
#define 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 #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ISSUE #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ISSUE

View File

@@ -321,21 +321,21 @@ final class HomeViewController: RefreshingTableViewController {
case all case all
case issues case issues
case pulls case pulls
var coreValue: HomeActivityFilter {
switch self {
case .all: return .all
case .issues: return .issues
case .pulls: return .pullRequests
}
}
} }
private let context: AppContext private let context: AppContext
private var page: HomePage? private var page: HomePage?
private var filter = ActivityFilter.all private var filter = ActivityFilter.all
private var currentPage: UInt32 = 0 private var nextPage: UInt32?
private var activities: [ActivityRow] { page?.activities ?? [] }
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
}
}
init(context: AppContext) { init(context: AppContext) {
self.context = context self.context = context
@@ -381,7 +381,8 @@ final class HomeViewController: RefreshingTableViewController {
} }
override func loadMoreContent() { override func loadMoreContent() {
loadPage(currentPage + 1, refreshing: false) guard let nextPage else { return }
loadPage(nextPage, refreshing: false)
} }
private func loadPage(_ requestedPage: UInt32, refreshing: Bool) { private func loadPage(_ requestedPage: UInt32, refreshing: Bool) {
@@ -392,23 +393,25 @@ final class HomeViewController: RefreshingTableViewController {
loadingTask?.cancel() loadingTask?.cancel()
loadingTask = Task { loadingTask = Task {
do { 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 { if requestedPage == 1 {
page = result page = result
} else { } else {
page?.activities.append(contentsOf: result.activities) page?.activities.append(contentsOf: result.activities)
page?.issueActivities.append(contentsOf: result.issueActivities) page?.nextPage = result.nextPage
page?.pullActivities.append(contentsOf: result.pullActivities)
page?.hasMore = result.hasMore
} }
currentPage = requestedPage nextPage = result.nextPage
finishPagination(hasMore: result.hasMore) finishPagination(hasMore: result.nextPage != nil)
title = page?.serverName title = page?.serverName
if requestedPage == 1 { tableView.tableHeaderView = page.map { page in if requestedPage == 1 { tableView.tableHeaderView = page.map { page in
HeatmapView(page: page, selectedFilter: filter.rawValue) { [weak self] index in HeatmapView(page: page, selectedFilter: filter.rawValue) { [weak self] index in
guard let self, let filter = ActivityFilter(rawValue: index) else { return } guard let self, let filter = ActivityFilter(rawValue: index) else { return }
guard filter != self.filter else { return }
self.filter = filter self.filter = filter
self.updateActivities() self.loadPage(1, refreshing: false)
} }
} } } }
updateActivities() updateActivities()