Add native iOS notifications (#64)

This commit is contained in:
Georg Bauer
2026-08-15 12:50:05 +02:00
parent 69d1709523
commit ced28ba1e6
23 changed files with 1589 additions and 22 deletions

View File

@@ -41,6 +41,35 @@ pub async fn load_repositories(server: &Server, page: i32) -> Result<Page<Reposi
})
}
pub async fn load_notifications(
server: &Server,
unread: bool,
page: i32,
) -> Result<Page<models::NotificationThread>, String> {
client(server)?
.notifications(unread, page, PAGE_SIZE)
.await
.map_err(message)
}
pub async fn load_notification_updates(
server: &Server,
since: Option<&str>,
) -> Result<Vec<models::NotificationThread>, String> {
client(server)?
.notification_updates(since)
.await
.map_err(message)
}
pub async fn mark_notification_read(server: &Server, id: i64) -> Result<(), String> {
client(server)?
.mark_notification_read(id)
.await
.map(|_| ())
.map_err(message)
}
pub async fn load_issues(
server: &Server,
owner: &str,

View File

@@ -1,6 +1,7 @@
mod content;
mod issues;
mod milestones;
mod notifications;
mod pulls;
mod repositories;
mod servers;

View File

@@ -0,0 +1,137 @@
use std::time::{SystemTime, UNIX_EPOCH};
use crate::*;
#[uniffi::export(async_runtime = "tokio")]
impl GotchaCore {
pub async fn notifications(
&self,
status: NotificationStatus,
page: u32,
) -> Result<NotificationListPage, GotchaError> {
let server = self.server()?;
let result = load_notifications(
&server,
matches!(status, NotificationStatus::Open),
valid_page(page)?,
)
.await?;
Ok(NotificationListPage {
rows: notification_rows(&server.credential_account, result.items),
has_more: result.has_more,
})
}
pub async fn mark_notification_read(
&self,
server_id: String,
id: i64,
) -> Result<(), GotchaError> {
let server = self.server_by_id(&server_id)?;
mark_notification_read(&server, id)
.await
.map_err(Into::into)
}
pub async fn poll_notifications(&self) -> Result<Vec<NotificationRow>, GotchaError> {
let servers = {
let state = self.state.lock().unwrap();
if !state.preferences.notifications_enabled {
return Ok(Vec::new());
}
state
.preferences
.servers
.iter()
.cloned()
.map(|server| {
let cursor = state
.preferences
.notification_cursors
.get(&server.credential_account)
.cloned();
(server, cursor)
})
.collect::<Vec<_>>()
};
let polled_at = gotcha_gitea::api_timestamp(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64,
);
let mut rows = Vec::new();
let mut cursors = Vec::new();
let mut first_error = None;
for (server, cursor) in servers {
let notifications = match load_notification_updates(&server, cursor.as_deref()).await {
Ok(notifications) => notifications,
Err(error) => {
first_error.get_or_insert(error);
continue;
}
};
let next_cursor = notification_cursor(&polled_at, &notifications);
if cursor.is_some() {
rows.extend(notification_rows(&server.credential_account, notifications));
}
cursors.push((server.credential_account, next_cursor));
}
if cursors.is_empty()
&& let Some(error) = first_error
{
return Err(error.into());
}
let mut state = self.state.lock().unwrap();
if !state.preferences.notifications_enabled {
return Ok(Vec::new());
}
for (server_id, cursor) in cursors {
if state
.preferences
.servers
.iter()
.any(|server| server.credential_account == server_id)
{
state
.preferences
.notification_cursors
.insert(server_id, cursor);
}
}
save_preferences(&state.preferences)?;
Ok(rows)
}
}
fn notification_cursor(
polled_at: &str,
notifications: &[gotcha_gitea::models::NotificationThread],
) -> String {
notifications
.iter()
.filter_map(|notification| notification.updated_at.as_deref())
.fold(polled_at.into(), |cursor, updated| {
cursor.max(updated.into())
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cursor_does_not_skip_an_update_that_arrived_during_polling() {
let notifications = vec![gotcha_gitea::models::NotificationThread {
updated_at: Some("2026-08-15T10:00:01Z".into()),
..Default::default()
}];
assert_eq!(
notification_cursor("2026-08-15T10:00:00Z", &notifications),
"2026-08-15T10:00:01Z"
);
}
}

View File

@@ -192,6 +192,9 @@ impl GotchaCore {
.ok_or("That server no longer exists.")?;
let mut preferences = state.preferences.clone();
preferences.servers.remove(index);
preferences
.notification_cursors
.remove(&server.credential_account);
let active_server =
active_server_after_removal(state.active_server, index, preferences.servers.len());
preferences.last_server = active_server;
@@ -224,6 +227,7 @@ impl GotchaCore {
issue_status: state.preferences.issue_status.clone(),
pull_status: state.preferences.pull_status.clone(),
appearance: state.preferences.appearance.index() as u32,
notifications_enabled: state.preferences.notifications_enabled,
}
}
@@ -253,6 +257,12 @@ impl GotchaCore {
save_preferences(&state.preferences).map_err(Into::into)
}
pub fn set_notifications_enabled(&self, enabled: bool) -> Result<(), GotchaError> {
let mut state = self.state.lock().unwrap();
state.preferences.notifications_enabled = enabled;
save_preferences(&state.preferences).map_err(Into::into)
}
pub async fn home(
&self,
page: u32,

View File

@@ -70,6 +70,10 @@ pub struct Preferences {
pub pull_filters: BTreeMap<String, PullFilter>,
#[serde(default)]
pub appearance: AppearanceMode,
#[serde(default)]
pub notifications_enabled: bool,
#[serde(default)]
pub notification_cursors: BTreeMap<String, String>,
}
impl Default for Preferences {
@@ -84,6 +88,8 @@ impl Default for Preferences {
pull_status: open_status(),
pull_filters: BTreeMap::new(),
appearance: AppearanceMode::default(),
notifications_enabled: false,
notification_cursors: BTreeMap::new(),
}
}
}

View File

@@ -80,6 +80,7 @@ pub struct Settings {
pub issue_status: String,
pub pull_status: String,
pub appearance: u32,
pub notifications_enabled: bool,
}
#[derive(uniffi::Object)]

View File

@@ -359,6 +359,33 @@ pub struct HomePage {
pub next_page: Option<u32>,
}
#[derive(Clone, Copy, Debug, uniffi::Enum)]
pub enum NotificationStatus {
Open,
Closed,
}
#[derive(Clone, uniffi::Record)]
pub struct NotificationRow {
pub id: i64,
pub server_id: String,
pub title: String,
pub detail: String,
pub meta: String,
pub unread: bool,
pub target: ActivityTargetKind,
pub owner: String,
pub repository: String,
pub number: i64,
pub sha: String,
}
#[derive(Clone, uniffi::Record)]
pub struct NotificationListPage {
pub rows: Vec<NotificationRow>,
pub has_more: bool,
}
#[derive(Clone, uniffi::Record)]
pub struct WidgetActivityPage {
pub server_name: String,
@@ -376,12 +403,14 @@ mod files;
mod helpers;
mod home;
mod lists;
mod notifications;
pub use details::*;
pub use files::*;
pub use helpers::compact_date;
pub use home::*;
pub use lists::*;
pub use notifications::*;
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,142 @@
use gotcha_gitea::{models, notifications};
use super::{ActivityTargetKind, NotificationRow, compact_date};
pub fn notification_rows(
server_id: &str,
notifications: Vec<models::NotificationThread>,
) -> Vec<NotificationRow> {
notifications
.into_iter()
.filter_map(|notification| notification_row(server_id, notification))
.collect()
}
fn notification_row(
server_id: &str,
notification: models::NotificationThread,
) -> Option<NotificationRow> {
let id = notification.id?;
let title = notification
.subject
.as_deref()
.and_then(|subject| subject.title.clone())
.filter(|title| !title.is_empty())
.unwrap_or_else(|| "Server notification".into());
let kind = notification
.subject
.as_deref()
.and_then(|subject| subject.r#type.as_deref())
.unwrap_or("Update");
let full_name = notification
.repository
.as_deref()
.and_then(|repository| repository.full_name.clone())
.or_else(|| {
let repository = notification.repository.as_deref()?;
Some(format!(
"{}/{}",
repository.owner.as_deref()?.login.as_deref()?,
repository.name.as_deref()?
))
})
.unwrap_or_else(|| "Server".into());
let (target, owner, repository, number, sha) = match notifications::target(&notification) {
notifications::NotificationTarget::None => (
ActivityTargetKind::None,
String::new(),
String::new(),
0,
String::new(),
),
notifications::NotificationTarget::Repository { owner, repository } => (
ActivityTargetKind::Repository,
owner,
repository,
0,
String::new(),
),
notifications::NotificationTarget::Issue {
owner,
repository,
number,
} => (
ActivityTargetKind::Issue,
owner,
repository,
number,
String::new(),
),
notifications::NotificationTarget::Pull {
owner,
repository,
number,
} => (
ActivityTargetKind::PullRequest,
owner,
repository,
number,
String::new(),
),
notifications::NotificationTarget::Commit {
owner,
repository,
sha,
} => (ActivityTargetKind::Commit, owner, repository, 0, sha),
};
Some(NotificationRow {
id,
server_id: server_id.into(),
title,
detail: format!("{kind} · {full_name}"),
meta: compact_date(notification.updated_at.as_deref()),
unread: notification.unread.unwrap_or_default(),
target,
owner,
repository,
number,
sha,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn presents_targetable_notification_without_exposing_api_models() {
let rows = notification_rows(
"work",
vec![models::NotificationThread {
id: Some(9),
unread: Some(true),
updated_at: Some("2026-08-15T10:00:00Z".into()),
repository: Some(Box::new(models::Repository {
full_name: Some("octo/demo".into()),
name: Some("demo".into()),
owner: Some(Box::new(models::User {
login: Some("octo".into()),
..Default::default()
})),
..Default::default()
})),
subject: Some(Box::new(models::NotificationSubject {
title: Some("Fix the bug".into()),
r#type: Some("Issue".into()),
url: Some("https://example/api/v1/repos/octo/demo/issues/42".into()),
..Default::default()
})),
..Default::default()
}],
);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].id, 9);
assert_eq!(rows[0].server_id, "work");
assert_eq!(rows[0].title, "Fix the bug");
assert_eq!(rows[0].detail, "Issue · octo/demo");
assert!(rows[0].unread);
assert_eq!(rows[0].target, ActivityTargetKind::Issue);
assert_eq!(rows[0].number, 42);
}
}