diff --git a/submodules/SettingsUI/Sources/GhostModeController.swift b/submodules/SettingsUI/Sources/GhostModeController.swift index a52f7357..5bad7912 100644 --- a/submodules/SettingsUI/Sources/GhostModeController.swift +++ b/submodules/SettingsUI/Sources/GhostModeController.swift @@ -300,6 +300,28 @@ public func ghostModeController(context: AccountContext) -> ViewController { } ) + // Refresh UI when Always Online is enabled externally and auto-disables Ghost Mode — + // the isEnabled flip happens in GhostModeManager from MiscSettingsManager context, + // so we need to pull fresh values from the manager. + let miscSettingsChangedSignal: Signal = Signal { subscriber in + let observer = NotificationCenter.default.addObserver( + forName: MiscSettingsManager.settingsChangedNotification, + object: nil, + queue: .main + ) { _ in + updateState { state in + state.isEnabled = GhostModeManager.shared.isEnabled + state.hideReadReceipts = GhostModeManager.shared.hideReadReceipts + state.hideStoryViews = GhostModeManager.shared.hideStoryViews + state.hideOnlineStatus = GhostModeManager.shared.hideOnlineStatus + state.hideTypingIndicator = GhostModeManager.shared.hideTypingIndicator + state.forceOffline = GhostModeManager.shared.forceOffline + } + } + return ActionDisposable { NotificationCenter.default.removeObserver(observer) } + } + let _ = miscSettingsChangedSignal.start() + let signal = combineLatest( context.sharedContext.presentationData, statePromise.get() diff --git a/submodules/SettingsUI/Sources/MiscController.swift b/submodules/SettingsUI/Sources/MiscController.swift index e541f666..eb8f4997 100644 --- a/submodules/SettingsUI/Sources/MiscController.swift +++ b/submodules/SettingsUI/Sources/MiscController.swift @@ -291,12 +291,34 @@ public func miscController(context: AccountContext) -> ViewController { toggleAlwaysOnline: { let newValue = !MiscSettingsManager.shared.alwaysOnline MiscSettingsManager.shared.alwaysOnline = newValue + // State will be refreshed via notification if Ghost Mode got auto-disabled updateState { state in state.alwaysOnline = newValue } } ) + // Refresh UI when Ghost Mode is auto-disabled by mutual exclusion — + // the toggle flip happens externally, so we must pull fresh values from the managers. + let ghostModeChangedSignal: Signal = Signal { subscriber in + let observer = NotificationCenter.default.addObserver( + forName: GhostModeManager.settingsChangedNotification, + object: nil, + queue: .main + ) { _ in + updateState { state in + state.isEnabled = MiscSettingsManager.shared.isEnabled + state.bypassCopyProtection = MiscSettingsManager.shared.bypassCopyProtection + state.disableViewOnceAutoDelete = MiscSettingsManager.shared.disableViewOnceAutoDelete + state.bypassScreenshotProtection = MiscSettingsManager.shared.bypassScreenshotProtection + state.blockAds = MiscSettingsManager.shared.blockAds + state.alwaysOnline = MiscSettingsManager.shared.alwaysOnline + } + } + return ActionDisposable { NotificationCenter.default.removeObserver(observer) } + } + let _ = ghostModeChangedSignal.start() + let signal = combineLatest( context.sharedContext.presentationData, statePromise.get() diff --git a/submodules/TelegramCore/Sources/GhostMode/GhostModeManager.swift b/submodules/TelegramCore/Sources/GhostMode/GhostModeManager.swift index d3ea16d7..ba06e6d5 100644 --- a/submodules/TelegramCore/Sources/GhostMode/GhostModeManager.swift +++ b/submodules/TelegramCore/Sources/GhostMode/GhostModeManager.swift @@ -11,25 +11,35 @@ public final class GhostModeManager { // MARK: - UserDefaults Keys private enum Keys { - static let isEnabled = "GhostMode.isEnabled" - static let hideReadReceipts = "GhostMode.hideReadReceipts" - static let hideStoryViews = "GhostMode.hideStoryViews" - static let hideOnlineStatus = "GhostMode.hideOnlineStatus" + static let isEnabled = "GhostMode.isEnabled" + static let hideReadReceipts = "GhostMode.hideReadReceipts" + static let hideStoryViews = "GhostMode.hideStoryViews" + static let hideOnlineStatus = "GhostMode.hideOnlineStatus" static let hideTypingIndicator = "GhostMode.hideTypingIndicator" - static let forceOffline = "GhostMode.forceOffline" + static let forceOffline = "GhostMode.forceOffline" } // MARK: - Settings Storage private let defaults = UserDefaults.standard + // Prevents recursive mutual-exclusion calls + private var isApplyingMutualExclusion = false + // MARK: - Properties - /// Master toggle for Ghost Mode + /// Master toggle for Ghost Mode. + /// Enabling Ghost Mode automatically disables Always Online in MiscSettingsManager. public var isEnabled: Bool { get { defaults.bool(forKey: Keys.isEnabled) } - set { + set { defaults.set(newValue, forKey: Keys.isEnabled) + if newValue && !isApplyingMutualExclusion { + // Ghost Mode ON → disable Always Online + isApplyingMutualExclusion = true + MiscSettingsManager.shared.disableAlwaysOnlineForMutualExclusion() + isApplyingMutualExclusion = false + } notifySettingsChanged() } } @@ -37,7 +47,7 @@ public final class GhostModeManager { /// Don't send read receipts (blue checkmarks) public var hideReadReceipts: Bool { get { defaults.bool(forKey: Keys.hideReadReceipts) } - set { + set { defaults.set(newValue, forKey: Keys.hideReadReceipts) notifySettingsChanged() } @@ -46,7 +56,7 @@ public final class GhostModeManager { /// Don't send story view notifications public var hideStoryViews: Bool { get { defaults.bool(forKey: Keys.hideStoryViews) } - set { + set { defaults.set(newValue, forKey: Keys.hideStoryViews) notifySettingsChanged() } @@ -55,7 +65,7 @@ public final class GhostModeManager { /// Don't send online status public var hideOnlineStatus: Bool { get { defaults.bool(forKey: Keys.hideOnlineStatus) } - set { + set { defaults.set(newValue, forKey: Keys.hideOnlineStatus) notifySettingsChanged() } @@ -64,7 +74,7 @@ public final class GhostModeManager { /// Don't send typing indicator public var hideTypingIndicator: Bool { get { defaults.bool(forKey: Keys.hideTypingIndicator) } - set { + set { defaults.set(newValue, forKey: Keys.hideTypingIndicator) notifySettingsChanged() } @@ -73,7 +83,7 @@ public final class GhostModeManager { /// Always appear as offline public var forceOffline: Bool { get { defaults.bool(forKey: Keys.forceOffline) } - set { + set { defaults.set(newValue, forKey: Keys.forceOffline) notifySettingsChanged() } @@ -81,72 +91,82 @@ public final class GhostModeManager { // MARK: - Computed Properties - /// Check if read receipts should be hidden (master + individual toggle) + /// Returns true only when Ghost Mode is enabled AND the individual toggle is on. + /// NOTE: Always Online takes precedence — if Always Online is active, online status is never hidden. public var shouldHideReadReceipts: Bool { return isEnabled && hideReadReceipts } - /// Check if story views should be hidden public var shouldHideStoryViews: Bool { return isEnabled && hideStoryViews } - /// Check if online status should be hidden + /// Online status is hidden only when Ghost Mode is on AND Always Online is NOT active. public var shouldHideOnlineStatus: Bool { - return isEnabled && hideOnlineStatus + guard isEnabled && hideOnlineStatus else { return false } + return !MiscSettingsManager.shared.shouldAlwaysBeOnline } - /// Check if typing indicator should be hidden public var shouldHideTypingIndicator: Bool { return isEnabled && hideTypingIndicator } - /// Check if should force offline + /// Force offline only when Ghost Mode is on AND Always Online is NOT active. public var shouldForceOffline: Bool { - return isEnabled && forceOffline + guard isEnabled && forceOffline else { return false } + return !MiscSettingsManager.shared.shouldAlwaysBeOnline } /// Count of active features (e.g., "5/5") public var activeFeatureCount: Int { var count = 0 - if hideReadReceipts { count += 1 } - if hideStoryViews { count += 1 } - if hideOnlineStatus { count += 1 } + if hideReadReceipts { count += 1 } + if hideStoryViews { count += 1 } + if hideOnlineStatus { count += 1 } if hideTypingIndicator { count += 1 } - if forceOffline { count += 1 } + if forceOffline { count += 1 } return count } /// Total number of features public static let totalFeatureCount = 5 + // MARK: - Internal mutual exclusion (called by MiscSettingsManager) + + /// Called by MiscSettingsManager when Always Online is turned on. + /// Disables Ghost Mode without triggering mutual exclusion back. + public func disableForMutualExclusion() { + isApplyingMutualExclusion = true + defaults.set(false, forKey: Keys.isEnabled) + notifySettingsChanged() + isApplyingMutualExclusion = false + } + // MARK: - Initialization private init() { - // Set default values if not set if !defaults.bool(forKey: "GhostMode.initialized") { defaults.set(true, forKey: "GhostMode.initialized") - // Default: all features enabled when ghost mode is on defaults.set(true, forKey: Keys.hideReadReceipts) defaults.set(true, forKey: Keys.hideStoryViews) defaults.set(true, forKey: Keys.hideOnlineStatus) defaults.set(true, forKey: Keys.hideTypingIndicator) defaults.set(true, forKey: Keys.forceOffline) - // Ghost mode itself is off by default defaults.set(false, forKey: Keys.isEnabled) } } - // MARK: - Enable All + // MARK: - Enable/Disable All - /// Enable all ghost mode features + /// Enable all ghost mode features. + /// Also disables Always Online (mutual exclusion). public func enableAll() { - hideReadReceipts = true - hideStoryViews = true - hideOnlineStatus = true + hideReadReceipts = true + hideStoryViews = true + hideOnlineStatus = true hideTypingIndicator = true - forceOffline = true - isEnabled = true + forceOffline = true + isEnabled = true // setter handles mutual exclusion } /// Disable all ghost mode features diff --git a/submodules/TelegramCore/Sources/MiscSettings/MiscSettingsManager.swift b/submodules/TelegramCore/Sources/MiscSettings/MiscSettingsManager.swift index 6a4e95a4..819c9e42 100644 --- a/submodules/TelegramCore/Sources/MiscSettings/MiscSettingsManager.swift +++ b/submodules/TelegramCore/Sources/MiscSettings/MiscSettingsManager.swift @@ -1,21 +1,24 @@ import Foundation /// MiscSettingsManager - Central manager for Misc privacy settings -/// Handles: Forward bypass, View-Once persistence, Screenshot bypass +/// Handles: Forward bypass, View-Once persistence, Screenshot bypass, Block Ads, Always Online public final class MiscSettingsManager { public static let shared = MiscSettingsManager() private enum Keys { - static let isEnabled = "MiscSettings.isEnabled" - static let bypassCopyProtection = "MiscSettings.bypassCopyProtection" - static let disableViewOnceAutoDelete = "MiscSettings.disableViewOnceAutoDelete" + static let isEnabled = "MiscSettings.isEnabled" + static let bypassCopyProtection = "MiscSettings.bypassCopyProtection" + static let disableViewOnceAutoDelete = "MiscSettings.disableViewOnceAutoDelete" static let bypassScreenshotProtection = "MiscSettings.bypassScreenshotProtection" - static let blockAds = "MiscSettings.blockAds" - static let alwaysOnline = "MiscSettings.alwaysOnline" + static let blockAds = "MiscSettings.blockAds" + static let alwaysOnline = "MiscSettings.alwaysOnline" } private let defaults = UserDefaults.standard + // Prevents recursive mutual-exclusion calls + private var isApplyingMutualExclusion = false + // MARK: - Main Toggle public var isEnabled: Bool { @@ -64,11 +67,18 @@ public final class MiscSettingsManager { } } - /// Keep online status always active + /// Always appear as online. + /// Enabling this automatically disables Ghost Mode (mutual exclusion). public var alwaysOnline: Bool { get { defaults.bool(forKey: Keys.alwaysOnline) } set { defaults.set(newValue, forKey: Keys.alwaysOnline) + if newValue && !isApplyingMutualExclusion { + // Always Online ON → disable Ghost Mode + isApplyingMutualExclusion = true + GhostModeManager.shared.disableForMutualExclusion() + isApplyingMutualExclusion = false + } notifySettingsChanged() } } @@ -99,28 +109,39 @@ public final class MiscSettingsManager { public var activeFeatureCount: Int { var count = 0 - if bypassCopyProtection { count += 1 } + if bypassCopyProtection { count += 1 } if disableViewOnceAutoDelete { count += 1 } if bypassScreenshotProtection { count += 1 } - if blockAds { count += 1 } - if alwaysOnline { count += 1 } + if blockAds { count += 1 } + if alwaysOnline { count += 1 } return count } public func enableAll() { - bypassCopyProtection = true - disableViewOnceAutoDelete = true + bypassCopyProtection = true + disableViewOnceAutoDelete = true bypassScreenshotProtection = true - blockAds = true - alwaysOnline = true + blockAds = true + alwaysOnline = true // setter handles mutual exclusion } public func disableAll() { - bypassCopyProtection = false - disableViewOnceAutoDelete = false + bypassCopyProtection = false + disableViewOnceAutoDelete = false bypassScreenshotProtection = false - blockAds = false - alwaysOnline = false + blockAds = false + alwaysOnline = false + } + + // MARK: - Internal mutual exclusion (called by GhostModeManager) + + /// Called by GhostModeManager when Ghost Mode is turned on. + /// Disables Always Online without triggering mutual exclusion back. + public func disableAlwaysOnlineForMutualExclusion() { + isApplyingMutualExclusion = true + defaults.set(false, forKey: Keys.alwaysOnline) + notifySettingsChanged() + isApplyingMutualExclusion = false } // MARK: - Notification @@ -134,7 +155,6 @@ public final class MiscSettingsManager { // MARK: - Init private init() { - // Set default values if first launch if !defaults.bool(forKey: "MiscSettings.initialized") { defaults.set(true, forKey: "MiscSettings.initialized") defaults.set(false, forKey: Keys.isEnabled) @@ -142,6 +162,7 @@ public final class MiscSettingsManager { defaults.set(true, forKey: Keys.disableViewOnceAutoDelete) defaults.set(true, forKey: Keys.bypassScreenshotProtection) defaults.set(true, forKey: Keys.blockAds) + defaults.set(false, forKey: Keys.alwaysOnline) } } } diff --git a/submodules/TelegramCore/Sources/State/ManagedAccountPresence.swift b/submodules/TelegramCore/Sources/State/ManagedAccountPresence.swift index 620f1c31..a5c70ae8 100644 --- a/submodules/TelegramCore/Sources/State/ManagedAccountPresence.swift +++ b/submodules/TelegramCore/Sources/State/ManagedAccountPresence.swift @@ -16,8 +16,13 @@ private final class AccountPresenceManagerImpl { private let currentRequestDisposable = MetaDisposable() private var onlineTimer: SignalKitTimer? + // Tracks the last app-level online value so we can refresh independently private var wasOnline: Bool = false + // Observers for settings change notifications + private var ghostModeObserver: NSObjectProtocol? + private var miscSettingsObserver: NSObjectProtocol? + init(queue: Queue, shouldKeepOnlinePresence: Signal, network: Network) { self.queue = queue self.network = network @@ -25,14 +30,37 @@ private final class AccountPresenceManagerImpl { self.shouldKeepOnlinePresenceDisposable = (shouldKeepOnlinePresence |> distinctUntilChanged |> deliverOn(self.queue)).start(next: { [weak self] value in - guard let `self` = self else { - return - } - if self.wasOnline != value { - self.wasOnline = value - self.updatePresence(value) - } + guard let self = self else { return } + self.wasOnline = value + self.refreshPresence() }) + + // React to Ghost Mode or Always Online settings changes without waiting + // for the next app focus event. + let notificationQueue = DispatchQueue.main + self.ghostModeObserver = NotificationCenter.default.addObserver( + forName: GhostModeManager.settingsChangedNotification, + object: nil, + queue: nil + ) { [weak self] _ in + notificationQueue.async { + self?.queue.async { + self?.refreshPresence() + } + } + } + + self.miscSettingsObserver = NotificationCenter.default.addObserver( + forName: MiscSettingsManager.settingsChangedNotification, + object: nil, + queue: nil + ) { [weak self] _ in + notificationQueue.async { + self?.queue.async { + self?.refreshPresence() + } + } + } } deinit { @@ -40,26 +68,43 @@ private final class AccountPresenceManagerImpl { self.shouldKeepOnlinePresenceDisposable?.dispose() self.currentRequestDisposable.dispose() self.onlineTimer?.invalidate() + if let observer = self.ghostModeObserver { + NotificationCenter.default.removeObserver(observer) + } + if let observer = self.miscSettingsObserver { + NotificationCenter.default.removeObserver(observer) + } } - private func updatePresence(_ isOnline: Bool) { - // GHOST MODE: Completely block status updates to freeze "last seen" time - if GhostModeManager.shared.shouldHideOnlineStatus { + /// Compute the effective online state and push it to Telegram. + /// Priority chain (highest → lowest): + /// 1. Always Online enabled → force online = true + /// 2. Ghost Mode hide online status → skip update entirely (freeze last-seen) + /// 3. Default app behaviour (wasOnline) + private func refreshPresence() { + let alwaysOnline = MiscSettingsManager.shared.shouldAlwaysBeOnline + let ghostHideOnline = GhostModeManager.shared.shouldHideOnlineStatus + + if alwaysOnline { + // Always Online wins — push online regardless of Ghost Mode + sendPresenceUpdate(online: true) + } else if ghostHideOnline { + // Ghost Mode active, no Always Online — freeze presence (don't send anything) self.onlineTimer?.invalidate() self.onlineTimer = nil - return + } else { + // Normal mode — follow the app-level state + sendPresenceUpdate(online: wasOnline) } - - // ALWAYS ONLINE: Force online status when enabled - let effectiveOnline = MiscSettingsManager.shared.shouldAlwaysBeOnline ? true : isOnline - + } + + private func sendPresenceUpdate(online: Bool) { let request: Signal - if effectiveOnline { + if online { + // Keep pinging every 30 s so the server keeps us online let timer = SignalKitTimer(timeout: 30.0, repeat: false, completion: { [weak self] in - guard let strongSelf = self else { - return - } - strongSelf.updatePresence(true) + guard let self = self else { return } + self.refreshPresence() }, queue: self.queue) self.onlineTimer = timer timer.start() @@ -69,16 +114,14 @@ private final class AccountPresenceManagerImpl { self.onlineTimer = nil request = self.network.request(Api.functions.account.updateStatus(offline: .boolTrue)) } + self.isPerformingUpdate.set(true) self.currentRequestDisposable.set((request |> `catch` { _ -> Signal in return .single(.boolFalse) } |> deliverOn(self.queue)).start(completed: { [weak self] in - guard let strongSelf = self else { - return - } - strongSelf.isPerformingUpdate.set(false) + self?.isPerformingUpdate.set(false) })) } }