Add native iOS notifications (#64)
This commit is contained in:
40
TESTING.md
40
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
|
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,45 @@ 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. 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
|
## 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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
137
crates/app/src/core/notifications.rs
Normal file
137
crates/app/src/core/notifications.rs
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use crate::*;
|
||||||
|
|
||||||
|
#[uniffi::export(async_runtime = "tokio")]
|
||||||
|
impl GotchaCore {
|
||||||
|
pub async fn notifications(
|
||||||
|
&self,
|
||||||
|
status: NotificationStatus,
|
||||||
|
page: u32,
|
||||||
|
) -> Result<NotificationListPage, GotchaError> {
|
||||||
|
let server = self.server()?;
|
||||||
|
let result = load_notifications(
|
||||||
|
&server,
|
||||||
|
matches!(status, NotificationStatus::Open),
|
||||||
|
valid_page(page)?,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(NotificationListPage {
|
||||||
|
rows: notification_rows(&server.credential_account, result.items),
|
||||||
|
has_more: result.has_more,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn mark_notification_read(
|
||||||
|
&self,
|
||||||
|
server_id: String,
|
||||||
|
id: i64,
|
||||||
|
) -> Result<(), GotchaError> {
|
||||||
|
let server = self.server_by_id(&server_id)?;
|
||||||
|
mark_notification_read(&server, id)
|
||||||
|
.await
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn poll_notifications(&self) -> Result<Vec<NotificationRow>, GotchaError> {
|
||||||
|
let servers = {
|
||||||
|
let state = self.state.lock().unwrap();
|
||||||
|
if !state.preferences.notifications_enabled {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
state
|
||||||
|
.preferences
|
||||||
|
.servers
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(|server| {
|
||||||
|
let cursor = state
|
||||||
|
.preferences
|
||||||
|
.notification_cursors
|
||||||
|
.get(&server.credential_account)
|
||||||
|
.cloned();
|
||||||
|
(server, cursor)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
let polled_at = gotcha_gitea::api_timestamp(
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs() as i64,
|
||||||
|
);
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
let mut cursors = Vec::new();
|
||||||
|
let mut first_error = None;
|
||||||
|
for (server, cursor) in servers {
|
||||||
|
let notifications = match load_notification_updates(&server, cursor.as_deref()).await {
|
||||||
|
Ok(notifications) => notifications,
|
||||||
|
Err(error) => {
|
||||||
|
first_error.get_or_insert(error);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let next_cursor = notification_cursor(&polled_at, ¬ifications);
|
||||||
|
if cursor.is_some() {
|
||||||
|
rows.extend(notification_rows(&server.credential_account, notifications));
|
||||||
|
}
|
||||||
|
cursors.push((server.credential_account, next_cursor));
|
||||||
|
}
|
||||||
|
|
||||||
|
if cursors.is_empty()
|
||||||
|
&& let Some(error) = first_error
|
||||||
|
{
|
||||||
|
return Err(error.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
if !state.preferences.notifications_enabled {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
for (server_id, cursor) in cursors {
|
||||||
|
if state
|
||||||
|
.preferences
|
||||||
|
.servers
|
||||||
|
.iter()
|
||||||
|
.any(|server| server.credential_account == server_id)
|
||||||
|
{
|
||||||
|
state
|
||||||
|
.preferences
|
||||||
|
.notification_cursors
|
||||||
|
.insert(server_id, cursor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
save_preferences(&state.preferences)?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn notification_cursor(
|
||||||
|
polled_at: &str,
|
||||||
|
notifications: &[gotcha_gitea::models::NotificationThread],
|
||||||
|
) -> String {
|
||||||
|
notifications
|
||||||
|
.iter()
|
||||||
|
.filter_map(|notification| notification.updated_at.as_deref())
|
||||||
|
.fold(polled_at.into(), |cursor, updated| {
|
||||||
|
cursor.max(updated.into())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cursor_does_not_skip_an_update_that_arrived_during_polling() {
|
||||||
|
let notifications = vec![gotcha_gitea::models::NotificationThread {
|
||||||
|
updated_at: Some("2026-08-15T10:00:01Z".into()),
|
||||||
|
..Default::default()
|
||||||
|
}];
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
notification_cursor("2026-08-15T10:00:00Z", ¬ifications),
|
||||||
|
"2026-08-15T10:00:01Z"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -192,6 +192,9 @@ impl GotchaCore {
|
|||||||
.ok_or("That server no longer exists.")?;
|
.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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
142
crates/app/src/presentation/notifications.rs
Normal file
142
crates/app/src/presentation/notifications.rs
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
use gotcha_gitea::{models, notifications};
|
||||||
|
|
||||||
|
use super::{ActivityTargetKind, NotificationRow, compact_date};
|
||||||
|
|
||||||
|
pub fn notification_rows(
|
||||||
|
server_id: &str,
|
||||||
|
notifications: Vec<models::NotificationThread>,
|
||||||
|
) -> Vec<NotificationRow> {
|
||||||
|
notifications
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|notification| notification_row(server_id, notification))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn notification_row(
|
||||||
|
server_id: &str,
|
||||||
|
notification: models::NotificationThread,
|
||||||
|
) -> Option<NotificationRow> {
|
||||||
|
let id = notification.id?;
|
||||||
|
let title = notification
|
||||||
|
.subject
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|subject| subject.title.clone())
|
||||||
|
.filter(|title| !title.is_empty())
|
||||||
|
.unwrap_or_else(|| "Server notification".into());
|
||||||
|
let kind = notification
|
||||||
|
.subject
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|subject| subject.r#type.as_deref())
|
||||||
|
.unwrap_or("Update");
|
||||||
|
let full_name = notification
|
||||||
|
.repository
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|repository| repository.full_name.clone())
|
||||||
|
.or_else(|| {
|
||||||
|
let repository = notification.repository.as_deref()?;
|
||||||
|
Some(format!(
|
||||||
|
"{}/{}",
|
||||||
|
repository.owner.as_deref()?.login.as_deref()?,
|
||||||
|
repository.name.as_deref()?
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "Server".into());
|
||||||
|
let (target, owner, repository, number, sha) = match notifications::target(¬ification) {
|
||||||
|
notifications::NotificationTarget::None => (
|
||||||
|
ActivityTargetKind::None,
|
||||||
|
String::new(),
|
||||||
|
String::new(),
|
||||||
|
0,
|
||||||
|
String::new(),
|
||||||
|
),
|
||||||
|
notifications::NotificationTarget::Repository { owner, repository } => (
|
||||||
|
ActivityTargetKind::Repository,
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
0,
|
||||||
|
String::new(),
|
||||||
|
),
|
||||||
|
notifications::NotificationTarget::Issue {
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
number,
|
||||||
|
} => (
|
||||||
|
ActivityTargetKind::Issue,
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
number,
|
||||||
|
String::new(),
|
||||||
|
),
|
||||||
|
notifications::NotificationTarget::Pull {
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
number,
|
||||||
|
} => (
|
||||||
|
ActivityTargetKind::PullRequest,
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
number,
|
||||||
|
String::new(),
|
||||||
|
),
|
||||||
|
notifications::NotificationTarget::Commit {
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
sha,
|
||||||
|
} => (ActivityTargetKind::Commit, owner, repository, 0, sha),
|
||||||
|
};
|
||||||
|
Some(NotificationRow {
|
||||||
|
id,
|
||||||
|
server_id: server_id.into(),
|
||||||
|
title,
|
||||||
|
detail: format!("{kind} · {full_name}"),
|
||||||
|
meta: compact_date(notification.updated_at.as_deref()),
|
||||||
|
unread: notification.unread.unwrap_or_default(),
|
||||||
|
target,
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
number,
|
||||||
|
sha,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn presents_targetable_notification_without_exposing_api_models() {
|
||||||
|
let rows = notification_rows(
|
||||||
|
"work",
|
||||||
|
vec![models::NotificationThread {
|
||||||
|
id: Some(9),
|
||||||
|
unread: Some(true),
|
||||||
|
updated_at: Some("2026-08-15T10:00:00Z".into()),
|
||||||
|
repository: Some(Box::new(models::Repository {
|
||||||
|
full_name: Some("octo/demo".into()),
|
||||||
|
name: Some("demo".into()),
|
||||||
|
owner: Some(Box::new(models::User {
|
||||||
|
login: Some("octo".into()),
|
||||||
|
..Default::default()
|
||||||
|
})),
|
||||||
|
..Default::default()
|
||||||
|
})),
|
||||||
|
subject: Some(Box::new(models::NotificationSubject {
|
||||||
|
title: Some("Fix the bug".into()),
|
||||||
|
r#type: Some("Issue".into()),
|
||||||
|
url: Some("https://example/api/v1/repos/octo/demo/issues/42".into()),
|
||||||
|
..Default::default()
|
||||||
|
})),
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(rows.len(), 1);
|
||||||
|
assert_eq!(rows[0].id, 9);
|
||||||
|
assert_eq!(rows[0].server_id, "work");
|
||||||
|
assert_eq!(rows[0].title, "Fix the bug");
|
||||||
|
assert_eq!(rows[0].detail, "Issue · octo/demo");
|
||||||
|
assert!(rows[0].unread);
|
||||||
|
assert_eq!(rows[0].target, ActivityTargetKind::Issue);
|
||||||
|
assert_eq!(rows[0].number, 42);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
212
crates/gitea/src/notifications.rs
Normal file
212
crates/gitea/src/notifications.rs
Normal 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(¬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(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 */,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
212
ios/Sources/NotificationCoordinator.swift
Normal file
212
ios/Sources/NotificationCoordinator.swift
Normal file
@@ -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<Void, Never>?
|
||||||
|
#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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
179
ios/Sources/NotificationsScreen.swift
Normal file
179
ios/Sources/NotificationsScreen.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
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? {
|
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,43 @@ 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()
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user