Add full-server activity to iOS home (#65)
This commit is contained in:
@@ -459,6 +459,21 @@ pub async fn load_activities(server: &Server, page: i32) -> Result<Page<models::
|
||||
.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> {
|
||||
Client::with_provider(&server.url, Some(&server.token), server.provider).map_err(message)
|
||||
}
|
||||
|
||||
@@ -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<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 {
|
||||
|
||||
@@ -86,9 +86,16 @@ pub struct Settings {
|
||||
#[derive(uniffi::Object)]
|
||||
pub struct GotchaCore {
|
||||
state: Mutex<State>,
|
||||
server_activity: tokio::sync::Mutex<Option<ServerActivitySession>>,
|
||||
startup_error: Option<String>,
|
||||
}
|
||||
|
||||
struct ServerActivitySession {
|
||||
server_id: String,
|
||||
next_page: u32,
|
||||
pager: gotcha_gitea::ServerActivityPager,
|
||||
}
|
||||
|
||||
fn validate_repository<'a>(
|
||||
owner: &'a str,
|
||||
repository: &'a str,
|
||||
|
||||
@@ -359,6 +359,12 @@ pub struct HomePage {
|
||||
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)]
|
||||
pub enum NotificationStatus {
|
||||
Open,
|
||||
|
||||
@@ -15,6 +15,26 @@ pub fn activity_rows(activities: &[models::Activity]) -> Vec<ActivityRow> {
|
||||
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 {
|
||||
use models::activity::OpType;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<UserActivityFeed>,
|
||||
}
|
||||
|
||||
struct UserActivityFeed {
|
||||
login: String,
|
||||
activities: VecDeque<models::Activity>,
|
||||
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<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(
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user