Add native iOS notifications (#64)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod content;
|
||||
mod issues;
|
||||
mod milestones;
|
||||
mod notifications;
|
||||
mod pulls;
|
||||
mod repositories;
|
||||
mod servers;
|
||||
|
||||
137
crates/app/src/core/notifications.rs
Normal file
137
crates/app/src/core/notifications.rs
Normal 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, ¬ifications);
|
||||
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", ¬ifications),
|
||||
"2026-08-15T10:00:01Z"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
142
crates/app/src/presentation/notifications.rs
Normal file
142
crates/app/src/presentation/notifications.rs
Normal 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(¬ification) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user