Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
242 changes: 227 additions & 15 deletions AppSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,19 @@ final class AppSettings {
static let autoApproveRelayAuth = "wisp_settings_auto_approve_relay_auth"
static let zapIconStyle = "wisp_settings_zap_icon_style"
static let videoLoop = "wisp_settings_video_loop"
static let syncSettingsToRelays = "wisp_settings_sync_settings_to_relays"
static func quickZapEnabled(for pubkey: String?) -> String {
pubkey.map { "wisp_settings_quick_zap_enabled_\($0)" } ?? "wisp_settings_quick_zap_enabled"
}
static func quickZapAmountSats(for pubkey: String?) -> String {
pubkey.map { "wisp_settings_quick_zap_amount_sats_\($0)" } ?? "wisp_settings_quick_zap_amount_sats"
}
static func quickZapAmountFiat(for pubkey: String?) -> String {
pubkey.map { "wisp_settings_quick_zap_amount_fiat_\($0)" } ?? "wisp_settings_quick_zap_amount_fiat"
}
static func quickZapMessage(for pubkey: String?) -> String {
pubkey.map { "wisp_settings_quick_zap_message_\($0)" } ?? "wisp_settings_quick_zap_message"
}
}

/// Allowed durations for the post-undo countdown. Picker shows these as
Expand All @@ -51,37 +64,70 @@ final class AppSettings {
private static let defaultAccentARGB: Int = 0xFFFF9800

var largeText: Bool {
didSet { UserDefaults.standard.set(largeText, forKey: Keys.largeText) }
didSet {
UserDefaults.standard.set(largeText, forKey: Keys.largeText)
EmojiRepository.shared.scheduleSettingsSync()
}
}
var themeName: String {
didSet { UserDefaults.standard.set(themeName, forKey: Keys.themeName) }
didSet {
UserDefaults.standard.set(themeName, forKey: Keys.themeName)
EmojiRepository.shared.scheduleSettingsSync()
}
}
var colorScheme: ColorSchemePreference {
didSet { UserDefaults.standard.set(colorScheme.rawValue, forKey: Keys.colorScheme) }
didSet {
UserDefaults.standard.set(colorScheme.rawValue, forKey: Keys.colorScheme)
EmojiRepository.shared.scheduleSettingsSync()
}
}
var accentColorARGB: Int {
didSet { UserDefaults.standard.set(accentColorARGB, forKey: Keys.accentColorARGB) }
didSet {
UserDefaults.standard.set(accentColorARGB, forKey: Keys.accentColorARGB)
EmojiRepository.shared.scheduleSettingsSync()
}
}
var autoLoadMedia: Bool {
didSet { UserDefaults.standard.set(autoLoadMedia, forKey: Keys.autoLoadMedia) }
didSet {
UserDefaults.standard.set(autoLoadMedia, forKey: Keys.autoLoadMedia)
EmojiRepository.shared.scheduleSettingsSync()
}
}
var videoAutoplay: Bool {
didSet { UserDefaults.standard.set(videoAutoplay, forKey: Keys.videoAutoplay) }
didSet {
UserDefaults.standard.set(videoAutoplay, forKey: Keys.videoAutoplay)
EmojiRepository.shared.scheduleSettingsSync()
}
}
var animateAvatars: Bool {
didSet { UserDefaults.standard.set(animateAvatars, forKey: Keys.animateAvatars) }
didSet {
UserDefaults.standard.set(animateAvatars, forKey: Keys.animateAvatars)
EmojiRepository.shared.scheduleSettingsSync()
}
}
var mediaLayoutStyle: MediaLayoutStyle {
didSet { UserDefaults.standard.set(mediaLayoutStyle.rawValue, forKey: Keys.mediaLayoutStyle) }
didSet {
UserDefaults.standard.set(mediaLayoutStyle.rawValue, forKey: Keys.mediaLayoutStyle)
EmojiRepository.shared.scheduleSettingsSync()
}
}
var clientTagEnabled: Bool {
didSet { UserDefaults.standard.set(clientTagEnabled, forKey: Keys.clientTagEnabled) }
didSet {
UserDefaults.standard.set(clientTagEnabled, forKey: Keys.clientTagEnabled)
EmojiRepository.shared.scheduleSettingsSync()
}
}
var fiatModeEnabled: Bool {
didSet { UserDefaults.standard.set(fiatModeEnabled, forKey: Keys.fiatModeEnabled) }
didSet {
UserDefaults.standard.set(fiatModeEnabled, forKey: Keys.fiatModeEnabled)
EmojiRepository.shared.scheduleSettingsSync()
}
}
var fiatCurrency: String {
didSet { UserDefaults.standard.set(fiatCurrency, forKey: Keys.fiatCurrency) }
didSet {
UserDefaults.standard.set(fiatCurrency, forKey: Keys.fiatCurrency)
EmojiRepository.shared.scheduleSettingsSync()
}
}
var notificationSoundsEnabled: Bool {
didSet { UserDefaults.standard.set(notificationSoundsEnabled, forKey: Keys.notificationSoundsEnabled) }
Expand All @@ -90,28 +136,86 @@ final class AppSettings {
/// `postUndoTimerForReplies`) waits `postUndoTimerSeconds` before sending,
/// giving the user a chance to cancel.
var postUndoTimerEnabled: Bool {
didSet { UserDefaults.standard.set(postUndoTimerEnabled, forKey: Keys.postUndoTimerEnabled) }
didSet {
UserDefaults.standard.set(postUndoTimerEnabled, forKey: Keys.postUndoTimerEnabled)
EmojiRepository.shared.scheduleSettingsSync()
}
}
var postUndoTimerSeconds: Int {
didSet { UserDefaults.standard.set(postUndoTimerSeconds, forKey: Keys.postUndoTimerSeconds) }
didSet {
UserDefaults.standard.set(postUndoTimerSeconds, forKey: Keys.postUndoTimerSeconds)
EmojiRepository.shared.scheduleSettingsSync()
}
}
/// When false, replies skip the undo countdown and publish immediately —
/// the default. Top-level posts still respect `postUndoTimerEnabled`.
var postUndoTimerForReplies: Bool {
didSet { UserDefaults.standard.set(postUndoTimerForReplies, forKey: Keys.postUndoTimerForReplies) }
didSet {
UserDefaults.standard.set(postUndoTimerForReplies, forKey: Keys.postUndoTimerForReplies)
EmojiRepository.shared.scheduleSettingsSync()
}
}
/// When true (default), AUTH challenges from relays are signed and sent automatically
/// without prompting. When false, each relay must be individually approved in relay settings.
var autoApproveRelayAuth: Bool {
didSet { UserDefaults.standard.set(autoApproveRelayAuth, forKey: Keys.autoApproveRelayAuth) }
}
var zapIconStyle: ZapIconStyle {
didSet { UserDefaults.standard.set(zapIconStyle.rawValue, forKey: Keys.zapIconStyle) }
didSet {
UserDefaults.standard.set(zapIconStyle.rawValue, forKey: Keys.zapIconStyle)
EmojiRepository.shared.scheduleSettingsSync()
}
}
/// When true (default), AppSettings + EmojiRepository publish a NIP-78
/// kind-30078 backup of the user's preferences and quick-reactions so the
/// same setup follows the account across devices/clients.
var syncSettingsToRelays: Bool {
didSet { UserDefaults.standard.set(syncSettingsToRelays, forKey: Keys.syncSettingsToRelays) }
}
// TODO: Persist to NIP78 (kind 30078) when that feature is available.
var videoLoop: Bool {
didSet { UserDefaults.standard.set(videoLoop, forKey: Keys.videoLoop) }
}
/// When true, a single tap of the zap button on a post sends the configured
/// amount immediately. Long-press still opens the zap composer. Surfaces in
/// settings as "Instant zaps" while in bitcoin mode and "Instant payments"
/// while in fiat mode. Disabled by default — the previous behaviour
/// (tap → composer) is preserved unless the user opts in.
var quickZapEnabled: Bool {
didSet {
let pk = NostrKey.load()?.pubkey
UserDefaults.standard.set(quickZapEnabled, forKey: Keys.quickZapEnabled(for: pk))
EmojiRepository.shared.scheduleSettingsSync()
}
}
/// Instant-zap amount in sats, used when `fiatModeEnabled` is false.
var quickZapAmountSats: Int64 {
didSet {
let pk = NostrKey.load()?.pubkey
UserDefaults.standard.set(quickZapAmountSats, forKey: Keys.quickZapAmountSats(for: pk))
EmojiRepository.shared.scheduleSettingsSync()
}
}
/// Instant-payment amount in `fiatCurrency` major units (e.g. 1.00 USD),
/// used when `fiatModeEnabled` is true. Converted to sats at fire time via
/// `ExchangeRateCache.fiatToSats`.
var quickZapAmountFiat: Double {
didSet {
let pk = NostrKey.load()?.pubkey
UserDefaults.standard.set(quickZapAmountFiat, forKey: Keys.quickZapAmountFiat(for: pk))
EmojiRepository.shared.scheduleSettingsSync()
}
}
/// Optional default message included on an instant zap / payment. Empty
/// string means "no message" — the zap fires with `content: ""` exactly
/// as the composer's blank state would produce. Persisted + synced.
var quickZapMessage: String {
didSet {
let pk = NostrKey.load()?.pubkey
UserDefaults.standard.set(quickZapMessage, forKey: Keys.quickZapMessage(for: pk))
EmojiRepository.shared.scheduleSettingsSync()
}
}

private init() {
let defaults = UserDefaults.standard
Expand All @@ -137,6 +241,114 @@ final class AppSettings {
let zapRaw = defaults.string(forKey: Keys.zapIconStyle) ?? ZapIconStyle.bitcoin.rawValue
self.zapIconStyle = ZapIconStyle(rawValue: zapRaw) ?? .bitcoin
self.videoLoop = defaults.object(forKey: Keys.videoLoop) as? Bool ?? true
self.syncSettingsToRelays = defaults.object(forKey: Keys.syncSettingsToRelays) as? Bool ?? true
let qzPubkey = NostrKey.load()?.pubkey
self.quickZapEnabled = defaults.object(forKey: Keys.quickZapEnabled(for: qzPubkey)) as? Bool ?? false
let storedQuickInt = defaults.integer(forKey: Keys.quickZapAmountSats(for: qzPubkey))
self.quickZapAmountSats = storedQuickInt > 0 ? Int64(storedQuickInt) : 21
let storedQuickFiat = defaults.double(forKey: Keys.quickZapAmountFiat(for: qzPubkey))
self.quickZapAmountFiat = storedQuickFiat > 0 ? storedQuickFiat : 0.10
self.quickZapMessage = defaults.string(forKey: Keys.quickZapMessage(for: qzPubkey)) ?? ""
}

/// Load per-account instant-zap settings from UserDefaults. Falls back to
/// defaults (21 sats / 0.10 fiat / disabled / no message) when no value
/// has been stored for this pubkey yet. Call on every account switch so
/// each account's preferences are isolated.
func loadQuickZapSettings(for pubkey: String) {
let defaults = UserDefaults.standard
quickZapEnabled = defaults.object(forKey: Keys.quickZapEnabled(for: pubkey)) as? Bool ?? false
let storedSats = defaults.integer(forKey: Keys.quickZapAmountSats(for: pubkey))
quickZapAmountSats = storedSats > 0 ? Int64(storedSats) : 21
let storedFiat = defaults.double(forKey: Keys.quickZapAmountFiat(for: pubkey))
quickZapAmountFiat = storedFiat > 0 ? storedFiat : 0.10
quickZapMessage = defaults.string(forKey: Keys.quickZapMessage(for: pubkey)) ?? ""
}

/// Apply settings restored from a NIP-78 backup. Only non-default keys
/// present in the payload are overwritten; the rest stay as configured
/// locally. Called from `EmojiRepository.refresh` after a successful
/// remote fetch — see `Nip78Backup.AppSettingsPayload`.
func applyRestored(payload: Nip78Backup.AppSettingsPayload) {
// Currency / zap
if let q = payload.quickZapEnabled { quickZapEnabled = q }
if let a = payload.quickZapAmountSats, a > 0 { quickZapAmountSats = a }
if let f = payload.quickZapAmountFiat, f > 0 { quickZapAmountFiat = f }
if let m = payload.quickZapMessage { quickZapMessage = m }
if let s = payload.zapIconStyle, let style = ZapIconStyle(rawValue: s) {
zapIconStyle = style
}
if let m = payload.fiatModeEnabled { fiatModeEnabled = m }
if let c = payload.fiatCurrency, !c.isEmpty { fiatCurrency = c }
if let raw = payload.zapPresetsCSV, !raw.isEmpty {
// Per-pubkey storage — fall back to the legacy global key when
// there's no active account so the value is at least available
// for the next session.
if let pubkey = NostrKey.load()?.pubkey {
UserDefaults.standard.set(raw, forKey: "zapPresetAmounts_\(pubkey)")
} else {
UserDefaults.standard.set(raw, forKey: "zapPresetAmounts")
}
}
// Appearance
if let b = payload.largeText { largeText = b }
if let n = payload.themeName, !n.isEmpty { themeName = n }
if let s = payload.colorScheme, let pref = ColorSchemePreference(rawValue: s) {
colorScheme = pref
}
if let a = payload.accentColorARGB { accentColorARGB = a }
// Media
if let b = payload.autoLoadMedia { autoLoadMedia = b }
if let b = payload.videoAutoplay { videoAutoplay = b }
if let b = payload.animateAvatars { animateAvatars = b }
if let s = payload.mediaLayoutStyle, let style = MediaLayoutStyle(rawValue: s) {
mediaLayoutStyle = style
}
// Posting
if let b = payload.clientTagEnabled { clientTagEnabled = b }
if let b = payload.postUndoTimerEnabled { postUndoTimerEnabled = b }
if let n = payload.postUndoTimerSeconds, Self.postUndoTimerOptions.contains(n) {
postUndoTimerSeconds = n
}
if let b = payload.postUndoTimerForReplies { postUndoTimerForReplies = b }
}

/// Build the payload that gets NIP-44 encrypted and published as kind-30078.
/// Mirrors `applyRestored` — every field the backup carries.
func snapshotForBackup() -> Nip78Backup.AppSettingsPayload {
// Pull the active account's preset row first; fall back to the
// legacy global key for users who haven't yet opened the new
// ZapSheet (which would have migrated the value over).
let defaults = UserDefaults.standard
let presetsRaw: String?
if let pubkey = NostrKey.load()?.pubkey,
let perUser = defaults.string(forKey: "zapPresetAmounts_\(pubkey)") {
presetsRaw = perUser
} else {
presetsRaw = defaults.string(forKey: "zapPresetAmounts")
}
return Nip78Backup.AppSettingsPayload(
quickZapEnabled: quickZapEnabled,
quickZapAmountSats: quickZapAmountSats,
quickZapAmountFiat: quickZapAmountFiat,
quickZapMessage: quickZapMessage,
zapIconStyle: zapIconStyle.rawValue,
fiatModeEnabled: fiatModeEnabled,
fiatCurrency: fiatCurrency,
zapPresetsCSV: presetsRaw,
largeText: largeText,
themeName: themeName,
colorScheme: colorScheme.rawValue,
accentColorARGB: accentColorARGB,
autoLoadMedia: autoLoadMedia,
videoAutoplay: videoAutoplay,
animateAvatars: animateAvatars,
mediaLayoutStyle: mediaLayoutStyle.rawValue,
clientTagEnabled: clientTagEnabled,
postUndoTimerEnabled: postUndoTimerEnabled,
postUndoTimerSeconds: postUndoTimerSeconds,
postUndoTimerForReplies: postUndoTimerForReplies
)
}

/// SF Symbol name for the zap icon. Only valid when `fiatModeEnabled` is false.
Expand Down
Loading