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

@@ -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 background with no border or pill and defaults to the clock. Select the
issue and pull-request icons in turn; each shows only matching recent issue and pull-request icons in turn; each shows only matching recent
activity or the native empty state, then the clock restores all rows. activity or the native empty state, then the clock restores all rows.
The bell remains a separate, accessible Notifications destination.
- [ ] Pull-request activity includes older creation and close events beyond the - [ ] Pull-request activity includes older creation and close events beyond the
first activity-feed page without requiring a manual pull-up; the empty first activity-feed page without requiring a manual pull-up; the empty
state appears only after every available page has been checked. state appears only after every available page has been checked.
@@ -251,6 +252,40 @@ source update are published, and installs or updates Gotcha through AltStore PAL
Back button and left-edge interactive swipe return directly to Home. Back button and left-edge interactive swipe return directly to Home.
- [ ] Non-linkable server activity does not navigate or appear tappable. - [ ] 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.
- [ ] 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 and background Gotcha. From Xcode's debugger, run
`e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"de.rfc1437.gotcha.notifications.refresh"]`.
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 ## Milestone navigation
- [ ] Open a milestone, then open one of its issues and one of its pull - [ ] Open a milestone, then open one of its issues and one of its pull

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( pub async fn load_issues(
server: &Server, server: &Server,
owner: &str, owner: &str,

View File

@@ -1,6 +1,7 @@
mod content; mod content;
mod issues; mod issues;
mod milestones; mod milestones;
mod notifications;
mod pulls; mod pulls;
mod repositories; mod repositories;
mod servers; 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.")?; .ok_or("That server no longer exists.")?;
let mut preferences = state.preferences.clone(); let mut preferences = state.preferences.clone();
preferences.servers.remove(index); preferences.servers.remove(index);
preferences
.notification_cursors
.remove(&server.credential_account);
let active_server = let active_server =
active_server_after_removal(state.active_server, index, preferences.servers.len()); active_server_after_removal(state.active_server, index, preferences.servers.len());
preferences.last_server = active_server; preferences.last_server = active_server;
@@ -224,6 +227,7 @@ impl GotchaCore {
issue_status: state.preferences.issue_status.clone(), issue_status: state.preferences.issue_status.clone(),
pull_status: state.preferences.pull_status.clone(), pull_status: state.preferences.pull_status.clone(),
appearance: state.preferences.appearance.index() as u32, 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) 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( pub async fn home(
&self, &self,
page: u32, page: u32,

View File

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

View File

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

View File

@@ -359,6 +359,33 @@ pub struct HomePage {
pub next_page: Option<u32>, 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)] #[derive(Clone, uniffi::Record)]
pub struct WidgetActivityPage { pub struct WidgetActivityPage {
pub server_name: String, pub server_name: String,
@@ -376,12 +403,14 @@ mod files;
mod helpers; mod helpers;
mod home; mod home;
mod lists; mod lists;
mod notifications;
pub use details::*; pub use details::*;
pub use files::*; pub use files::*;
pub use helpers::compact_date; pub use helpers::compact_date;
pub use home::*; pub use home::*;
pub use lists::*; pub use lists::*;
pub use notifications::*;
#[cfg(test)] #[cfg(test)]
mod tests; 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);
}
}

View File

@@ -174,6 +174,16 @@ pub fn api_date(timestamp: i64) -> String {
format!("{year:04}-{month:02}-{day:02}T00:00:00Z") 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> { pub fn parse_api_date(value: &str) -> Option<i64> {
let date = value.get(..10)?; let date = value.get(..10)?;
let mut parts = date.split('-'); let mut parts = date.split('-');
@@ -230,6 +240,7 @@ mod tests {
assert!(!Page::from_items(vec![(); 29], 30).has_more); assert!(!Page::from_items(vec![(); 29], 30).has_more);
let leap_day = parse_api_date("2024-02-29T12:34:56Z").unwrap(); 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_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); assert_eq!(parse_api_date("2023-02-29T00:00:00Z"), None);
} }
} }

View File

@@ -18,6 +18,7 @@ pub mod diff;
mod domain; mod domain;
mod issues; mod issues;
mod milestones; mod milestones;
pub mod notifications;
mod pulls; mod pulls;
mod repositories; mod repositories;
@@ -27,7 +28,7 @@ pub use config::{Config, Selection, ServerProfile, TuiPreferences, server_url};
pub use domain::{ pub use domain::{
CreateIssue, DEFAULT_PAGE_SIZE, EditIssue, HistoryCommit, HomeData, IssueDetails, IssueDraft, CreateIssue, DEFAULT_PAGE_SIZE, EditIssue, HistoryCommit, HomeData, IssueDetails, IssueDraft,
IssueEditorData, IssueQuery, MilestoneDetails, MilestoneDraft, Page, PullDetails, RepositoryId, 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 issues::comment_can_edit;
pub use pulls::{PullFileSource, pull_file_source, pull_state}; 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(),
}
);
}
}

View File

@@ -649,6 +649,12 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable {
func setMilestoneClosed(owner: String, repository: String, id: Int64, closed: Bool) async throws 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 clearPullFilters() throws
func pull(owner: String, repository: String, number: Int64, page: UInt32) async throws -> PullPage 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 setIssueStatus(status: String) throws
func setNotificationsEnabled(enabled: Bool) throws
func setPullStatus(status: String) throws func setPullStatus(status: String) throws
func settings() -> Settings 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) { open func clearPullFilters()throws {try rustCallWithError(FfiConverterTypeGotchaError_lift) {
uniffiCallStatus in uniffiCallStatus in
uniffi_gotcha_core_fn_method_gotchacore_clear_pull_filters( 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) { open func setPullStatus(status: String)throws {try rustCallWithError(FfiConverterTypeGotchaError_lift) {
uniffiCallStatus in uniffiCallStatus in
uniffi_gotcha_core_fn_method_gotchacore_set_pull_status( 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 struct PullFilterOptions: Equatable, Hashable {
public var milestones: [String] public var milestones: [String]
public var selectedMilestone: String public var selectedMilestone: String
@@ -3620,13 +3829,15 @@ public struct Settings: Equatable, Hashable {
public var issueStatus: String public var issueStatus: String
public var pullStatus: String public var pullStatus: String
public var appearance: UInt32 public var appearance: UInt32
public var notificationsEnabled: Bool
// Default memberwise initializers are never public by default, so we // Default memberwise initializers are never public by default, so we
// declare one manually. // 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.issueStatus = issueStatus
self.pullStatus = pullStatus self.pullStatus = pullStatus
self.appearance = appearance self.appearance = appearance
self.notificationsEnabled = notificationsEnabled
} }
@@ -3647,7 +3858,8 @@ public struct FfiConverterTypeSettings: FfiConverterRustBuffer {
try Settings( try Settings(
issueStatus: FfiConverterString.read(from: &buf), issueStatus: FfiConverterString.read(from: &buf),
pullStatus: 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.issueStatus, into: &buf)
FfiConverterString.write(value.pullStatus, into: &buf) FfiConverterString.write(value.pullStatus, into: &buf)
FfiConverterUInt32.write(value.appearance, 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 { public enum RepositoryContentKind: Equatable, Hashable {
case directory 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) #if swift(>=5.8)
@_documentation(visibility: private) @_documentation(visibility: private)
#endif #endif
@@ -5234,6 +5538,15 @@ private let initializationResult: InitializationResult = {
if (uniffi_gotcha_core_checksum_method_gotchacore_set_milestone_closed() != 33) { if (uniffi_gotcha_core_checksum_method_gotchacore_set_milestone_closed() != 33) {
return InitializationResult.apiChecksumMismatch 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) { if (uniffi_gotcha_core_checksum_method_gotchacore_clear_pull_filters() != 61566) {
return InitializationResult.apiChecksumMismatch return InitializationResult.apiChecksumMismatch
} }
@@ -5288,6 +5601,9 @@ private let initializationResult: InitializationResult = {
if (uniffi_gotcha_core_checksum_method_gotchacore_set_issue_status() != 5573) { if (uniffi_gotcha_core_checksum_method_gotchacore_set_issue_status() != 5573) {
return InitializationResult.apiChecksumMismatch 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) { if (uniffi_gotcha_core_checksum_method_gotchacore_set_pull_status() != 40421) {
return InitializationResult.apiChecksumMismatch return InitializationResult.apiChecksumMismatch
} }

View File

@@ -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 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 #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 #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_CLEAR_PULL_FILTERS
#define 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 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 void uniffi_gotcha_core_fn_method_gotchacore_set_issue_status(uint64_t ptr, RustBuffer status, RustCallStatus *_Nonnull out_status
); );
#endif #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 #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_PULL_STATUS
#define 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 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 #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_MILESTONE_CLOSED
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_milestone_closed(void 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 #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_CLEAR_PULL_FILTERS #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 #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_ISSUE_STATUS
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_issue_status(void 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 #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_PULL_STATUS #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_PULL_STATUS

View File

@@ -16,7 +16,9 @@
381A27D70EA30C1A3BA1BBC1 /* CommentEditorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F9FFCC06E0313760524EBD2 /* CommentEditorViewController.swift */; }; 381A27D70EA30C1A3BA1BBC1 /* CommentEditorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F9FFCC06E0313760524EBD2 /* CommentEditorViewController.swift */; };
39C36B0D5FB260C920D466DC /* GotchaWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5121A6F14A9C9F6EA144BB2F /* GotchaWidgets.swift */; }; 39C36B0D5FB260C920D466DC /* GotchaWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5121A6F14A9C9F6EA144BB2F /* GotchaWidgets.swift */; };
4652515AE4CB10963D995143 /* CommitScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7588A44A93B8DB4B78C3B2BB /* CommitScreens.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 */; }; 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, ); }; }; 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 */; }; 7BBE64F66374221F1743BC24 /* IssueScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACD6449327E1A51FCD8E3BD4 /* IssueScreens.swift */; };
7DD332583B169B3CA1CA46E0 /* Highlighter in Frameworks */ = {isa = PBXBuildFile; productRef = EC5F999F50905E3801E8A71A /* Highlighter */; }; 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 = "<group>"; }; 99B8279189276A084B69D7D0 /* PullScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PullScreens.swift; sourceTree = "<group>"; };
9C0921C76676A14A024BA417 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 9C0921C76676A14A024BA417 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
ACD6449327E1A51FCD8E3BD4 /* IssueScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueScreens.swift; sourceTree = "<group>"; }; ACD6449327E1A51FCD8E3BD4 /* IssueScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueScreens.swift; sourceTree = "<group>"; };
BA5F9B46F07F3EB2333CD4DF /* NotificationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCoordinator.swift; sourceTree = "<group>"; };
C13A39F3C39C1F353D58C307 /* ServerScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerScreens.swift; sourceTree = "<group>"; }; C13A39F3C39C1F353D58C307 /* ServerScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerScreens.swift; sourceTree = "<group>"; };
CA582099DD57D35C748EFB02 /* MilestoneEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MilestoneEditorViewController.swift; sourceTree = "<group>"; }; CA582099DD57D35C748EFB02 /* MilestoneEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MilestoneEditorViewController.swift; sourceTree = "<group>"; };
D35C7ECBC3EEC8B4659234AE /* HomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreen.swift; sourceTree = "<group>"; }; D35C7ECBC3EEC8B4659234AE /* HomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreen.swift; sourceTree = "<group>"; };
DDAABE6B13ADC6D08D9438AF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; DDAABE6B13ADC6D08D9438AF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
DE88A28F99C6D0224865E04D /* NotificationsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsScreen.swift; sourceTree = "<group>"; };
F121BE52F7C9C8780341F988 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; }; F121BE52F7C9C8780341F988 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
F61515849F6AACD721FE915C /* AppContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppContext.swift; sourceTree = "<group>"; }; F61515849F6AACD721FE915C /* AppContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppContext.swift; sourceTree = "<group>"; };
F75B3E4FFB9C9992517C4D69 /* Support.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Support.swift; sourceTree = "<group>"; }; F75B3E4FFB9C9992517C4D69 /* Support.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Support.swift; sourceTree = "<group>"; };
@@ -130,6 +134,8 @@
ACD6449327E1A51FCD8E3BD4 /* IssueScreens.swift */, ACD6449327E1A51FCD8E3BD4 /* IssueScreens.swift */,
CA582099DD57D35C748EFB02 /* MilestoneEditorViewController.swift */, CA582099DD57D35C748EFB02 /* MilestoneEditorViewController.swift */,
64B529D84926EEEDC163A929 /* MilestoneScreens.swift */, 64B529D84926EEEDC163A929 /* MilestoneScreens.swift */,
BA5F9B46F07F3EB2333CD4DF /* NotificationCoordinator.swift */,
DE88A28F99C6D0224865E04D /* NotificationsScreen.swift */,
99B8279189276A084B69D7D0 /* PullScreens.swift */, 99B8279189276A084B69D7D0 /* PullScreens.swift */,
FD6BB62D12708255650508B2 /* RepositoryDirectoryScreen.swift */, FD6BB62D12708255650508B2 /* RepositoryDirectoryScreen.swift */,
181AE294D07DB4EAC9C9A0FF /* RepositoryFileScreens.swift */, 181AE294D07DB4EAC9C9A0FF /* RepositoryFileScreens.swift */,
@@ -351,6 +357,8 @@
7BBE64F66374221F1743BC24 /* IssueScreens.swift in Sources */, 7BBE64F66374221F1743BC24 /* IssueScreens.swift in Sources */,
1EDCCB5DE286C1DA407F00F1 /* MilestoneEditorViewController.swift in Sources */, 1EDCCB5DE286C1DA407F00F1 /* MilestoneEditorViewController.swift in Sources */,
C33BA07C5F7DA72CBF72CEAE /* MilestoneScreens.swift in Sources */, C33BA07C5F7DA72CBF72CEAE /* MilestoneScreens.swift in Sources */,
7A511844F3CBA0603A894DDE /* NotificationCoordinator.swift in Sources */,
6DAA1D3230197F537D70735E /* NotificationsScreen.swift in Sources */,
33D3E65C9E50522B2039816A /* PullScreens.swift in Sources */, 33D3E65C9E50522B2039816A /* PullScreens.swift in Sources */,
130339B2D7AEAC791E50140F /* RepositoryDirectoryScreen.swift in Sources */, 130339B2D7AEAC791E50140F /* RepositoryDirectoryScreen.swift in Sources */,
DC155F5DEB86D95FAF709E16 /* RepositoryFileScreens.swift in Sources */, DC155F5DEB86D95FAF709E16 /* RepositoryFileScreens.swift in Sources */,

View File

@@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>de.rfc1437.gotcha.notifications.refresh</string>
</array>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
@@ -33,6 +37,10 @@
<string>$(CURRENT_PROJECT_VERSION)</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
<false/> <false/>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
</array>
<key>UILaunchScreen</key> <key>UILaunchScreen</key>
<dict/> <dict/>
<key>UISupportedInterfaceOrientations</key> <key>UISupportedInterfaceOrientations</key>

View File

@@ -4,6 +4,7 @@ import WidgetKit
@MainActor @MainActor
final class AppContext { final class AppContext {
let core: GotchaCore let core: GotchaCore
lazy var notifications = NotificationCoordinator(context: self)
private let window: UIWindow private let window: UIWindow
private(set) var tabs = UITabBarController() private(set) var tabs = UITabBarController()
private(set) var navigationControllers: [UINavigationController] = [] private(set) var navigationControllers: [UINavigationController] = []
@@ -96,14 +97,77 @@ final class AppContext {
} }
func route(_ activity: ActivityRow) { 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] let navigation = navigationControllers[0]
switch activity.target { tabs.selectedIndex = 0
switch target {
case .repository: case .repository:
navigation.pushViewController( navigation.pushViewController(
IssuesViewController( IssuesViewController(
context: self, context: self,
owner: activity.owner, owner: owner,
repository: activity.repository repository: repository
), ),
animated: true animated: true
) )
@@ -111,9 +175,9 @@ final class AppContext {
navigation.pushViewController( navigation.pushViewController(
IssueViewController( IssueViewController(
context: self, context: self,
owner: activity.owner, owner: owner,
repository: activity.repository, repository: repository,
number: activity.number number: number
), ),
animated: true animated: true
) )
@@ -121,9 +185,9 @@ final class AppContext {
navigation.pushViewController( navigation.pushViewController(
PullViewController( PullViewController(
context: self, context: self,
owner: activity.owner, owner: owner,
repository: activity.repository, repository: repository,
number: activity.number number: number
), ),
animated: true animated: true
) )
@@ -131,9 +195,9 @@ final class AppContext {
navigation.pushViewController( navigation.pushViewController(
FilesViewController( FilesViewController(
context: self, context: self,
owner: activity.owner, owner: owner,
repository: activity.repository, repository: repository,
sha: activity.sha sha: sha
), ),
animated: true animated: true
) )

View File

@@ -16,6 +16,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
window.rootViewController = context.makeRootController() window.rootViewController = context.makeRootController()
window.makeKeyAndVisible() window.makeKeyAndVisible()
self.window = window self.window = window
context.notifications.start()
context.showStartupErrorIfNeeded() context.showStartupErrorIfNeeded()
if let url = launchOptions?[.url] as? URL { if let url = launchOptions?[.url] as? URL {
context.route(widgetURL: url) context.route(widgetURL: url)
@@ -33,5 +34,10 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
func applicationDidBecomeActive(_ application: UIApplication) { func applicationDidBecomeActive(_ application: UIApplication) {
WidgetCenter.shared.reloadAllTimelines() WidgetCenter.shared.reloadAllTimelines()
context?.notifications.applicationDidBecomeActive()
}
func applicationDidEnterBackground(_ application: UIApplication) {
context?.notifications.applicationDidEnterBackground()
} }
} }

View File

@@ -104,12 +104,23 @@ final class HomeViewController: RefreshingTableViewController {
finishPagination(hasMore: result.nextPage != nil) finishPagination(hasMore: result.nextPage != nil)
title = page?.serverName title = page?.serverName
if requestedPage == 1 { tableView.tableHeaderView = page.map { page in 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 let self, let filter = ActivityFilter(rawValue: index) else { return }
guard filter != self.filter else { return } guard filter != self.filter else { return }
self.filter = filter self.filter = filter
self.loadPage(1, refreshing: false) self.loadPage(1, refreshing: false)
},
onNotifications: { [weak self] in
guard let self else { return }
self.navigationController?.pushViewController(
NotificationsViewController(context: self.context),
animated: true
)
} }
)
} } } }
updateActivities() updateActivities()
} catch { } catch {
@@ -179,7 +190,12 @@ final class HeatmapView: UIView {
private let cells: [HeatCell] private let cells: [HeatCell]
private let calendar = Calendar(identifier: .gregorian) 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 cells = page.heatCells
super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 180)) super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 180))
backgroundColor = .systemBackground backgroundColor = .systemBackground
@@ -225,6 +241,15 @@ final class HeatmapView: UIView {
button.widthAnchor.constraint(equalToConstant: 44).isActive = true button.widthAnchor.constraint(equalToConstant: 44).isActive = true
filters.addArrangedSubview(button) filters.addArrangedSubview(button)
} }
let 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) addSubview(filters)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
filters.centerXAnchor.constraint(equalTo: centerXAnchor), filters.centerXAnchor.constraint(equalTo: centerXAnchor),

View File

@@ -0,0 +1,197 @@
import BackgroundTasks
import UIKit
import UserNotifications
@MainActor
final class NotificationCoordinator: NSObject, 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<Void, Never>?
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() {
refresh(deliverAlerts: false)
}
func applicationDidEnterBackground() {
scheduleBackgroundRefresh()
}
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() }
}
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions {
[]
}
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse
) async {
let userInfo = response.notification.request.content.userInfo
await MainActor.run {
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"
}
}
}

View File

@@ -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)
}
}

View File

@@ -4,6 +4,8 @@ import UIKit
final class SettingsViewController: UITableViewController { final class SettingsViewController: UITableViewController {
private let context: AppContext private let context: AppContext
private let appearanceControl = UISegmentedControl(items: ["Auto", "Light", "Dark"]) private let appearanceControl = UISegmentedControl(items: ["Auto", "Light", "Dark"])
private let notificationSwitch = UISwitch()
private var notificationStatus = "Managed by iOS"
init(context: AppContext) { init(context: AppContext) {
self.context = context self.context = context
@@ -19,23 +21,58 @@ final class SettingsViewController: UITableViewController {
let settings = context.core.settings() let settings = context.core.settings()
appearanceControl.selectedSegmentIndex = Int(settings.appearance) appearanceControl.selectedSegmentIndex = Int(settings.appearance)
appearanceControl.addTarget(self, action: #selector(appearanceChanged), for: .valueChanged) 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 viewWillAppear(_ animated: Bool) {
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 1 } super.viewWillAppear(animated)
notificationSwitch.isOn = context.core.settings().notificationsEnabled
Task {
notificationStatus = await context.notifications.authorizationDescription()
tableView.reloadSections(IndexSet(integer: 1), with: .none)
}
}
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? { override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
"Appearance" section == 0 ? "Appearance" : "Notifications"
} }
override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { 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( override func tableView(
_ tableView: UITableView, _ tableView: UITableView,
cellForRowAt indexPath: IndexPath cellForRowAt indexPath: IndexPath
) -> UITableViewCell { ) -> 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) let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
appearanceControl.translatesAutoresizingMaskIntoConstraints = false appearanceControl.translatesAutoresizingMaskIntoConstraints = false
cell.contentView.addSubview(appearanceControl) cell.contentView.addSubview(appearanceControl)
@@ -48,6 +85,12 @@ final class SettingsViewController: UITableViewController {
return cell 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() { @objc private func appearanceChanged() {
do { do {
try context.core.setAppearance(index: UInt32(appearanceControl.selectedSegmentIndex)) try context.core.setAppearance(index: UInt32(appearanceControl.selectedSegmentIndex))
@@ -56,4 +99,35 @@ final class SettingsViewController: UITableViewController {
show(error: error) 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()
tableView.reloadSections(IndexSet(integer: 1), with: .none)
}
}
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)
}
} }

View File

@@ -34,11 +34,15 @@ targets:
CFBundleShortVersionString: "$(MARKETING_VERSION)" CFBundleShortVersionString: "$(MARKETING_VERSION)"
CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" CFBundleVersion: "$(CURRENT_PROJECT_VERSION)"
ITSAppUsesNonExemptEncryption: false ITSAppUsesNonExemptEncryption: false
BGTaskSchedulerPermittedIdentifiers:
- de.rfc1437.gotcha.notifications.refresh
CFBundleURLTypes: CFBundleURLTypes:
- CFBundleURLName: de.rfc1437.gotcha - CFBundleURLName: de.rfc1437.gotcha
CFBundleURLSchemes: CFBundleURLSchemes:
- gotcha - gotcha
UILaunchScreen: {} UILaunchScreen: {}
UIBackgroundModes:
- fetch
UISupportedInterfaceOrientations: UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait - UIInterfaceOrientationPortrait
sources: sources: