Compare commits
26
Commits
@@ -367,6 +367,14 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
try await live.deleteAccount()
|
try await live.deleteAccount()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
|
||||||
|
try await live.subscribeToNotifications(eventType: eventType)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
|
||||||
|
try await live.unsubscribeFromNotifications(eventType: eventType)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Sync management (Settings surface)
|
// MARK: - Sync management (Settings surface)
|
||||||
|
|
||||||
public func pendingOperations() async -> [PendingOperationSummary] {
|
public func pendingOperations() async -> [PendingOperationSummary] {
|
||||||
|
|||||||
@@ -91,4 +91,7 @@ public protocol OutlineAPIClient: Sendable {
|
|||||||
/// confirmation code param confirmed live, matches every other simple
|
/// confirmation code param confirmed live, matches every other simple
|
||||||
/// no-body delete in this API.
|
/// no-body delete in this API.
|
||||||
func deleteAccount() async throws
|
func deleteAccount() async throws
|
||||||
|
/// `nil` targets every notification event. See `NotificationEventType`.
|
||||||
|
func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser
|
||||||
|
func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -302,6 +302,14 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
|||||||
try await postForSuccess("users.delete", body: EmptyParams())
|
try await postForSuccess("users.delete", body: EmptyParams())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
|
||||||
|
try await post("users.notificationsSubscribe", body: NotificationSubscriptionRequest(eventType: eventType?.rawValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
|
||||||
|
try await post("users.notificationsUnsubscribe", body: NotificationSubscriptionRequest(eventType: eventType?.rawValue))
|
||||||
|
}
|
||||||
|
|
||||||
private func post<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response {
|
private func post<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response {
|
||||||
guard let token = try? tokenStore.token() else {
|
guard let token = try? tokenStore.token() else {
|
||||||
throw OutlineAPIError.tokenUnavailable
|
throw OutlineAPIError.tokenUnavailable
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The subset of Outline's notification event types this app exposes a
|
||||||
|
/// toggle for. Wire values confirmed live (captured `users.update`
|
||||||
|
/// responses showing the full `notificationSettings` dictionary after
|
||||||
|
/// toggling each one in Outline's own web app). The server tracks more
|
||||||
|
/// event types than this app has UI for (`revisions.create`,
|
||||||
|
/// `emails.onboarding`, `emails.features` were also seen live) — those are
|
||||||
|
/// left alone since `users.notificationsSubscribe`/`Unsubscribe` are
|
||||||
|
/// per-event, not a whole-object replace like `preferences`, so there's no
|
||||||
|
/// clobbering risk in only covering a subset.
|
||||||
|
public enum NotificationEventType: String, CaseIterable, Sendable {
|
||||||
|
case documentPublish = "documents.publish"
|
||||||
|
case documentUpdate = "documents.update"
|
||||||
|
case commentCreate = "comments.create"
|
||||||
|
case commentMentioned = "comments.mentioned"
|
||||||
|
case documentMentioned = "documents.mentioned"
|
||||||
|
case commentGroupMentioned = "comments.group_mentioned"
|
||||||
|
case documentGroupMentioned = "documents.group_mentioned"
|
||||||
|
case commentResolve = "comments.resolve"
|
||||||
|
case reactionCreate = "reactions.create"
|
||||||
|
case collectionCreate = "collections.create"
|
||||||
|
case emailsInviteAccepted = "emails.invite_accepted"
|
||||||
|
case documentAddUser = "documents.add_user"
|
||||||
|
case collectionAddUser = "collections.add_user"
|
||||||
|
case emailsExportCompleted = "emails.export_completed"
|
||||||
|
case accessRequestCreate = "access_requests.create"
|
||||||
|
}
|
||||||
@@ -8,6 +8,10 @@ public struct OutlineUser: Codable, Identifiable, Hashable, Sendable {
|
|||||||
public let role: String?
|
public let role: String?
|
||||||
public let language: String?
|
public let language: String?
|
||||||
public let preferences: OutlineUserPreferences?
|
public let preferences: OutlineUserPreferences?
|
||||||
|
/// Keyed by `NotificationEventType`'s raw values, plus event types this
|
||||||
|
/// app has no UI for — kept as a flexible dictionary rather than a
|
||||||
|
/// fixed struct for that reason. `true` means subscribed.
|
||||||
|
public let notificationSettings: [String: Bool]?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
id: String,
|
id: String,
|
||||||
@@ -16,7 +20,8 @@ public struct OutlineUser: Codable, Identifiable, Hashable, Sendable {
|
|||||||
avatarUrl: String? = nil,
|
avatarUrl: String? = nil,
|
||||||
role: String? = nil,
|
role: String? = nil,
|
||||||
language: String? = nil,
|
language: String? = nil,
|
||||||
preferences: OutlineUserPreferences? = nil
|
preferences: OutlineUserPreferences? = nil,
|
||||||
|
notificationSettings: [String: Bool]? = nil
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.name = name
|
self.name = name
|
||||||
@@ -25,5 +30,6 @@ public struct OutlineUser: Codable, Identifiable, Hashable, Sendable {
|
|||||||
self.role = role
|
self.role = role
|
||||||
self.language = language
|
self.language = language
|
||||||
self.preferences = preferences
|
self.preferences = preferences
|
||||||
|
self.notificationSettings = notificationSettings
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// `users.notificationsSubscribe` / `users.notificationsUnsubscribe`. A
|
||||||
|
/// `nil` `eventType` targets every notification event at once — confirmed
|
||||||
|
/// live via Outline's own "All notifications" master toggle, which sends
|
||||||
|
/// no `eventType` at all.
|
||||||
|
public struct NotificationSubscriptionRequest: Encodable, Sendable {
|
||||||
|
public let eventType: String?
|
||||||
|
|
||||||
|
public init(eventType: String? = nil) {
|
||||||
|
self.eventType = eventType
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,6 +97,8 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
|
|||||||
func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser { throw NotStubbed() }
|
func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser { throw NotStubbed() }
|
||||||
func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser { throw NotStubbed() }
|
func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser { throw NotStubbed() }
|
||||||
func deleteAccount() async throws { throw NotStubbed() }
|
func deleteAccount() async throws { throw NotStubbed() }
|
||||||
|
func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser { throw NotStubbed() }
|
||||||
|
func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser { throw NotStubbed() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct StubTransportError: Error {}
|
private struct StubTransportError: Error {}
|
||||||
|
|||||||
@@ -1178,6 +1178,57 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
|||||||
XCTAssertNil(object?["smartText"])
|
XCTAssertNil(object?["smartText"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testSubscribeToNotificationsSendsEventTypeAndDecodesResult() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "user-1",
|
||||||
|
"name": "Jane Doe",
|
||||||
|
"notificationSettings": { "documents.publish": true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let user = try await client.subscribeToNotifications(eventType: .documentPublish)
|
||||||
|
|
||||||
|
XCTAssertEqual(user.notificationSettings?["documents.publish"], true)
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.notificationsSubscribe")
|
||||||
|
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
|
||||||
|
XCTAssertEqual(sentBody?["eventType"] as? String, "documents.publish")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUnsubscribeFromNotificationsWithNilEventTypeTargetsAll() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "user-1",
|
||||||
|
"name": "Jane Doe",
|
||||||
|
"notificationSettings": { "documents.publish": false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
_ = try await client.unsubscribeFromNotifications(eventType: nil)
|
||||||
|
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.notificationsUnsubscribe")
|
||||||
|
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
|
||||||
|
XCTAssertNil(sentBody?["eventType"])
|
||||||
|
}
|
||||||
|
|
||||||
func testDeleteAccountSendsRequest() async throws {
|
func testDeleteAccountSendsRequest() async throws {
|
||||||
let httpClient = MockHTTPClient()
|
let httpClient = MockHTTPClient()
|
||||||
httpClient.responseData = """
|
httpClient.responseData = """
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ struct SettingsView: View {
|
|||||||
@State private var isShowingDeleteAccountConfirmation = false
|
@State private var isShowingDeleteAccountConfirmation = false
|
||||||
@State private var isDeletingAccount = false
|
@State private var isDeletingAccount = false
|
||||||
@State private var deleteAccountErrorMessage: String?
|
@State private var deleteAccountErrorMessage: String?
|
||||||
|
@State private var isSavingNotifications = false
|
||||||
|
@State private var notificationsErrorMessage: String?
|
||||||
|
|
||||||
/// Full Local Sync and cache-clearing both need a real connection to be
|
/// Full Local Sync and cache-clearing both need a real connection to be
|
||||||
/// safe — clearing while offline (or letting Full Local Sync think it
|
/// safe — clearing while offline (or letting Full Local Sync think it
|
||||||
@@ -79,6 +81,7 @@ struct SettingsView: View {
|
|||||||
case .appearance: appearanceDetail
|
case .appearance: appearanceDetail
|
||||||
case .profile: profileDetail
|
case .profile: profileDetail
|
||||||
case .preferences: preferencesDetail
|
case .preferences: preferencesDetail
|
||||||
|
case .notifications: notificationsDetail
|
||||||
case .offlineSync: offlineSyncDetail
|
case .offlineSync: offlineSyncDetail
|
||||||
case .advanced: advancedDetail
|
case .advanced: advancedDetail
|
||||||
case .about: aboutDetail
|
case .about: aboutDetail
|
||||||
@@ -127,6 +130,9 @@ struct SettingsView: View {
|
|||||||
private var profileDetail: some View {
|
private var profileDetail: some View {
|
||||||
VStack(alignment: .leading, spacing: 24) {
|
VStack(alignment: .leading, spacing: 24) {
|
||||||
sectionHeader
|
sectionHeader
|
||||||
|
if !isEffectivelyOnline {
|
||||||
|
offlineSettingsHint
|
||||||
|
}
|
||||||
avatarRow
|
avatarRow
|
||||||
Divider().frame(maxWidth: 420)
|
Divider().frame(maxWidth: 420)
|
||||||
nameRow
|
nameRow
|
||||||
@@ -184,6 +190,7 @@ struct SettingsView: View {
|
|||||||
ProgressView().controlSize(.small)
|
ProgressView().controlSize(.small)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.disabled(!isEffectivelyOnline)
|
||||||
if let avatarErrorMessage {
|
if let avatarErrorMessage {
|
||||||
Text(avatarErrorMessage)
|
Text(avatarErrorMessage)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -212,6 +219,7 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.disabled(!isEffectivelyOnline)
|
||||||
if let nameErrorMessage {
|
if let nameErrorMessage {
|
||||||
Text(nameErrorMessage)
|
Text(nameErrorMessage)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -248,8 +256,13 @@ struct SettingsView: View {
|
|||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
if !isEffectivelyOnline {
|
||||||
|
offlineSettingsHint
|
||||||
|
}
|
||||||
|
|
||||||
preferencesSubsection("Display") {
|
preferencesSubsection("Display") {
|
||||||
languageRow
|
languageRow
|
||||||
|
.disabled(!isEffectivelyOnline)
|
||||||
Divider()
|
Divider()
|
||||||
appearanceRow
|
appearanceRow
|
||||||
Divider()
|
Divider()
|
||||||
@@ -259,6 +272,7 @@ struct SettingsView: View {
|
|||||||
isOn: session.userPreferences?.useCursorPointer ?? false,
|
isOn: session.userPreferences?.useCursorPointer ?? false,
|
||||||
onChange: { newValue in Task { await savePreference { $0.useCursorPointer = newValue } } }
|
onChange: { newValue in Task { await savePreference { $0.useCursorPointer = newValue } } }
|
||||||
)
|
)
|
||||||
|
.disabled(!isEffectivelyOnline)
|
||||||
Divider()
|
Divider()
|
||||||
preferenceToggleRow(
|
preferenceToggleRow(
|
||||||
"Show line numbers",
|
"Show line numbers",
|
||||||
@@ -266,6 +280,7 @@ struct SettingsView: View {
|
|||||||
isOn: session.userPreferences?.codeBlockLineNumbers ?? false,
|
isOn: session.userPreferences?.codeBlockLineNumbers ?? false,
|
||||||
onChange: { newValue in Task { await savePreference { $0.codeBlockLineNumbers = newValue } } }
|
onChange: { newValue in Task { await savePreference { $0.codeBlockLineNumbers = newValue } } }
|
||||||
)
|
)
|
||||||
|
.disabled(!isEffectivelyOnline)
|
||||||
Divider()
|
Divider()
|
||||||
preferenceToggleRow(
|
preferenceToggleRow(
|
||||||
"Show comment marker",
|
"Show comment marker",
|
||||||
@@ -273,6 +288,7 @@ struct SettingsView: View {
|
|||||||
isOn: session.userPreferences?.showCommentMarker ?? false,
|
isOn: session.userPreferences?.showCommentMarker ?? false,
|
||||||
onChange: { newValue in Task { await savePreference { $0.showCommentMarker = newValue } } }
|
onChange: { newValue in Task { await savePreference { $0.showCommentMarker = newValue } } }
|
||||||
)
|
)
|
||||||
|
.disabled(!isEffectivelyOnline)
|
||||||
}
|
}
|
||||||
|
|
||||||
preferencesSubsection("Behavior") {
|
preferencesSubsection("Behavior") {
|
||||||
@@ -299,6 +315,7 @@ struct SettingsView: View {
|
|||||||
Divider()
|
Divider()
|
||||||
notificationBadgeRow
|
notificationBadgeRow
|
||||||
}
|
}
|
||||||
|
.disabled(!isEffectivelyOnline)
|
||||||
|
|
||||||
if let preferencesErrorMessage {
|
if let preferencesErrorMessage {
|
||||||
Text(preferencesErrorMessage)
|
Text(preferencesErrorMessage)
|
||||||
@@ -309,6 +326,7 @@ struct SettingsView: View {
|
|||||||
preferencesSubsection("Danger") {
|
preferencesSubsection("Danger") {
|
||||||
deleteAccountRow
|
deleteAccountRow
|
||||||
}
|
}
|
||||||
|
.disabled(!isEffectivelyOnline)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: 480, alignment: .leading)
|
.frame(maxWidth: 480, alignment: .leading)
|
||||||
.task { await refreshProfile() }
|
.task { await refreshProfile() }
|
||||||
@@ -529,6 +547,174 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Notifications
|
||||||
|
|
||||||
|
private var notificationsDetail: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
sectionHeader
|
||||||
|
Text("Manage when and where you receive email notifications.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
if !isEffectivelyOnline {
|
||||||
|
offlineSettingsHint
|
||||||
|
}
|
||||||
|
|
||||||
|
allNotificationsRow
|
||||||
|
Divider()
|
||||||
|
notificationToggleRow(
|
||||||
|
"Document published",
|
||||||
|
description: "Receive a notification whenever a new document is published",
|
||||||
|
isOn: session.userNotificationSettings?[NotificationEventType.documentPublish.rawValue] ?? false,
|
||||||
|
onChange: { newValue in Task { await setNotifications([.documentPublish], subscribed: newValue) } }
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
|
notificationToggleRow(
|
||||||
|
"Document updated",
|
||||||
|
description: "Receive a notification when a document you are subscribed to is edited",
|
||||||
|
isOn: session.userNotificationSettings?[NotificationEventType.documentUpdate.rawValue] ?? false,
|
||||||
|
onChange: { newValue in Task { await setNotifications([.documentUpdate], subscribed: newValue) } }
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
|
notificationToggleRow(
|
||||||
|
"Comment posted",
|
||||||
|
description: "Receive a notification when a document you are subscribed to or a thread you participated in receives a comment",
|
||||||
|
isOn: session.userNotificationSettings?[NotificationEventType.commentCreate.rawValue] ?? false,
|
||||||
|
onChange: { newValue in Task { await setNotifications([.commentCreate], subscribed: newValue) } }
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
|
notificationToggleRow(
|
||||||
|
"Mentioned",
|
||||||
|
description: "Receive a notification when someone mentions you in a document or comment",
|
||||||
|
isOn: [NotificationEventType.commentMentioned, .documentMentioned].allSatisfy { session.userNotificationSettings?[$0.rawValue] ?? false },
|
||||||
|
onChange: { newValue in Task { await setNotifications([.commentMentioned, .documentMentioned], subscribed: newValue) } }
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
|
notificationToggleRow(
|
||||||
|
"Group mentions",
|
||||||
|
description: "Receive a notification when someone mentions a group you are a member of in a document or comment",
|
||||||
|
isOn: [NotificationEventType.commentGroupMentioned, .documentGroupMentioned].allSatisfy { session.userNotificationSettings?[$0.rawValue] ?? false },
|
||||||
|
onChange: { newValue in Task { await setNotifications([.commentGroupMentioned, .documentGroupMentioned], subscribed: newValue) } }
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
|
notificationToggleRow(
|
||||||
|
"Resolved",
|
||||||
|
description: "Receive a notification when a comment thread you were involved in is resolved",
|
||||||
|
isOn: session.userNotificationSettings?[NotificationEventType.commentResolve.rawValue] ?? false,
|
||||||
|
onChange: { newValue in Task { await setNotifications([.commentResolve], subscribed: newValue) } }
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
|
notificationToggleRow(
|
||||||
|
"Reaction added",
|
||||||
|
description: "Receive a notification when someone reacts to your comment",
|
||||||
|
isOn: session.userNotificationSettings?[NotificationEventType.reactionCreate.rawValue] ?? false,
|
||||||
|
onChange: { newValue in Task { await setNotifications([.reactionCreate], subscribed: newValue) } }
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
|
notificationToggleRow(
|
||||||
|
"Collection created",
|
||||||
|
description: "Receive a notification whenever a new collection is created",
|
||||||
|
isOn: session.userNotificationSettings?[NotificationEventType.collectionCreate.rawValue] ?? false,
|
||||||
|
onChange: { newValue in Task { await setNotifications([.collectionCreate], subscribed: newValue) } }
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
|
notificationToggleRow(
|
||||||
|
"Invite accepted",
|
||||||
|
description: "Receive a notification when someone you invited creates an account",
|
||||||
|
isOn: session.userNotificationSettings?[NotificationEventType.emailsInviteAccepted.rawValue] ?? false,
|
||||||
|
onChange: { newValue in Task { await setNotifications([.emailsInviteAccepted], subscribed: newValue) } }
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
|
notificationToggleRow(
|
||||||
|
"Invited to document",
|
||||||
|
description: "Receive a notification when a document is shared with you",
|
||||||
|
isOn: session.userNotificationSettings?[NotificationEventType.documentAddUser.rawValue] ?? false,
|
||||||
|
onChange: { newValue in Task { await setNotifications([.documentAddUser], subscribed: newValue) } }
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
|
notificationToggleRow(
|
||||||
|
"Invited to collection",
|
||||||
|
description: "Receive a notification when you are given access to a collection",
|
||||||
|
isOn: session.userNotificationSettings?[NotificationEventType.collectionAddUser.rawValue] ?? false,
|
||||||
|
onChange: { newValue in Task { await setNotifications([.collectionAddUser], subscribed: newValue) } }
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
|
notificationToggleRow(
|
||||||
|
"Export completed",
|
||||||
|
description: "Receive a notification when an export you requested has been completed",
|
||||||
|
isOn: session.userNotificationSettings?[NotificationEventType.emailsExportCompleted.rawValue] ?? false,
|
||||||
|
onChange: { newValue in Task { await setNotifications([.emailsExportCompleted], subscribed: newValue) } }
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
|
notificationToggleRow(
|
||||||
|
"Document access requested",
|
||||||
|
description: "Receive a notification when a user requests access to a document you manage",
|
||||||
|
isOn: session.userNotificationSettings?[NotificationEventType.accessRequestCreate.rawValue] ?? false,
|
||||||
|
onChange: { newValue in Task { await setNotifications([.accessRequestCreate], subscribed: newValue) } }
|
||||||
|
)
|
||||||
|
|
||||||
|
if let notificationsErrorMessage {
|
||||||
|
Text(notificationsErrorMessage)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(!isEffectivelyOnline)
|
||||||
|
.frame(maxWidth: 480, alignment: .leading)
|
||||||
|
.task { await refreshProfile() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var allNotificationsRow: some View {
|
||||||
|
let isOn = NotificationEventType.allCases.allSatisfy { session.userNotificationSettings?[$0.rawValue] ?? false }
|
||||||
|
return notificationToggleRow(
|
||||||
|
"All notifications",
|
||||||
|
description: nil,
|
||||||
|
isOn: isOn,
|
||||||
|
onChange: { newValue in Task { await setNotifications(nil, subscribed: newValue) } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notificationToggleRow(
|
||||||
|
_ title: String,
|
||||||
|
description: String?,
|
||||||
|
isOn: Bool,
|
||||||
|
onChange: @escaping (Bool) -> Void
|
||||||
|
) -> some View {
|
||||||
|
Toggle(isOn: Binding(get: { isOn }, set: onChange)) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(title)
|
||||||
|
if let description {
|
||||||
|
Text(description)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `eventTypes` groups more than one wire event under a single visible
|
||||||
|
/// toggle (e.g. "Mentioned" covers both `comments.mentioned` and
|
||||||
|
/// `documents.mentioned`) — confirmed as the right grouping against
|
||||||
|
/// Outline's own settings copy, applied with one subscribe/unsubscribe
|
||||||
|
/// call per underlying event type, sequentially.
|
||||||
|
private func setNotifications(_ eventTypes: [NotificationEventType]?, subscribed: Bool) async {
|
||||||
|
guard let apiClient = session.apiClient else { return }
|
||||||
|
isSavingNotifications = true
|
||||||
|
defer { isSavingNotifications = false }
|
||||||
|
let targets: [NotificationEventType?] = eventTypes ?? [nil]
|
||||||
|
do {
|
||||||
|
for target in targets {
|
||||||
|
let updated = subscribed
|
||||||
|
? try await apiClient.subscribeToNotifications(eventType: target)
|
||||||
|
: try await apiClient.unsubscribeFromNotifications(eventType: target)
|
||||||
|
session.applyUpdatedProfile(updated)
|
||||||
|
}
|
||||||
|
notificationsErrorMessage = nil
|
||||||
|
} catch {
|
||||||
|
notificationsErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update your notification settings.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Offline & Sync
|
// MARK: - Offline & Sync
|
||||||
|
|
||||||
private var offlineSyncDetail: some View {
|
private var offlineSyncDetail: some View {
|
||||||
@@ -778,6 +964,20 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
/// Everything backed by a live `users.update`/`users.notifications*`
|
||||||
|
/// call (Profile's name/avatar, all of Preferences, all of
|
||||||
|
/// Notifications) shows this and disables its controls while offline —
|
||||||
|
/// there's nothing to optimistically apply here the way document edits
|
||||||
|
/// can be, and no offline queue for it (see `TODO.local.md`'s own
|
||||||
|
/// "things that actually require an internet connection" framing,
|
||||||
|
/// already applied to Share/Permissions/Search). Local-only settings
|
||||||
|
/// (Appearance, Offline Mode itself) are deliberately left enabled.
|
||||||
|
private var offlineSettingsHint: some View {
|
||||||
|
Text("You're offline — these settings need a connection to change.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
|
||||||
private func labeledRow(_ label: String, _ value: String) -> some View {
|
private func labeledRow(_ label: String, _ value: String) -> some View {
|
||||||
HStack {
|
HStack {
|
||||||
Text(label)
|
Text(label)
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
|||||||
/// placeholder until its content is specified and built.
|
/// placeholder until its content is specified and built.
|
||||||
var isImplemented: Bool {
|
var isImplemented: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .appearance, .offlineSync, .advanced, .about, .profile, .preferences:
|
case .appearance, .offlineSync, .advanced, .about, .profile, .preferences, .notifications:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ final class SessionStore {
|
|||||||
var userAvatarURL: URL?
|
var userAvatarURL: URL?
|
||||||
var userLanguage: String?
|
var userLanguage: String?
|
||||||
var userPreferences: OutlineUserPreferences?
|
var userPreferences: OutlineUserPreferences?
|
||||||
|
var userNotificationSettings: [String: Bool]?
|
||||||
var teamName: String?
|
var teamName: String?
|
||||||
var teamAvatarURL: URL?
|
var teamAvatarURL: URL?
|
||||||
private(set) var apiClient: OutlineAPIClient?
|
private(set) var apiClient: OutlineAPIClient?
|
||||||
@@ -77,6 +78,7 @@ final class SessionStore {
|
|||||||
userAvatarURL = nil
|
userAvatarURL = nil
|
||||||
userLanguage = nil
|
userLanguage = nil
|
||||||
userPreferences = nil
|
userPreferences = nil
|
||||||
|
userNotificationSettings = nil
|
||||||
teamName = nil
|
teamName = nil
|
||||||
teamAvatarURL = nil
|
teamAvatarURL = nil
|
||||||
apiClient = nil
|
apiClient = nil
|
||||||
@@ -101,6 +103,7 @@ final class SessionStore {
|
|||||||
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
||||||
userLanguage = user.language
|
userLanguage = user.language
|
||||||
userPreferences = user.preferences
|
userPreferences = user.preferences
|
||||||
|
userNotificationSettings = user.notificationSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
private func apply(user: OutlineUser, team: OutlineTeam, serverURL: URL) {
|
private func apply(user: OutlineUser, team: OutlineTeam, serverURL: URL) {
|
||||||
@@ -113,6 +116,7 @@ final class SessionStore {
|
|||||||
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
||||||
userLanguage = user.language
|
userLanguage = user.language
|
||||||
userPreferences = user.preferences
|
userPreferences = user.preferences
|
||||||
|
userNotificationSettings = user.notificationSettings
|
||||||
teamName = team.name
|
teamName = team.name
|
||||||
teamAvatarURL = team.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
teamAvatarURL = team.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user