Add native iOS notifications (#64)

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

View File

@@ -174,6 +174,16 @@ pub fn api_date(timestamp: i64) -> String {
format!("{year:04}-{month:02}-{day:02}T00:00:00Z")
}
pub fn api_timestamp(timestamp: i64) -> String {
let days = timestamp.div_euclid(86_400);
let seconds = timestamp.rem_euclid(86_400);
let (year, month, day) = civil_from_days(days);
let hour = seconds / 3_600;
let minute = seconds % 3_600 / 60;
let second = seconds % 60;
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
}
pub fn parse_api_date(value: &str) -> Option<i64> {
let date = value.get(..10)?;
let mut parts = date.split('-');
@@ -230,6 +240,7 @@ mod tests {
assert!(!Page::from_items(vec![(); 29], 30).has_more);
let leap_day = parse_api_date("2024-02-29T12:34:56Z").unwrap();
assert_eq!(api_date(leap_day), "2024-02-29T00:00:00Z");
assert_eq!(api_timestamp(leap_day + 45_296), "2024-02-29T12:34:56Z");
assert_eq!(parse_api_date("2023-02-29T00:00:00Z"), None);
}
}

View File

@@ -18,6 +18,7 @@ pub mod diff;
mod domain;
mod issues;
mod milestones;
pub mod notifications;
mod pulls;
mod repositories;
@@ -27,7 +28,7 @@ pub use config::{Config, Selection, ServerProfile, TuiPreferences, server_url};
pub use domain::{
CreateIssue, DEFAULT_PAGE_SIZE, EditIssue, HistoryCommit, HomeData, IssueDetails, IssueDraft,
IssueEditorData, IssueQuery, MilestoneDetails, MilestoneDraft, Page, PullDetails, RepositoryId,
api_date, civil_from_days, days_from_civil, parse_api_date,
api_date, api_timestamp, civil_from_days, days_from_civil, parse_api_date,
};
pub use issues::comment_can_edit;
pub use pulls::{PullFileSource, pull_file_source, pull_state};

View File

@@ -0,0 +1,212 @@
use gitea_openapi::apis::notification_api;
use crate::{Client, Error, Page, Result, models, positive, validate_page};
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum NotificationTarget {
None,
Repository {
owner: String,
repository: String,
},
Issue {
owner: String,
repository: String,
number: i64,
},
Pull {
owner: String,
repository: String,
number: i64,
},
Commit {
owner: String,
repository: String,
sha: String,
},
}
impl Client {
pub async fn notifications(
&self,
unread: bool,
page: i32,
limit: i32,
) -> Result<Page<models::NotificationThread>> {
validate_page(page, limit)?;
let items = notification_api::notify_get_list(
&self.configuration(),
Some(!unread),
Some(vec![if unread { "unread" } else { "read" }.into()]),
None,
None,
None,
Some(page),
Some(limit),
)
.await
.map_err(Error::generated)?;
Ok(Page::from_items(items, limit))
}
pub async fn notification_updates(
&self,
since: Option<&str>,
) -> Result<Vec<models::NotificationThread>> {
let mut notifications = Vec::new();
for page in 1.. {
let batch = notification_api::notify_get_list(
&self.configuration(),
Some(false),
Some(vec!["unread".into()]),
None,
since.map(str::to_owned),
None,
Some(page),
Some(100),
)
.await
.map_err(Error::generated)?;
let complete = batch.len() < 100;
notifications.extend(batch);
if complete {
break;
}
}
Ok(notifications)
}
pub async fn mark_notification_read(&self, id: i64) -> Result<models::NotificationThread> {
positive(id, "notification ID")?;
notification_api::notify_read_thread(&self.configuration(), &id.to_string(), Some("read"))
.await
.map_err(Error::generated)
}
}
pub fn target(notification: &models::NotificationThread) -> NotificationTarget {
let Some(repository) = notification.repository.as_deref() else {
return NotificationTarget::None;
};
let Some(owner) = repository
.owner
.as_deref()
.and_then(|owner| owner.login.clone())
else {
return NotificationTarget::None;
};
let Some(repository) = repository.name.clone() else {
return NotificationTarget::None;
};
let Some(subject) = notification.subject.as_deref() else {
return NotificationTarget::Repository { owner, repository };
};
let value = subject
.url
.as_deref()
.or(subject.html_url.as_deref())
.and_then(last_path_component);
match subject
.r#type
.as_deref()
.unwrap_or_default()
.to_ascii_lowercase()
.as_str()
{
"issue" => {
value
.and_then(|value| value.parse().ok())
.map_or(NotificationTarget::None, |number| {
NotificationTarget::Issue {
owner,
repository,
number,
}
})
}
"pull" | "pullrequest" | "pull_request" => value
.and_then(|value| value.parse().ok())
.map_or(NotificationTarget::None, |number| {
NotificationTarget::Pull {
owner,
repository,
number,
}
}),
"commit" => value.map_or(NotificationTarget::None, |sha| NotificationTarget::Commit {
owner,
repository,
sha: sha.into(),
}),
"repository" => NotificationTarget::Repository { owner, repository },
_ => NotificationTarget::None,
}
}
fn last_path_component(url: &str) -> Option<&str> {
url.trim_end_matches('/')
.rsplit('/')
.next()
.filter(|value| !value.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
fn notification(kind: &str, url: &str) -> models::NotificationThread {
models::NotificationThread {
repository: Some(Box::new(models::Repository {
name: Some("demo".into()),
owner: Some(Box::new(models::User {
login: Some("octo".into()),
..Default::default()
})),
..Default::default()
})),
subject: Some(Box::new(models::NotificationSubject {
r#type: Some(kind.into()),
url: Some(url.into()),
..Default::default()
})),
..Default::default()
}
}
#[test]
fn maps_notification_subjects_to_native_destinations() {
assert_eq!(
target(&notification(
"Issue",
"https://gitea.example/api/v1/repos/octo/demo/issues/42"
)),
NotificationTarget::Issue {
owner: "octo".into(),
repository: "demo".into(),
number: 42,
}
);
assert_eq!(
target(&notification(
"Pull",
"https://gitea.example/api/v1/repos/octo/demo/pulls/7"
)),
NotificationTarget::Pull {
owner: "octo".into(),
repository: "demo".into(),
number: 7,
}
);
assert_eq!(
target(&notification(
"Commit",
"https://gitea.example/api/v1/repos/octo/demo/git/commits/deadbeef"
)),
NotificationTarget::Commit {
owner: "octo".into(),
repository: "demo".into(),
sha: "deadbeef".into(),
}
);
}
}