diff --git a/TESTING.md b/TESTING.md index 85c9f20..e6763c9 100644 --- a/TESTING.md +++ b/TESTING.md @@ -243,6 +243,7 @@ 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 issue and pull-request icons in turn; each shows only matching recent activity or the native empty state, then the clock restores all rows. + The bell remains a separate, accessible Notifications destination. - [ ] Pull-request activity includes older creation and close events beyond the first activity-feed page without requiring a manual pull-up; the empty state appears only after every available page has been checked. @@ -251,6 +252,45 @@ source update are published, and installs or updates Gotcha through AltStore PAL Back button and left-edge interactive swipe return directly to Home. - [ ] Non-linkable server activity does not navigate or appear tappable. +## Notifications + +- [ ] Launch a fresh install. Gotcha does not request notification permission + at launch. Open Settings and turn on **Background notifications**; only + then does the standard iOS authorization sheet appear. Deny once and + confirm Gotcha keeps the switch off, explains that notifications are + disabled, and offers **Open Settings**. The **Notification Settings** row + opens Gotcha's page in iOS Settings. Opening Settings and waiting for the + authorization status to load must leave scrolling, tabs, and the switch + responsive without repeated cell layout or a frozen app. +- [ ] Allow notifications. Settings reports the current system authorization, + the app switch persists across relaunches, and iOS Settings remains the + source of truth for alerts, sounds, Focus, and scheduled summaries. + Turning the app switch off cancels pending alerts and background refresh. +- [ ] Tap the Home bell. Open and Closed each show the matching server + notification threads with a type icon, title, repository, date, Dynamic + Type layout, and accessible Open/Closed value. Pull to refresh, switch + status repeatedly, and load a list longer than one page without duplicate + or stale rows. +- [ ] Tap open and closed issue, pull-request, commit, and repository + notifications. Each opens the native destination on the Home navigation + stack; opening an unread thread marks it read, Back returns to the + notification list, and the next refresh moves it from Open to Closed. +- [ ] After the first poll establishes a cursor, create or receive another + server notification. Launch the Debug simulator build with + `xcrun simctl launch booted de.rfc1437.gotcha --validate-background-notifications`, + then background Gotcha. This explicit Debug-only mode grants the poll the + same finite background execution time as an ordinary app transition; + production builds continue to rely exclusively on `BGAppRefreshTask`. + Confirm one ordinary-priority local notification is delivered with no + private issue title or repository name on the Lock Screen. A repeated + update replaces the same thread alert rather than stacking duplicates; + tapping it selects the correct configured server, opens the originating + item, and marks the Gitea thread read. +- [ ] Bring Gotcha to the foreground while a poll finds an update. It refreshes + notification data without displaying a banner over the active app. Leave + it backgrounded and confirm iOS, not an in-app timer, chooses subsequent + refresh timing. + ## Milestone navigation - [ ] Open a milestone, then open one of its issues and one of its pull diff --git a/crates/app/src/api.rs b/crates/app/src/api.rs index 27b1cfd..d0d2a97 100644 --- a/crates/app/src/api.rs +++ b/crates/app/src/api.rs @@ -41,6 +41,35 @@ pub async fn load_repositories(server: &Server, page: i32) -> Result Result, String> { + client(server)? + .notifications(unread, page, PAGE_SIZE) + .await + .map_err(message) +} + +pub async fn load_notification_updates( + server: &Server, + since: Option<&str>, +) -> Result, 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, diff --git a/crates/app/src/core/mod.rs b/crates/app/src/core/mod.rs index c939ee8..42878f0 100644 --- a/crates/app/src/core/mod.rs +++ b/crates/app/src/core/mod.rs @@ -1,6 +1,7 @@ mod content; mod issues; mod milestones; +mod notifications; mod pulls; mod repositories; mod servers; diff --git a/crates/app/src/core/notifications.rs b/crates/app/src/core/notifications.rs new file mode 100644 index 0000000..eef26dd --- /dev/null +++ b/crates/app/src/core/notifications.rs @@ -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 { + 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, 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::>() + }; + 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" + ); + } +} diff --git a/crates/app/src/core/servers.rs b/crates/app/src/core/servers.rs index d19927c..ea7014b 100644 --- a/crates/app/src/core/servers.rs +++ b/crates/app/src/core/servers.rs @@ -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, diff --git a/crates/app/src/domain.rs b/crates/app/src/domain.rs index ed1b6fd..d646c1b 100644 --- a/crates/app/src/domain.rs +++ b/crates/app/src/domain.rs @@ -70,6 +70,10 @@ pub struct Preferences { pub pull_filters: BTreeMap, #[serde(default)] pub appearance: AppearanceMode, + #[serde(default)] + pub notifications_enabled: bool, + #[serde(default)] + pub notification_cursors: BTreeMap, } 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(), } } } diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 2fc4b82..7bd194a 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -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)] diff --git a/crates/app/src/presentation.rs b/crates/app/src/presentation.rs index 74aa690..0cb1da4 100644 --- a/crates/app/src/presentation.rs +++ b/crates/app/src/presentation.rs @@ -359,6 +359,33 @@ pub struct HomePage { pub next_page: Option, } +#[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, + 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; diff --git a/crates/app/src/presentation/notifications.rs b/crates/app/src/presentation/notifications.rs new file mode 100644 index 0000000..408e2fa --- /dev/null +++ b/crates/app/src/presentation/notifications.rs @@ -0,0 +1,142 @@ +use gotcha_gitea::{models, notifications}; + +use super::{ActivityTargetKind, NotificationRow, compact_date}; + +pub fn notification_rows( + server_id: &str, + notifications: Vec, +) -> Vec { + notifications + .into_iter() + .filter_map(|notification| notification_row(server_id, notification)) + .collect() +} + +fn notification_row( + server_id: &str, + notification: models::NotificationThread, +) -> Option { + 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); + } +} diff --git a/crates/gitea/src/domain.rs b/crates/gitea/src/domain.rs index e9415c3..26afd4f 100644 --- a/crates/gitea/src/domain.rs +++ b/crates/gitea/src/domain.rs @@ -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 { 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); } } diff --git a/crates/gitea/src/lib.rs b/crates/gitea/src/lib.rs index 9be1e55..b76292f 100644 --- a/crates/gitea/src/lib.rs +++ b/crates/gitea/src/lib.rs @@ -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}; diff --git a/crates/gitea/src/notifications.rs b/crates/gitea/src/notifications.rs new file mode 100644 index 0000000..201a41b --- /dev/null +++ b/crates/gitea/src/notifications.rs @@ -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> { + 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> { + 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 { + 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(¬ification( + "Issue", + "https://gitea.example/api/v1/repos/octo/demo/issues/42" + )), + NotificationTarget::Issue { + owner: "octo".into(), + repository: "demo".into(), + number: 42, + } + ); + assert_eq!( + target(¬ification( + "Pull", + "https://gitea.example/api/v1/repos/octo/demo/pulls/7" + )), + NotificationTarget::Pull { + owner: "octo".into(), + repository: "demo".into(), + number: 7, + } + ); + assert_eq!( + target(¬ification( + "Commit", + "https://gitea.example/api/v1/repos/octo/demo/git/commits/deadbeef" + )), + NotificationTarget::Commit { + owner: "octo".into(), + repository: "demo".into(), + sha: "deadbeef".into(), + } + ); + } +} diff --git a/ios/Generated/gotcha_core.swift b/ios/Generated/gotcha_core.swift index 654a162..d004812 100644 --- a/ios/Generated/gotcha_core.swift +++ b/ios/Generated/gotcha_core.swift @@ -649,6 +649,12 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable { func setMilestoneClosed(owner: String, repository: String, id: Int64, closed: Bool) async throws + func markNotificationRead(serverId: String, id: Int64) async throws + + func notifications(status: NotificationStatus, page: UInt32) async throws -> NotificationListPage + + func pollNotifications() async throws -> [NotificationRow] + func clearPullFilters() throws func pull(owner: String, repository: String, number: Int64, page: UInt32) async throws -> PullPage @@ -685,6 +691,8 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable { func setIssueStatus(status: String) throws + func setNotificationsEnabled(enabled: Bool) throws + func setPullStatus(status: String) throws func settings() -> Settings @@ -1114,6 +1122,54 @@ open func setMilestoneClosed(owner: String, repository: String, id: Int64, close ) } +open func markNotificationRead(serverId: String, id: Int64)async throws { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_gotcha_core_fn_method_gotchacore_mark_notification_read( + self.uniffiCloneHandle(),FfiConverterString.lower(serverId),FfiConverterInt64.lower(id) + ) + }, + pollFunc: ffi_gotcha_core_rust_future_poll_void, + completeFunc: ffi_gotcha_core_rust_future_complete_void, + freeFunc: ffi_gotcha_core_rust_future_free_void, + liftFunc: { $0 }, + errorHandler: FfiConverterTypeGotchaError_lift + ) +} + +open func notifications(status: NotificationStatus, page: UInt32)async throws -> NotificationListPage { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_gotcha_core_fn_method_gotchacore_notifications( + self.uniffiCloneHandle(),FfiConverterTypeNotificationStatus_lower(status),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: FfiConverterTypeNotificationListPage_lift, + errorHandler: FfiConverterTypeGotchaError_lift + ) +} + +open func pollNotifications()async throws -> [NotificationRow] { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_gotcha_core_fn_method_gotchacore_poll_notifications( + self.uniffiCloneHandle() + ) + }, + 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: FfiConverterSequenceTypeNotificationRow.lift, + errorHandler: FfiConverterTypeGotchaError_lift + ) +} + open func clearPullFilters()throws {try rustCallWithError(FfiConverterTypeGotchaError_lift) { uniffiCallStatus in uniffi_gotcha_core_fn_method_gotchacore_clear_pull_filters( @@ -1329,6 +1385,15 @@ open func setIssueStatus(status: String)throws {try rustCallWithError(FfiConve } } +open func setNotificationsEnabled(enabled: Bool)throws {try rustCallWithError(FfiConverterTypeGotchaError_lift) { + uniffiCallStatus in + uniffi_gotcha_core_fn_method_gotchacore_set_notifications_enabled( + self.uniffiCloneHandle(), + FfiConverterBool.lower(enabled),uniffiCallStatus + ) +} +} + open func setPullStatus(status: String)throws {try rustCallWithError(FfiConverterTypeGotchaError_lift) { uniffiCallStatus in uniffi_gotcha_core_fn_method_gotchacore_set_pull_status( @@ -2988,6 +3053,150 @@ public func FfiConverterTypeMilestoneRow_lower(_ value: MilestoneRow) -> RustBuf } +public struct NotificationListPage: Equatable, Hashable { + public var rows: [NotificationRow] + public var hasMore: Bool + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(rows: [NotificationRow], hasMore: Bool) { + self.rows = rows + self.hasMore = hasMore + } + + + + +} + +#if compiler(>=6) +extension NotificationListPage: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeNotificationListPage: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> NotificationListPage { + return + try NotificationListPage( + rows: FfiConverterSequenceTypeNotificationRow.read(from: &buf), + hasMore: FfiConverterBool.read(from: &buf) + ) + } + + public static func write(_ value: NotificationListPage, into buf: inout [UInt8]) { + FfiConverterSequenceTypeNotificationRow.write(value.rows, into: &buf) + FfiConverterBool.write(value.hasMore, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeNotificationListPage_lift(_ buf: RustBuffer) throws -> NotificationListPage { + return try FfiConverterTypeNotificationListPage.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeNotificationListPage_lower(_ value: NotificationListPage) -> RustBuffer { + return FfiConverterTypeNotificationListPage.lower(value) +} + + +public struct NotificationRow: Equatable, Hashable { + public var id: Int64 + public var serverId: String + public var title: String + public var detail: String + public var meta: String + public var unread: Bool + public var target: ActivityTargetKind + public var owner: String + public var repository: String + public var number: Int64 + public var sha: String + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(id: Int64, serverId: String, title: String, detail: String, meta: String, unread: Bool, target: ActivityTargetKind, owner: String, repository: String, number: Int64, sha: String) { + self.id = id + self.serverId = serverId + self.title = title + self.detail = detail + self.meta = meta + self.unread = unread + self.target = target + self.owner = owner + self.repository = repository + self.number = number + self.sha = sha + } + + + + +} + +#if compiler(>=6) +extension NotificationRow: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeNotificationRow: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> NotificationRow { + return + try NotificationRow( + id: FfiConverterInt64.read(from: &buf), + serverId: FfiConverterString.read(from: &buf), + title: FfiConverterString.read(from: &buf), + detail: FfiConverterString.read(from: &buf), + meta: FfiConverterString.read(from: &buf), + unread: FfiConverterBool.read(from: &buf), + target: FfiConverterTypeActivityTargetKind.read(from: &buf), + owner: FfiConverterString.read(from: &buf), + repository: FfiConverterString.read(from: &buf), + number: FfiConverterInt64.read(from: &buf), + sha: FfiConverterString.read(from: &buf) + ) + } + + public static func write(_ value: NotificationRow, into buf: inout [UInt8]) { + FfiConverterInt64.write(value.id, into: &buf) + FfiConverterString.write(value.serverId, into: &buf) + FfiConverterString.write(value.title, into: &buf) + FfiConverterString.write(value.detail, into: &buf) + FfiConverterString.write(value.meta, into: &buf) + FfiConverterBool.write(value.unread, into: &buf) + FfiConverterTypeActivityTargetKind.write(value.target, into: &buf) + FfiConverterString.write(value.owner, into: &buf) + FfiConverterString.write(value.repository, into: &buf) + FfiConverterInt64.write(value.number, into: &buf) + FfiConverterString.write(value.sha, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeNotificationRow_lift(_ buf: RustBuffer) throws -> NotificationRow { + return try FfiConverterTypeNotificationRow.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeNotificationRow_lower(_ value: NotificationRow) -> RustBuffer { + return FfiConverterTypeNotificationRow.lower(value) +} + + public struct PullFilterOptions: Equatable, Hashable { public var milestones: [String] public var selectedMilestone: String @@ -3620,13 +3829,15 @@ public struct Settings: Equatable, Hashable { public var issueStatus: String public var pullStatus: String public var appearance: UInt32 + public var notificationsEnabled: Bool // Default memberwise initializers are never public by default, so we // declare one manually. - public init(issueStatus: String, pullStatus: String, appearance: UInt32) { + public init(issueStatus: String, pullStatus: String, appearance: UInt32, notificationsEnabled: Bool) { self.issueStatus = issueStatus self.pullStatus = pullStatus self.appearance = appearance + self.notificationsEnabled = notificationsEnabled } @@ -3647,7 +3858,8 @@ public struct FfiConverterTypeSettings: FfiConverterRustBuffer { try Settings( issueStatus: FfiConverterString.read(from: &buf), pullStatus: FfiConverterString.read(from: &buf), - appearance: FfiConverterUInt32.read(from: &buf) + appearance: FfiConverterUInt32.read(from: &buf), + notificationsEnabled: FfiConverterBool.read(from: &buf) ) } @@ -3655,6 +3867,7 @@ public struct FfiConverterTypeSettings: FfiConverterRustBuffer { FfiConverterString.write(value.issueStatus, into: &buf) FfiConverterString.write(value.pullStatus, into: &buf) FfiConverterUInt32.write(value.appearance, into: &buf) + FfiConverterBool.write(value.notificationsEnabled, into: &buf) } } @@ -4206,6 +4419,72 @@ public func FfiConverterTypeHomeActivityFilter_lower(_ value: HomeActivityFilter +public enum NotificationStatus: Equatable, Hashable { + + case `open` + case closed + + + + + +} + +#if compiler(>=6) +extension NotificationStatus: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeNotificationStatus: FfiConverterRustBuffer { + typealias SwiftType = NotificationStatus + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> NotificationStatus { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .`open` + + case 2: return .closed + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: NotificationStatus, into buf: inout [UInt8]) { + switch value { + + + case .`open`: + writeInt(&buf, Int32(1)) + + + case .closed: + writeInt(&buf, Int32(2)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeNotificationStatus_lift(_ buf: RustBuffer) throws -> NotificationStatus { + return try FfiConverterTypeNotificationStatus.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeNotificationStatus_lower(_ value: NotificationStatus) -> RustBuffer { + return FfiConverterTypeNotificationStatus.lower(value) +} + + + + public enum RepositoryContentKind: Equatable, Hashable { case directory @@ -5002,6 +5281,31 @@ fileprivate struct FfiConverterSequenceTypeMilestoneRow: FfiConverterRustBuffer } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeNotificationRow: FfiConverterRustBuffer { + typealias SwiftType = [NotificationRow] + + public static func write(_ value: [NotificationRow], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeNotificationRow.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [NotificationRow] { + let len: Int32 = try readInt(&buf) + var seq = [NotificationRow]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeNotificationRow.read(from: &buf)) + } + return seq + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -5234,6 +5538,15 @@ private let initializationResult: InitializationResult = { if (uniffi_gotcha_core_checksum_method_gotchacore_set_milestone_closed() != 33) { return InitializationResult.apiChecksumMismatch } + if (uniffi_gotcha_core_checksum_method_gotchacore_mark_notification_read() != 39536) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_gotcha_core_checksum_method_gotchacore_notifications() != 58813) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_gotcha_core_checksum_method_gotchacore_poll_notifications() != 21038) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_gotcha_core_checksum_method_gotchacore_clear_pull_filters() != 61566) { return InitializationResult.apiChecksumMismatch } @@ -5288,6 +5601,9 @@ private let initializationResult: InitializationResult = { if (uniffi_gotcha_core_checksum_method_gotchacore_set_issue_status() != 5573) { return InitializationResult.apiChecksumMismatch } + if (uniffi_gotcha_core_checksum_method_gotchacore_set_notifications_enabled() != 13019) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_gotcha_core_checksum_method_gotchacore_set_pull_status() != 40421) { return InitializationResult.apiChecksumMismatch } diff --git a/ios/Generated/gotcha_coreFFI.h b/ios/Generated/gotcha_coreFFI.h index 53c8518..928671c 100644 --- a/ios/Generated/gotcha_coreFFI.h +++ b/ios/Generated/gotcha_coreFFI.h @@ -373,6 +373,21 @@ uint64_t uniffi_gotcha_core_fn_method_gotchacore_save_milestone(uint64_t ptr, Ru uint64_t uniffi_gotcha_core_fn_method_gotchacore_set_milestone_closed(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t id, int8_t closed ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_MARK_NOTIFICATION_READ +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_MARK_NOTIFICATION_READ +uint64_t uniffi_gotcha_core_fn_method_gotchacore_mark_notification_read(uint64_t ptr, RustBuffer server_id, int64_t id +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_NOTIFICATIONS +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_NOTIFICATIONS +uint64_t uniffi_gotcha_core_fn_method_gotchacore_notifications(uint64_t ptr, RustBuffer status, uint32_t page +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_POLL_NOTIFICATIONS +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_POLL_NOTIFICATIONS +uint64_t uniffi_gotcha_core_fn_method_gotchacore_poll_notifications(uint64_t ptr +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_CLEAR_PULL_FILTERS #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_CLEAR_PULL_FILTERS void uniffi_gotcha_core_fn_method_gotchacore_clear_pull_filters(uint64_t ptr, RustCallStatus *_Nonnull out_status @@ -463,6 +478,11 @@ void uniffi_gotcha_core_fn_method_gotchacore_set_appearance(uint64_t ptr, uint32 void uniffi_gotcha_core_fn_method_gotchacore_set_issue_status(uint64_t ptr, RustBuffer status, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_NOTIFICATIONS_ENABLED +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_NOTIFICATIONS_ENABLED +void uniffi_gotcha_core_fn_method_gotchacore_set_notifications_enabled(uint64_t ptr, int8_t enabled, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_PULL_STATUS #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_PULL_STATUS void uniffi_gotcha_core_fn_method_gotchacore_set_pull_status(uint64_t ptr, RustBuffer status, RustCallStatus *_Nonnull out_status @@ -889,6 +909,24 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_save_milestone(void #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_MILESTONE_CLOSED uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_milestone_closed(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_MARK_NOTIFICATION_READ +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_MARK_NOTIFICATION_READ +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_mark_notification_read(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_NOTIFICATIONS +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_NOTIFICATIONS +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_notifications(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_POLL_NOTIFICATIONS +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_POLL_NOTIFICATIONS +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_poll_notifications(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_CLEAR_PULL_FILTERS @@ -997,6 +1035,12 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_appearance(void #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_ISSUE_STATUS uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_issue_status(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_NOTIFICATIONS_ENABLED +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_NOTIFICATIONS_ENABLED +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_notifications_enabled(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_PULL_STATUS diff --git a/ios/Gotcha.xcodeproj/project.pbxproj b/ios/Gotcha.xcodeproj/project.pbxproj index bed15fa..ca881c9 100644 --- a/ios/Gotcha.xcodeproj/project.pbxproj +++ b/ios/Gotcha.xcodeproj/project.pbxproj @@ -16,7 +16,9 @@ 381A27D70EA30C1A3BA1BBC1 /* CommentEditorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F9FFCC06E0313760524EBD2 /* CommentEditorViewController.swift */; }; 39C36B0D5FB260C920D466DC /* GotchaWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5121A6F14A9C9F6EA144BB2F /* GotchaWidgets.swift */; }; 4652515AE4CB10963D995143 /* CommitScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7588A44A93B8DB4B78C3B2BB /* CommitScreens.swift */; }; + 6DAA1D3230197F537D70735E /* NotificationsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE88A28F99C6D0224865E04D /* NotificationsScreen.swift */; }; 74626C144E9214BF89F821D2 /* WidgetIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77AE1D594C38D2BDAA2E86AD /* WidgetIntents.swift */; }; + 7A511844F3CBA0603A894DDE /* NotificationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA5F9B46F07F3EB2333CD4DF /* NotificationCoordinator.swift */; }; 7A9D1ADD6623C89A7D5E016F /* GotchaWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 52ABD1B07CEEF00263038653 /* GotchaWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 7BBE64F66374221F1743BC24 /* IssueScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACD6449327E1A51FCD8E3BD4 /* IssueScreens.swift */; }; 7DD332583B169B3CA1CA46E0 /* Highlighter in Frameworks */ = {isa = PBXBuildFile; productRef = EC5F999F50905E3801E8A71A /* Highlighter */; }; @@ -83,10 +85,12 @@ 99B8279189276A084B69D7D0 /* PullScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PullScreens.swift; sourceTree = ""; }; 9C0921C76676A14A024BA417 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; ACD6449327E1A51FCD8E3BD4 /* IssueScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueScreens.swift; sourceTree = ""; }; + BA5F9B46F07F3EB2333CD4DF /* NotificationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCoordinator.swift; sourceTree = ""; }; C13A39F3C39C1F353D58C307 /* ServerScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerScreens.swift; sourceTree = ""; }; CA582099DD57D35C748EFB02 /* MilestoneEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MilestoneEditorViewController.swift; sourceTree = ""; }; D35C7ECBC3EEC8B4659234AE /* HomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreen.swift; sourceTree = ""; }; DDAABE6B13ADC6D08D9438AF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + DE88A28F99C6D0224865E04D /* NotificationsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsScreen.swift; sourceTree = ""; }; F121BE52F7C9C8780341F988 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; F61515849F6AACD721FE915C /* AppContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppContext.swift; sourceTree = ""; }; F75B3E4FFB9C9992517C4D69 /* Support.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Support.swift; sourceTree = ""; }; @@ -130,6 +134,8 @@ ACD6449327E1A51FCD8E3BD4 /* IssueScreens.swift */, CA582099DD57D35C748EFB02 /* MilestoneEditorViewController.swift */, 64B529D84926EEEDC163A929 /* MilestoneScreens.swift */, + BA5F9B46F07F3EB2333CD4DF /* NotificationCoordinator.swift */, + DE88A28F99C6D0224865E04D /* NotificationsScreen.swift */, 99B8279189276A084B69D7D0 /* PullScreens.swift */, FD6BB62D12708255650508B2 /* RepositoryDirectoryScreen.swift */, 181AE294D07DB4EAC9C9A0FF /* RepositoryFileScreens.swift */, @@ -351,6 +357,8 @@ 7BBE64F66374221F1743BC24 /* IssueScreens.swift in Sources */, 1EDCCB5DE286C1DA407F00F1 /* MilestoneEditorViewController.swift in Sources */, C33BA07C5F7DA72CBF72CEAE /* MilestoneScreens.swift in Sources */, + 7A511844F3CBA0603A894DDE /* NotificationCoordinator.swift in Sources */, + 6DAA1D3230197F537D70735E /* NotificationsScreen.swift in Sources */, 33D3E65C9E50522B2039816A /* PullScreens.swift in Sources */, 130339B2D7AEAC791E50140F /* RepositoryDirectoryScreen.swift in Sources */, DC155F5DEB86D95FAF709E16 /* RepositoryFileScreens.swift in Sources */, diff --git a/ios/Info.plist b/ios/Info.plist index 443d314..1dee002 100644 --- a/ios/Info.plist +++ b/ios/Info.plist @@ -2,6 +2,10 @@ + BGTaskSchedulerPermittedIdentifiers + + de.rfc1437.gotcha.notifications.refresh + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -33,6 +37,10 @@ $(CURRENT_PROJECT_VERSION) ITSAppUsesNonExemptEncryption + UIBackgroundModes + + fetch + UILaunchScreen UISupportedInterfaceOrientations diff --git a/ios/Sources/AppContext.swift b/ios/Sources/AppContext.swift index 21eb079..1595fea 100644 --- a/ios/Sources/AppContext.swift +++ b/ios/Sources/AppContext.swift @@ -4,6 +4,7 @@ import WidgetKit @MainActor final class AppContext { let core: GotchaCore + lazy var notifications = NotificationCoordinator(context: self) private let window: UIWindow private(set) var tabs = UITabBarController() private(set) var navigationControllers: [UINavigationController] = [] @@ -96,14 +97,77 @@ final class AppContext { } func route(_ activity: ActivityRow) { + route( + serverId: nil, + target: activity.target, + owner: activity.owner, + repository: activity.repository, + number: activity.number, + sha: activity.sha + ) + } + + func route(_ notification: NotificationRow) { + route( + serverId: notification.serverId, + target: notification.target, + owner: notification.owner, + repository: notification.repository, + number: notification.number, + sha: notification.sha + ) + } + + func route(notificationUserInfo userInfo: [AnyHashable: Any]) { + let target: ActivityTargetKind + switch userInfo["target"] as? String { + case "repository": target = .repository + case "issue": target = .issue + case "pull": target = .pullRequest + case "commit": target = .commit + default: target = .none + } + route( + serverId: userInfo["serverId"] as? String, + target: target, + owner: userInfo["owner"] as? String ?? "", + repository: userInfo["repository"] as? String ?? "", + number: (userInfo["number"] as? NSNumber)?.int64Value ?? 0, + sha: userInfo["sha"] as? String ?? "" + ) + } + + private func route( + serverId: String?, + target: ActivityTargetKind, + owner: String, + repository: String, + number: Int64, + sha: String + ) { + if let serverId { + guard let index = core.servers().firstIndex(where: { $0.id == serverId }) else { + tabs.present(errorAlert("That notification's server is no longer configured."), animated: true) + return + } + if core.activeServerIndex() != UInt32(index) { + do { + try selectServer(index: UInt32(index)) + } catch { + tabs.present(errorAlert(error.localizedDescription), animated: true) + return + } + } + } let navigation = navigationControllers[0] - switch activity.target { + tabs.selectedIndex = 0 + switch target { case .repository: navigation.pushViewController( IssuesViewController( context: self, - owner: activity.owner, - repository: activity.repository + owner: owner, + repository: repository ), animated: true ) @@ -111,9 +175,9 @@ final class AppContext { navigation.pushViewController( IssueViewController( context: self, - owner: activity.owner, - repository: activity.repository, - number: activity.number + owner: owner, + repository: repository, + number: number ), animated: true ) @@ -121,9 +185,9 @@ final class AppContext { navigation.pushViewController( PullViewController( context: self, - owner: activity.owner, - repository: activity.repository, - number: activity.number + owner: owner, + repository: repository, + number: number ), animated: true ) @@ -131,9 +195,9 @@ final class AppContext { navigation.pushViewController( FilesViewController( context: self, - owner: activity.owner, - repository: activity.repository, - sha: activity.sha + owner: owner, + repository: repository, + sha: sha ), animated: true ) diff --git a/ios/Sources/AppDelegate.swift b/ios/Sources/AppDelegate.swift index 32cb00b..393a68b 100644 --- a/ios/Sources/AppDelegate.swift +++ b/ios/Sources/AppDelegate.swift @@ -16,6 +16,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { window.rootViewController = context.makeRootController() window.makeKeyAndVisible() self.window = window + context.notifications.start() context.showStartupErrorIfNeeded() if let url = launchOptions?[.url] as? URL { context.route(widgetURL: url) @@ -33,5 +34,10 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { func applicationDidBecomeActive(_ application: UIApplication) { WidgetCenter.shared.reloadAllTimelines() + context?.notifications.applicationDidBecomeActive() + } + + func applicationDidEnterBackground(_ application: UIApplication) { + context?.notifications.applicationDidEnterBackground() } } diff --git a/ios/Sources/HomeScreen.swift b/ios/Sources/HomeScreen.swift index a1275a8..351e773 100644 --- a/ios/Sources/HomeScreen.swift +++ b/ios/Sources/HomeScreen.swift @@ -104,12 +104,23 @@ final class HomeViewController: RefreshingTableViewController { finishPagination(hasMore: result.nextPage != nil) title = page?.serverName if requestedPage == 1 { tableView.tableHeaderView = page.map { page in - HeatmapView(page: page, selectedFilter: filter.rawValue) { [weak self] index in + HeatmapView( + page: page, + selectedFilter: filter.rawValue, + onFilter: { [weak self] index in guard let self, let filter = ActivityFilter(rawValue: index) else { return } guard filter != self.filter else { return } self.filter = filter self.loadPage(1, refreshing: false) - } + }, + onNotifications: { [weak self] in + guard let self else { return } + self.navigationController?.pushViewController( + NotificationsViewController(context: self.context), + animated: true + ) + } + ) } } updateActivities() } catch { @@ -179,7 +190,12 @@ final class HeatmapView: UIView { private let cells: [HeatCell] private let calendar = Calendar(identifier: .gregorian) - init(page: HomePage, selectedFilter: Int, onFilter: @escaping (Int) -> Void) { + init( + page: HomePage, + selectedFilter: Int, + onFilter: @escaping (Int) -> Void, + onNotifications: @escaping () -> Void + ) { cells = page.heatCells super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 180)) backgroundColor = .systemBackground @@ -225,6 +241,15 @@ final class HeatmapView: UIView { button.widthAnchor.constraint(equalToConstant: 44).isActive = true filters.addArrangedSubview(button) } + let notifications = UIButton( + type: .custom, + primaryAction: UIAction { _ in onNotifications() } + ) + notifications.setImage(UIImage(systemName: "bell"), for: .normal) + notifications.tintColor = .secondaryLabel + notifications.accessibilityLabel = "Notifications" + notifications.widthAnchor.constraint(equalToConstant: 44).isActive = true + filters.addArrangedSubview(notifications) addSubview(filters) NSLayoutConstraint.activate([ filters.centerXAnchor.constraint(equalTo: centerXAnchor), diff --git a/ios/Sources/NotificationCoordinator.swift b/ios/Sources/NotificationCoordinator.swift new file mode 100644 index 0000000..0a2bb32 --- /dev/null +++ b/ios/Sources/NotificationCoordinator.swift @@ -0,0 +1,212 @@ +import BackgroundTasks +import UIKit +import UserNotifications + +@MainActor +final class NotificationCoordinator: NSObject, @preconcurrency UNUserNotificationCenterDelegate { + static let refreshIdentifier = "de.rfc1437.gotcha.notifications.refresh" + + private unowned let context: AppContext + private let center = UNUserNotificationCenter.current() + private var timer: Timer? + private var pollingTask: Task? +#if DEBUG + private let validatesBackgroundNotifications = ProcessInfo.processInfo.arguments.contains( + "--validate-background-notifications" + ) +#endif + + init(context: AppContext) { + self.context = context + } + + func start() { + center.delegate = self + BGTaskScheduler.shared.register( + forTaskWithIdentifier: Self.refreshIdentifier, + using: nil + ) { [weak self] task in + Task { @MainActor in + guard let self, let task = task as? BGAppRefreshTask else { + task.setTaskCompleted(success: false) + return + } + self.handle(task) + } + } + timer = Timer.scheduledTimer(withTimeInterval: 5 * 60, repeats: true) { + [weak self] _ in + Task { @MainActor in self?.refresh(deliverAlerts: false) } + } + } + + func applicationDidBecomeActive() { +#if DEBUG + guard !validatesBackgroundNotifications else { return } +#endif + refresh(deliverAlerts: false) + } + + func applicationDidEnterBackground() { + scheduleBackgroundRefresh() +#if DEBUG + guard validatesBackgroundNotifications else { return } + let identifier = UIApplication.shared.beginBackgroundTask() + pollingTask?.cancel() + pollingTask = Task { + defer { UIApplication.shared.endBackgroundTask(identifier) } + try? await poll(deliverAlerts: true) + } +#endif + } + + func setEnabled(_ enabled: Bool) async throws -> Bool { + if !enabled { + try context.core.setNotificationsEnabled(enabled: false) + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.refreshIdentifier) + center.removeAllPendingNotificationRequests() + return false + } + + var settings = await center.notificationSettings() + if settings.authorizationStatus == .notDetermined { + _ = try await center.requestAuthorization(options: [.alert, .sound]) + settings = await center.notificationSettings() + } + guard Self.isAuthorized(settings.authorizationStatus) else { return false } + try context.core.setNotificationsEnabled(enabled: true) + scheduleBackgroundRefresh() + try await poll(deliverAlerts: false) + return true + } + + func authorizationDescription() async -> String { + switch await center.notificationSettings().authorizationStatus { + case .notDetermined: return "Not requested" + case .denied: return "Disabled in iOS Settings" + case .authorized: return "Allowed" + case .provisional: return "Delivered quietly" + case .ephemeral: return "Allowed temporarily" + @unknown default: return "Managed by iOS" + } + } + + func openSystemSettings() { + guard let url = URL(string: UIApplication.openNotificationSettingsURLString) else { return } + UIApplication.shared.open(url) + } + + private func refresh(deliverAlerts: Bool) { + pollingTask?.cancel() + pollingTask = Task { + do { + let settings = await center.notificationSettings() + guard + context.core.settings().notificationsEnabled, + Self.isAuthorized(settings.authorizationStatus) + else { return } + try await poll(deliverAlerts: deliverAlerts) + } catch { + // Foreground screens surface API errors when the user explicitly refreshes them. + } + } + } + + private func poll(deliverAlerts: Bool) async throws { + let rows = try await context.core.pollNotifications() + guard deliverAlerts else { return } + for row in rows where row.target != .none { + let content = UNMutableNotificationContent() + content.title = title(for: row.target) + content.body = "Open Gotcha to view the update." + content.sound = .default + content.threadIdentifier = "gotcha.\(row.serverId)" + content.userInfo = [ + "serverId": row.serverId, + "threadId": row.id, + "target": targetName(row.target), + "owner": row.owner, + "repository": row.repository, + "number": row.number, + "sha": row.sha, + ] + let request = UNNotificationRequest( + identifier: "gotcha.\(row.serverId).\(row.id)", + content: content, + trigger: nil + ) + try await center.add(request) + } + } + + private func scheduleBackgroundRefresh() { + guard context.core.settings().notificationsEnabled else { return } + let request = BGAppRefreshTaskRequest(identifier: Self.refreshIdentifier) + request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) + try? BGTaskScheduler.shared.submit(request) + } + + private func handle(_ backgroundTask: BGAppRefreshTask) { + scheduleBackgroundRefresh() + pollingTask?.cancel() + let task = Task { + do { + let settings = await center.notificationSettings() + guard Self.isAuthorized(settings.authorizationStatus) else { + backgroundTask.setTaskCompleted(success: true) + return + } + try await poll(deliverAlerts: true) + backgroundTask.setTaskCompleted(success: true) + } catch { + backgroundTask.setTaskCompleted(success: false) + } + } + pollingTask = task + backgroundTask.expirationHandler = { task.cancel() } + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification + ) async -> UNNotificationPresentationOptions { + [] + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse + ) async { + let userInfo = response.notification.request.content.userInfo + context.route(notificationUserInfo: userInfo) + guard + let serverId = userInfo["serverId"] as? String, + let id = userInfo["threadId"] as? NSNumber + else { return } + try? await context.core.markNotificationRead(serverId: serverId, id: id.int64Value) + } + + private static func isAuthorized(_ status: UNAuthorizationStatus) -> Bool { + status == .authorized || status == .provisional || status == .ephemeral + } + + private func title(for target: ActivityTargetKind) -> String { + switch target { + case .repository: return "New repository notification" + case .issue: return "New issue notification" + case .pullRequest: return "New pull request notification" + case .commit: return "New commit notification" + case .none: return "New server notification" + } + } + + private func targetName(_ target: ActivityTargetKind) -> String { + switch target { + case .repository: return "repository" + case .issue: return "issue" + case .pullRequest: return "pull" + case .commit: return "commit" + case .none: return "none" + } + } +} diff --git a/ios/Sources/NotificationsScreen.swift b/ios/Sources/NotificationsScreen.swift new file mode 100644 index 0000000..b2b44ad --- /dev/null +++ b/ios/Sources/NotificationsScreen.swift @@ -0,0 +1,179 @@ +import UIKit + +final class NotificationCell: UITableViewCell { + private let icon = UIImageView() + private let titleLabel = UILabel() + private let detailLabel = UILabel() + private let metaLabel = UILabel() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + icon.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .headline) + icon.setContentHuggingPriority(.required, for: .horizontal) + titleLabel.font = .preferredFont(forTextStyle: .headline) + titleLabel.numberOfLines = 2 + detailLabel.font = .preferredFont(forTextStyle: .subheadline) + detailLabel.textColor = .secondaryLabel + detailLabel.numberOfLines = 2 + metaLabel.font = .preferredFont(forTextStyle: .caption1) + metaLabel.textColor = .tertiaryLabel + [titleLabel, detailLabel, metaLabel].forEach { + $0.adjustsFontForContentSizeCategory = true + } + let labels = UIStackView(arrangedSubviews: [titleLabel, detailLabel, metaLabel]) + labels.axis = .vertical + labels.spacing = 4 + let stack = UIStackView(arrangedSubviews: [icon, labels]) + stack.alignment = .top + stack.spacing = 12 + stack.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8), + stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10), + stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10), + icon.widthAnchor.constraint(equalToConstant: 24), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + func configure(_ row: NotificationRow) { + icon.image = UIImage(systemName: symbolName(for: row.target)) + icon.tintColor = row.unread ? .tintColor : .secondaryLabel + titleLabel.text = row.title + detailLabel.text = row.detail + metaLabel.text = row.meta + accessoryType = row.target == .none ? .none : .disclosureIndicator + selectionStyle = row.target == .none ? .none : .default + accessibilityValue = row.unread ? "Open" : "Closed" + } + + private func symbolName(for target: ActivityTargetKind) -> String { + switch target { + case .repository: return "books.vertical" + case .issue: return "exclamationmark.circle" + case .pullRequest: return "arrow.triangle.pull" + case .commit: return "point.topleft.down.to.point.bottomright.curvepath" + case .none: return "bell" + } + } +} + +@MainActor +final class NotificationsViewController: RefreshingTableViewController { + private let context: AppContext + private let statusControl = UISegmentedControl(items: ["Open", "Closed"]) + private var rows: [NotificationRow] = [] + private var currentPage: UInt32 = 0 + + init(context: AppContext) { + self.context = context + super.init() + title = "Notifications" + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + override func viewDidLoad() { + super.viewDidLoad() + tableView.register(NotificationCell.self, forCellReuseIdentifier: "notification") + tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = 92 + statusControl.selectedSegmentIndex = 0 + statusControl.addTarget(self, action: #selector(statusChanged), for: .valueChanged) + statusControl.accessibilityLabel = "Notification status" + navigationItem.rightBarButtonItem = UIBarButtonItem(customView: statusControl) + loadContent(refreshing: false) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + guard currentPage > 0 else { return } + loadNotifications(page: 1, refreshing: false) + } + + override func loadContent(refreshing: Bool) { + loadNotifications(page: 1, refreshing: refreshing) + } + + override func loadMoreContent() { + loadNotifications(page: currentPage + 1, refreshing: false) + } + + private func loadNotifications(page: UInt32, refreshing: Bool) { + if page == 1 { + resetPagination() + beginLoading(refreshing: refreshing) + } + loadingTask?.cancel() + loadingTask = Task { + do { + let result = try await context.core.notifications( + status: statusControl.selectedSegmentIndex == 0 ? .open : .closed, + page: page + ) + if page == 1 { rows = result.rows } else { rows.append(contentsOf: result.rows) } + currentPage = page + finishPagination(hasMore: result.hasMore) + tableView.reloadData() + let status = statusControl.selectedSegmentIndex == 0 ? "open" : "closed" + tableView.backgroundView = rows.isEmpty + ? EmptyBackgroundView( + title: "No \(status) notifications", + detail: "This server has no \(status) notifications." + ) + : nil + } catch { + if !Task.isCancelled { + show(error: error) + failPagination() + } + } + if page == 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: "notification", + for: indexPath + ) as! NotificationCell + cell.configure(rows[indexPath.row]) + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + let row = rows[indexPath.row] + guard row.target != .none else { return } + guard row.unread else { + context.route(row) + return + } + loadingTask?.cancel() + loadingTask = Task { + do { + try await context.core.markNotificationRead(serverId: row.serverId, id: row.id) + guard !Task.isCancelled else { return } + context.route(row) + } catch { + if !Task.isCancelled { show(error: error) } + } + } + } + + @objc private func statusChanged() { + loadNotifications(page: 1, refreshing: false) + } +} diff --git a/ios/Sources/SettingsViewController.swift b/ios/Sources/SettingsViewController.swift index d74f1c4..4262f7a 100644 --- a/ios/Sources/SettingsViewController.swift +++ b/ios/Sources/SettingsViewController.swift @@ -4,6 +4,8 @@ import UIKit final class SettingsViewController: UITableViewController { private let context: AppContext private let appearanceControl = UISegmentedControl(items: ["Auto", "Light", "Dark"]) + private let notificationSwitch = UISwitch() + private var notificationStatus = "Managed by iOS" init(context: AppContext) { self.context = context @@ -19,23 +21,58 @@ final class SettingsViewController: UITableViewController { let settings = context.core.settings() appearanceControl.selectedSegmentIndex = Int(settings.appearance) appearanceControl.addTarget(self, action: #selector(appearanceChanged), for: .valueChanged) + notificationSwitch.isOn = settings.notificationsEnabled + notificationSwitch.accessibilityLabel = "Background notifications" + notificationSwitch.addTarget(self, action: #selector(notificationsChanged), for: .valueChanged) } - override func numberOfSections(in tableView: UITableView) -> Int { 1 } - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 1 } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + notificationSwitch.isOn = context.core.settings().notificationsEnabled + Task { + notificationStatus = await context.notifications.authorizationDescription() + updateNotificationSettingsCell() + } + } + + override func numberOfSections(in tableView: UITableView) -> Int { 2 } + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + section == 0 ? 1 : 2 + } override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - "Appearance" + section == 0 ? "Appearance" : "Notifications" } override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { - "Follow iOS automatically or choose a fixed appearance." + if section == 0 { + return "Follow iOS automatically or choose a fixed appearance." + } + return "Gotcha checks periodically for server notifications. iOS decides when background refresh runs; delivery style, sounds, Focus, and summaries remain under your control in iOS Settings." } override func tableView( _ tableView: UITableView, cellForRowAt indexPath: IndexPath ) -> UITableViewCell { + guard indexPath.section == 0 else { + if indexPath.row == 0 { + let cell = UITableViewCell(style: .default, reuseIdentifier: nil) + var content = cell.defaultContentConfiguration() + content.text = "Background notifications" + cell.contentConfiguration = content + cell.accessoryView = notificationSwitch + cell.selectionStyle = .none + return cell + } + let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) + var content = cell.defaultContentConfiguration() + content.text = "Notification Settings" + content.secondaryText = notificationStatus + cell.contentConfiguration = content + cell.accessoryType = .disclosureIndicator + return cell + } let cell = UITableViewCell(style: .default, reuseIdentifier: nil) appearanceControl.translatesAutoresizingMaskIntoConstraints = false cell.contentView.addSubview(appearanceControl) @@ -48,6 +85,12 @@ final class SettingsViewController: UITableViewController { return cell } + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + guard indexPath.section == 1, indexPath.row == 1 else { return } + context.notifications.openSystemSettings() + } + @objc private func appearanceChanged() { do { try context.core.setAppearance(index: UInt32(appearanceControl.selectedSegmentIndex)) @@ -56,4 +99,43 @@ final class SettingsViewController: UITableViewController { show(error: error) } } + + @objc private func notificationsChanged() { + let requested = notificationSwitch.isOn + notificationSwitch.isEnabled = false + Task { + do { + let enabled = try await context.notifications.setEnabled(requested) + notificationSwitch.isOn = enabled + if requested && !enabled { showNotificationsDisabledAlert() } + } catch { + notificationSwitch.isOn = context.core.settings().notificationsEnabled + show(error: error) + } + notificationSwitch.isEnabled = true + notificationStatus = await context.notifications.authorizationDescription() + updateNotificationSettingsCell() + } + } + + private func updateNotificationSettingsCell() { + guard let cell = tableView.cellForRow(at: IndexPath(row: 1, section: 1)) else { return } + var content = cell.defaultContentConfiguration() + content.text = "Notification Settings" + content.secondaryText = notificationStatus + cell.contentConfiguration = content + } + + private func showNotificationsDisabledAlert() { + let alert = UIAlertController( + title: "Notifications Are Disabled", + message: "Allow notifications in iOS Settings, then turn Background notifications on again.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "Not Now", style: .cancel)) + alert.addAction(UIAlertAction(title: "Open Settings", style: .default) { [weak self] _ in + self?.context.notifications.openSystemSettings() + }) + present(alert, animated: true) + } } diff --git a/ios/project.yml b/ios/project.yml index 8a657a1..d6ecb95 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -34,11 +34,15 @@ targets: CFBundleShortVersionString: "$(MARKETING_VERSION)" CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" ITSAppUsesNonExemptEncryption: false + BGTaskSchedulerPermittedIdentifiers: + - de.rfc1437.gotcha.notifications.refresh CFBundleURLTypes: - CFBundleURLName: de.rfc1437.gotcha CFBundleURLSchemes: - gotcha UILaunchScreen: {} + UIBackgroundModes: + - fetch UISupportedInterfaceOrientations: - UIInterfaceOrientationPortrait sources: