Add full-server activity to iOS home (#65)
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
10
TESTING.md
10
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
|
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.
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 */,
|
||||||
|
|||||||
@@ -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,7 +180,9 @@ final class HomeViewController: RefreshingTableViewController {
|
|||||||
: nil
|
: nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func symbolName(for icon: ActivityIcon) -> String {
|
}
|
||||||
|
|
||||||
|
func activitySymbolName(for icon: ActivityIcon) -> String {
|
||||||
switch icon {
|
switch icon {
|
||||||
case .pullRequest: return "arrow.triangle.pull"
|
case .pullRequest: return "arrow.triangle.pull"
|
||||||
case .issue: return "exclamationmark.circle"
|
case .issue: return "exclamationmark.circle"
|
||||||
@@ -183,7 +192,6 @@ final class HomeViewController: RefreshingTableViewController {
|
|||||||
case .release: return "shippingbox"
|
case .release: return "shippingbox"
|
||||||
case .repository: return "books.vertical"
|
case .repository: return "books.vertical"
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final class HeatmapView: UIView {
|
final class HeatmapView: UIView {
|
||||||
@@ -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() }
|
||||||
|
|||||||
104
ios/Sources/ServerActivityScreen.swift
Normal file
104
ios/Sources/ServerActivityScreen.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user