Уведомления в фоне, оптимизация FPS чата, release notes, read receipts паритет с Android
This commit is contained in:
@@ -7,20 +7,53 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
4C9BDB443750F7003CFB705C /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 272B862BE4D99E7DD751CC3E /* Foundation.framework */; };
|
||||
853F29992F4B63D20092AD05 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 853F29982F4B63D20092AD05 /* Lottie */; };
|
||||
853F29A02F4B63D20092AD05 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 853F29A12F4B63D20092AD05 /* P256K */; };
|
||||
D0BD72A9646880B604F1AC3C /* RosettaNotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A182B0EDE5C68E7C6F1FB6D1 /* RosettaNotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
DA91A59FDC04C2EBE77550F4 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F43A41D5496A62870E307FC /* NotificationService.swift */; };
|
||||
F1A000012F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000022F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport */; };
|
||||
F1A000032F6F00010092AD05 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000042F6F00010092AD05 /* FirebaseMessaging */; };
|
||||
F1A000062F6F00010092AD05 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000072F6F00010092AD05 /* FirebaseCrashlytics */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
6AADF4618CA423BB75F12BF1 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 853F295A2F4B50410092AD05 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = E47730762E9823BA2D02A197;
|
||||
remoteInfo = RosettaNotificationService;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
249D2C5CD23DB96B22202215 /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
D0BD72A9646880B604F1AC3C /* RosettaNotificationService.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
0F43A41D5496A62870E307FC /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
|
||||
272B862BE4D99E7DD751CC3E /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
|
||||
853F29622F4B50410092AD05 /* Rosetta.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Rosetta.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
93685A4F330DCD1B63EF121F /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
A182B0EDE5C68E7C6F1FB6D1 /* RosettaNotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = RosettaNotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
853F29642F4B50410092AD05 /* Rosetta */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = Rosetta;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -39,14 +72,32 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B2C595701A2879A2FD49DDEF /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
4C9BDB443750F7003CFB705C /* Foundation.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
32A246700D4A2618B3F81039 /* iOS */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
272B862BE4D99E7DD751CC3E /* Foundation.framework */,
|
||||
);
|
||||
name = iOS;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
853F29592F4B50410092AD05 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
853F29642F4B50410092AD05 /* Rosetta */,
|
||||
853F29632F4B50410092AD05 /* Products */,
|
||||
95676C1A4D239B1FF9E73782 /* Frameworks */,
|
||||
BA35C0165A0371B32DD8B4C0 /* RosettaNotificationService */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -54,10 +105,29 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
853F29622F4B50410092AD05 /* Rosetta.app */,
|
||||
A182B0EDE5C68E7C6F1FB6D1 /* RosettaNotificationService.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
95676C1A4D239B1FF9E73782 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
32A246700D4A2618B3F81039 /* iOS */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
BA35C0165A0371B32DD8B4C0 /* RosettaNotificationService */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0F43A41D5496A62870E307FC /* NotificationService.swift */,
|
||||
93685A4F330DCD1B63EF121F /* Info.plist */,
|
||||
);
|
||||
name = RosettaNotificationService;
|
||||
path = RosettaNotificationService;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -68,10 +138,12 @@
|
||||
853F295E2F4B50410092AD05 /* Sources */,
|
||||
853F295F2F4B50410092AD05 /* Frameworks */,
|
||||
853F29602F4B50410092AD05 /* Resources */,
|
||||
249D2C5CD23DB96B22202215 /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
3323872B02212359E2291EE8 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
853F29642F4B50410092AD05 /* Rosetta */,
|
||||
@@ -88,6 +160,23 @@
|
||||
productReference = 853F29622F4B50410092AD05 /* Rosetta.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
E47730762E9823BA2D02A197 /* RosettaNotificationService */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = B5D2E60ADEB8AE2E8F7615C6 /* Build configuration list for PBXNativeTarget "RosettaNotificationService" */;
|
||||
buildPhases = (
|
||||
A624149985830F8CA8C2E52D /* Sources */,
|
||||
B2C595701A2879A2FD49DDEF /* Frameworks */,
|
||||
F9F9B9BDE87DB35631992F35 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = RosettaNotificationService;
|
||||
productName = RosettaNotificationService;
|
||||
productReference = A182B0EDE5C68E7C6F1FB6D1 /* RosettaNotificationService.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@@ -101,6 +190,9 @@
|
||||
853F29612F4B50410092AD05 = {
|
||||
CreatedOnToolsVersion = 26.2;
|
||||
};
|
||||
E47730762E9823BA2D02A197 = {
|
||||
CreatedOnToolsVersion = 26.2;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 853F295D2F4B50410092AD05 /* Build configuration list for PBXProject "Rosetta" */;
|
||||
@@ -123,6 +215,7 @@
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
853F29612F4B50410092AD05 /* Rosetta */,
|
||||
E47730762E9823BA2D02A197 /* RosettaNotificationService */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -135,6 +228,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
F9F9B9BDE87DB35631992F35 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -145,9 +245,54 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A624149985830F8CA8C2E52D /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
DA91A59FDC04C2EBE77550F4 /* NotificationService.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
3323872B02212359E2291EE8 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
name = RosettaNotificationService;
|
||||
target = E47730762E9823BA2D02A197 /* RosettaNotificationService */;
|
||||
targetProxy = 6AADF4618CA423BB75F12BF1 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
0140D6320A9CF4B5E933E0B1 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO;
|
||||
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = RosettaNotificationService/RosettaNotificationService.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
DEVELOPMENT_TEAM = QN8Z263QGX;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = RosettaNotificationService/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.1.8;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev.NotificationService;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
853F296B2F4B50420092AD05 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -275,7 +420,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
DEVELOPMENT_TEAM = QN8Z263QGX;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -291,7 +436,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.1.6;
|
||||
MARKETING_VERSION = 1.1.8;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -314,7 +459,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
DEVELOPMENT_TEAM = QN8Z263QGX;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -330,7 +475,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.1.6;
|
||||
MARKETING_VERSION = 1.1.8;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -345,6 +490,35 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
93E51266ED50ED634DDBB900 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO;
|
||||
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = RosettaNotificationService/RosettaNotificationService.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
DEVELOPMENT_TEAM = QN8Z263QGX;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = RosettaNotificationService/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.1.8;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev.NotificationService;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -366,6 +540,15 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
B5D2E60ADEB8AE2E8F7615C6 /* Build configuration list for PBXNativeTarget "RosettaNotificationService" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
93E51266ED50ED634DDBB900 /* Release */,
|
||||
0140D6320A9CF4B5E933E0B1 /* Debug */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
buildConfiguration = "Release"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
<key>RosettaNotificationService.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
<dict>
|
||||
|
||||
@@ -10,8 +10,15 @@ final class DialogRepository {
|
||||
static let shared = DialogRepository()
|
||||
|
||||
private(set) var dialogs: [String: Dialog] = [:] {
|
||||
didSet { _sortedDialogsCache = nil }
|
||||
didSet {
|
||||
_sortedDialogsCache = nil
|
||||
dialogsVersion &+= 1
|
||||
}
|
||||
}
|
||||
|
||||
/// Monotonic counter incremented on every `dialogs` mutation.
|
||||
/// Used by ChatListViewModel to avoid redundant partition recomputation.
|
||||
@ObservationIgnored private(set) var dialogsVersion: Int = 0
|
||||
private var currentAccount: String = ""
|
||||
private var storagePassword: String = ""
|
||||
private var persistTask: Task<Void, Never>?
|
||||
@@ -81,6 +88,7 @@ final class DialogRepository {
|
||||
)
|
||||
_sortedKeysCache = nil
|
||||
updateAppBadge()
|
||||
syncMutedKeysToDefaults()
|
||||
}
|
||||
|
||||
func reset(clearPersisted: Bool = false) {
|
||||
@@ -91,6 +99,7 @@ final class DialogRepository {
|
||||
storagePassword = ""
|
||||
UNUserNotificationCenter.current().setBadgeCount(0)
|
||||
UserDefaults.standard.set(0, forKey: "app_badge_count")
|
||||
UserDefaults(suiteName: "group.com.rosetta.dev")?.set(0, forKey: "app_badge_count")
|
||||
|
||||
guard !currentAccount.isEmpty else { return }
|
||||
let accountToReset = currentAccount
|
||||
@@ -146,14 +155,14 @@ final class DialogRepository {
|
||||
)
|
||||
|
||||
// Desktop parity: constructLastMessageTextByAttachments() returns
|
||||
// "Photo"/"Avatar"/"File" for attachment-only messages.
|
||||
// "Photo"/"Avatar"/"File"/"Forwarded message" for attachment-only messages.
|
||||
if decryptedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
let firstAttachment = packet.attachments.first {
|
||||
switch firstAttachment.type {
|
||||
case .image: dialog.lastMessage = "Photo"
|
||||
case .file: dialog.lastMessage = "File"
|
||||
case .avatar: dialog.lastMessage = "Avatar"
|
||||
default: dialog.lastMessage = decryptedText
|
||||
case .messages: dialog.lastMessage = "Forwarded message"
|
||||
}
|
||||
} else {
|
||||
dialog.lastMessage = decryptedText
|
||||
@@ -165,13 +174,12 @@ final class DialogRepository {
|
||||
if fromMe {
|
||||
dialog.iHaveSent = true
|
||||
} else {
|
||||
// Only increment unread count when:
|
||||
// 1. The message is genuinely new (not a dedup hit from sync re-processing)
|
||||
// 2. The user is NOT currently viewing this dialog
|
||||
// Desktop parity: desktop computes unread from `SELECT COUNT(*) WHERE read = 0`,
|
||||
// so duplicates never inflate the count. iOS uses an incremental counter,
|
||||
// so we must guard against re-incrementing for known messages.
|
||||
if isNewMessage && !MessageRepository.shared.isDialogActive(opponentKey) {
|
||||
// Only increment unread count for REAL-TIME messages (not sync).
|
||||
// During sync, messages may already be read on another device but arrive
|
||||
// as "new" to iOS. Incrementing here inflates the badge (e.g., 11 → 4 → 0).
|
||||
// Android parity: Android recalculates unread from DB after every message
|
||||
// via COUNT(*) WHERE read=0. iOS defers to reconcileUnreadCounts() at sync end.
|
||||
if isNewMessage && !fromSync && !MessageRepository.shared.isDialogActive(opponentKey) {
|
||||
dialog.unreadCount += 1
|
||||
}
|
||||
}
|
||||
@@ -341,7 +349,7 @@ final class DialogRepository {
|
||||
case .image: dialog.lastMessage = "Photo"
|
||||
case .file: dialog.lastMessage = "File"
|
||||
case .avatar: dialog.lastMessage = "Avatar"
|
||||
default: dialog.lastMessage = lastMsg.text
|
||||
case .messages: dialog.lastMessage = "Forwarded message"
|
||||
}
|
||||
} else {
|
||||
dialog.lastMessage = lastMsg.text
|
||||
@@ -457,9 +465,19 @@ final class DialogRepository {
|
||||
guard var dialog = dialogs[opponentKey] else { return }
|
||||
dialog.isMuted.toggle()
|
||||
dialogs[opponentKey] = dialog
|
||||
syncMutedKeysToDefaults()
|
||||
schedulePersist()
|
||||
}
|
||||
|
||||
/// Sync muted chat keys to shared App Group UserDefaults.
|
||||
/// Background push handler reads this to skip notifications for muted chats
|
||||
/// without needing MainActor access to DialogRepository.
|
||||
private func syncMutedKeysToDefaults() {
|
||||
let mutedKeys = dialogs.values.filter(\.isMuted).map(\.opponentKey)
|
||||
UserDefaults.standard.set(mutedKeys, forKey: "muted_chats_keys")
|
||||
UserDefaults(suiteName: "group.com.rosetta.dev")?.set(mutedKeys, forKey: "muted_chats_keys")
|
||||
}
|
||||
|
||||
private func normalizeTimestamp(_ raw: Int64) -> Int64 {
|
||||
raw < 1_000_000_000_000 ? raw * 1000 : raw
|
||||
}
|
||||
@@ -485,9 +503,15 @@ final class DialogRepository {
|
||||
}
|
||||
|
||||
/// Update app icon badge with total unread message count.
|
||||
/// Writes to shared App Group UserDefaults so the Notification Service Extension
|
||||
/// can read the current count and increment it when the app is terminated.
|
||||
private func updateAppBadge() {
|
||||
let total = dialogs.values.reduce(0) { $0 + $1.unreadCount }
|
||||
let total = dialogs.values.filter { !$0.isMuted }.reduce(0) { $0 + $1.unreadCount }
|
||||
UNUserNotificationCenter.current().setBadgeCount(total)
|
||||
// Shared storage — NSE reads this to increment badge when app is killed.
|
||||
let shared = UserDefaults(suiteName: "group.com.rosetta.dev")
|
||||
shared?.set(total, forKey: "app_badge_count")
|
||||
// Keep standard defaults in sync for backward compat.
|
||||
UserDefaults.standard.set(total, forKey: "app_badge_count")
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,14 @@ final class MessageRepository: ObservableObject {
|
||||
messagesByDialog[dialogKey]?.last?.id == messageId
|
||||
}
|
||||
|
||||
/// Android parity: returns the timestamp of the latest incoming message in a dialog.
|
||||
/// Used for read receipt dedup — only send if `latestIncomingTs > lastReadReceiptTimestamp`.
|
||||
func latestIncomingTimestamp(for dialogKey: String, myPublicKey: String) -> Int64? {
|
||||
messagesByDialog[dialogKey]?
|
||||
.last { $0.fromPublicKey == dialogKey || $0.fromPublicKey != myPublicKey }?
|
||||
.timestamp
|
||||
}
|
||||
|
||||
/// Whether the user is currently viewing any chat.
|
||||
var hasActiveDialog: Bool {
|
||||
!activeDialogs.isEmpty
|
||||
@@ -105,6 +113,12 @@ final class MessageRepository: ObservableObject {
|
||||
activeDialogs.contains(dialogKey)
|
||||
}
|
||||
|
||||
/// All currently active dialog keys (read-only snapshot).
|
||||
/// Android parity: used to re-mark messages as read on idle→active transition.
|
||||
var activeDialogKeys: Set<String> {
|
||||
activeDialogs
|
||||
}
|
||||
|
||||
func setDialogActive(_ dialogKey: String, isActive: Bool) {
|
||||
if isActive {
|
||||
activeDialogs.insert(dialogKey)
|
||||
@@ -184,6 +198,12 @@ final class MessageRepository: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the current delivery status for a message, or nil if not found.
|
||||
func deliveryStatus(forMessageId messageId: String) -> DeliveryStatus? {
|
||||
guard let dialogKey = messageToDialog[messageId] else { return nil }
|
||||
return messagesByDialog[dialogKey]?.first(where: { $0.id == messageId })?.deliveryStatus
|
||||
}
|
||||
|
||||
func updateDeliveryStatus(messageId: String, status: DeliveryStatus, newTimestamp: Int64? = nil) {
|
||||
guard let dialogKey = messageToDialog[messageId] else { return }
|
||||
updateMessages(for: dialogKey) { messages in
|
||||
|
||||
@@ -105,14 +105,39 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
}
|
||||
|
||||
/// Verify connection health after returning from background.
|
||||
/// Always force reconnect — after background, the socket is likely dead
|
||||
/// and a 2s ping timeout just delays the inevitable.
|
||||
/// Fast path: if already authenticated, send a WebSocket ping first (< 100ms).
|
||||
/// If pong arrives, connection is alive — no reconnect needed.
|
||||
/// If ping fails or times out (500ms), force full reconnect.
|
||||
func reconnectIfNeeded() {
|
||||
guard savedPublicKey != nil, savedPrivateHash != nil else { return }
|
||||
|
||||
// Don't interrupt active handshake
|
||||
if connectionState == .handshaking { return }
|
||||
|
||||
// Fast path: if authenticated, try ping first before tearing down.
|
||||
if connectionState == .authenticated, client.isConnected {
|
||||
Self.logger.info("Foreground — ping check")
|
||||
client.sendPing { [weak self] error in
|
||||
guard let self else { return }
|
||||
if error == nil {
|
||||
// Pong received — connection alive, send heartbeat to keep it fresh.
|
||||
Self.logger.info("Foreground ping OK — connection alive")
|
||||
self.client.sendText("heartbeat")
|
||||
return
|
||||
}
|
||||
// Ping failed — connection dead, force reconnect.
|
||||
Self.logger.info("Foreground ping failed — force reconnecting")
|
||||
self.handshakeComplete = false
|
||||
self.heartbeatTask?.cancel()
|
||||
Task { @MainActor in
|
||||
self.connectionState = .connecting
|
||||
}
|
||||
self.client.forceReconnect()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Not authenticated — force reconnect immediately.
|
||||
Self.logger.info("Foreground reconnect — force reconnecting")
|
||||
handshakeComplete = false
|
||||
heartbeatTask?.cancel()
|
||||
|
||||
@@ -31,7 +31,11 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
||||
override init() {
|
||||
super.init()
|
||||
let config = URLSessionConfiguration.default
|
||||
config.waitsForConnectivity = true
|
||||
// Don't wait for connectivity — fail fast so NWPathMonitor can trigger
|
||||
// instant reconnect when network becomes available.
|
||||
config.waitsForConnectivity = false
|
||||
config.timeoutIntervalForRequest = 10
|
||||
config.timeoutIntervalForResource = 15
|
||||
session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
|
||||
startNetworkMonitor()
|
||||
}
|
||||
@@ -91,6 +95,8 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
||||
webSocketTask = nil
|
||||
isConnected = false
|
||||
disconnectHandledForCurrentSocket = false
|
||||
// Android parity: reset backoff so next failure starts from 1s, not stale 8s/16s.
|
||||
reconnectAttempts = 0
|
||||
Self.logger.info("Force reconnect triggered")
|
||||
connect()
|
||||
}
|
||||
@@ -196,9 +202,19 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
||||
guard !isManuallyClosed else { return }
|
||||
|
||||
guard reconnectTask == nil else { return }
|
||||
// Android parity: exponential backoff — 1s, 2s, 4s, 8s, 16s (cap).
|
||||
// First attempt: reconnect immediately (0ms delay) for fastest recovery.
|
||||
// Subsequent attempts: exponential backoff — 1s, 2s, 4s, 8s, 16s (cap).
|
||||
reconnectAttempts += 1
|
||||
let exponent = min(reconnectAttempts - 1, 4)
|
||||
if reconnectAttempts == 1 {
|
||||
// Immediate retry — no delay on first attempt.
|
||||
Self.logger.info("Reconnecting immediately (attempt #1)...")
|
||||
reconnectTask = Task { [weak self] in
|
||||
guard let self, !isManuallyClosed, !Task.isCancelled else { return }
|
||||
self.reconnectTask = nil
|
||||
self.connect()
|
||||
}
|
||||
} else {
|
||||
let exponent = min(reconnectAttempts - 2, 4)
|
||||
let delayMs = min(1000 * (1 << exponent), 16000)
|
||||
reconnectTask = Task { [weak self] in
|
||||
Self.logger.info("Reconnecting in \(delayMs)ms (attempt #\(self?.reconnectAttempts ?? 0))...")
|
||||
@@ -209,3 +225,4 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,9 @@ final class TransportManager: @unchecked Sendable {
|
||||
/// - id: Unique file identifier (used as filename in multipart).
|
||||
/// - content: Raw file content to upload.
|
||||
/// - Returns: Server-assigned tag for later download.
|
||||
/// Android parity: retry with exponential backoff (1s, 2s, 4s) on upload failure.
|
||||
private static let maxUploadRetries = 3
|
||||
|
||||
func uploadFile(id: String, content: Data) async throws -> String {
|
||||
guard let serverUrl = await MainActor.run(body: { transportServer }) else {
|
||||
throw TransportError.noTransportServer
|
||||
@@ -102,6 +105,9 @@ final class TransportManager: @unchecked Sendable {
|
||||
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
|
||||
request.httpBody = body
|
||||
|
||||
var lastError: Error = TransportError.invalidResponse
|
||||
for attempt in 0..<Self.maxUploadRetries {
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
@@ -121,6 +127,16 @@ final class TransportManager: @unchecked Sendable {
|
||||
|
||||
Self.logger.info("Upload complete: id=\(id), tag=\(tag)")
|
||||
return tag
|
||||
} catch {
|
||||
lastError = error
|
||||
if attempt < Self.maxUploadRetries - 1 {
|
||||
let delayMs = 1000 * (1 << attempt) // 1s, 2s, 4s
|
||||
Self.logger.warning("Upload retry \(attempt + 1)/\(Self.maxUploadRetries) for \(id) in \(delayMs)ms: \(error.localizedDescription)")
|
||||
try? await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
||||
// MARK: - Download
|
||||
|
||||
@@ -28,8 +28,9 @@ final class SessionManager {
|
||||
private var syncRequestInFlight = false
|
||||
private var pendingIncomingMessages: [PacketMessage] = []
|
||||
private var isProcessingIncomingMessages = false
|
||||
private var pendingReadReceiptKeys: Set<String> = []
|
||||
private var lastReadReceiptSentAt: [String: Int64] = [:]
|
||||
/// Android parity: tracks the latest incoming message timestamp per dialog
|
||||
/// for which a read receipt was already sent. Prevents redundant sends.
|
||||
private var lastReadReceiptTimestamp: [String: Int64] = [:]
|
||||
private var requestedUserInfoKeys: Set<String> = []
|
||||
private var onlineSubscribedKeys: Set<String> = []
|
||||
private var pendingOutgoingRetryTasks: [String: Task<Void, Never>] = [:]
|
||||
@@ -38,17 +39,9 @@ final class SessionManager {
|
||||
private let maxOutgoingRetryAttempts = ProtocolConstants.maxOutgoingRetryAttempts
|
||||
private let maxOutgoingWaitingLifetimeMs: Int64 = ProtocolConstants.messageDeliveryTimeoutS * 1000
|
||||
|
||||
// MARK: - Idle Detection (Desktop parity)
|
||||
// MARK: - Foreground Detection (Android parity)
|
||||
|
||||
/// Tracks the last user interaction timestamp for idle detection.
|
||||
/// Desktop: messages marked unread if user idle > 20 seconds.
|
||||
private var lastUserInteractionTime: Date = Date()
|
||||
private var idleObserverToken: NSObjectProtocol?
|
||||
|
||||
/// Whether the user is considered idle (no interaction for `idleTimeoutForUnreadS`).
|
||||
private var isUserIdle: Bool {
|
||||
Date().timeIntervalSince(lastUserInteractionTime) > ProtocolConstants.idleTimeoutForUnreadS
|
||||
}
|
||||
private var foregroundObserverToken: NSObjectProtocol?
|
||||
|
||||
/// Whether the app is in the foreground.
|
||||
private var isAppInForeground: Bool {
|
||||
@@ -60,13 +53,22 @@ final class SessionManager {
|
||||
private init() {
|
||||
setupProtocolCallbacks()
|
||||
setupUserInfoSearchHandler()
|
||||
setupIdleDetection()
|
||||
setupForegroundObserver()
|
||||
}
|
||||
|
||||
/// Desktop parity: track user interaction to implement idle detection.
|
||||
/// Call this from any user-facing interaction (tap, scroll, keyboard).
|
||||
func recordUserInteraction() {
|
||||
lastUserInteractionTime = Date()
|
||||
/// Android parity (ON_RESUME): re-mark active dialogs as read and send read receipts.
|
||||
/// Called on foreground resume. Android has no idle detection — just re-marks on resume.
|
||||
func markActiveDialogsAsRead() {
|
||||
let activeKeys = MessageRepository.shared.activeDialogKeys
|
||||
let myKey = currentPublicKey
|
||||
for dialogKey in activeKeys {
|
||||
guard !SystemAccounts.isSystemAccount(dialogKey) else { continue }
|
||||
DialogRepository.shared.markAsRead(opponentKey: dialogKey)
|
||||
MessageRepository.shared.markIncomingAsRead(
|
||||
opponentKey: dialogKey, myPublicKey: myKey
|
||||
)
|
||||
sendReadReceipt(toPublicKey: dialogKey)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session Lifecycle
|
||||
@@ -218,10 +220,15 @@ final class SessionManager {
|
||||
recipientPublicKeyHex: toPublicKey
|
||||
)
|
||||
|
||||
// Desktop parity: attachment password = plainKeyAndNonce interpreted as UTF-8 string
|
||||
// (same derivation as aesChachaKey: key.toString('utf-8') in useDialog.ts)
|
||||
// Must use UTF-8 decoding with replacement characters (U+FFFD) to match Node.js behavior.
|
||||
let latin1String = String(decoding: encrypted.plainKeyAndNonce, as: UTF8.self)
|
||||
// Attachment password: Android-style UTF-8 decoder (1:1 parity with Android).
|
||||
let attachmentPassword = MessageCrypto.bytesToAndroidUtf8String(encrypted.plainKeyAndNonce)
|
||||
|
||||
// aesChachaKey = Latin-1 encoding (matches desktop sync chain:
|
||||
// Buffer.from(decryptedString, 'binary') takes low byte of each char).
|
||||
// NEVER use WHATWG UTF-8 for aesChachaKey — U+FFFD round-trips as 0xFD, not original byte.
|
||||
guard let latin1ForSync = String(data: encrypted.plainKeyAndNonce, encoding: .isoLatin1) else {
|
||||
throw CryptoError.encryptionFailed
|
||||
}
|
||||
|
||||
// Desktop parity: avatar blob is a full data URI (e.g. "data:image/png;base64,iVBOR...")
|
||||
// not just raw base64. Desktop's AvatarProvider stores and sends data URIs.
|
||||
@@ -229,7 +236,7 @@ final class SessionManager {
|
||||
let avatarData = Data(dataURI.utf8)
|
||||
let encryptedBlob = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
|
||||
avatarData,
|
||||
password: latin1String
|
||||
password: attachmentPassword
|
||||
)
|
||||
|
||||
// Upload encrypted blob to transport server (desktop: uploadFile)
|
||||
@@ -243,8 +250,8 @@ final class SessionManager {
|
||||
let blurhash = avatarImage?.blurHash(numberOfComponents: (4, 3)) ?? ""
|
||||
let preview = "\(tag)::\(blurhash)"
|
||||
|
||||
// Build aesChachaKey (same as regular messages)
|
||||
let aesChachaPayload = Data(latin1String.utf8)
|
||||
// Build aesChachaKey with Latin-1 payload (desktop sync parity)
|
||||
let aesChachaPayload = Data(latin1ForSync.utf8)
|
||||
let aesChachaKey = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
|
||||
aesChachaPayload,
|
||||
password: privKey
|
||||
@@ -297,10 +304,15 @@ final class SessionManager {
|
||||
DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .error)
|
||||
}
|
||||
|
||||
// Saved Messages — local only
|
||||
// Saved Messages — mark delivered locally but STILL send to server
|
||||
// for cross-device avatar sync. Other devices receive via sync and
|
||||
// update their local avatar cache.
|
||||
if toPublicKey == currentPublicKey {
|
||||
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered)
|
||||
DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .delivered)
|
||||
// Send to server for multi-device sync (unlike text Saved Messages)
|
||||
ProtocolManager.shared.sendPacket(packet)
|
||||
Self.logger.info("📤 Avatar synced to Saved Messages (multi-device) tag=\(tag)")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -342,9 +354,11 @@ final class SessionManager {
|
||||
recipientPublicKeyHex: toPublicKey
|
||||
)
|
||||
|
||||
// Attachment password: WHATWG UTF-8 of raw key+nonce bytes.
|
||||
// Matches desktop's Buffer.from(rawBytes).toString('utf-8') for PBKDF2 password derivation.
|
||||
let attachmentPassword = String(decoding: encrypted.plainKeyAndNonce, as: UTF8.self)
|
||||
// Attachment password: Android-style UTF-8 decoder (feross/buffer polyfill) for 1:1 parity.
|
||||
// Android uses bytesToJsUtf8String() which emits ONE U+FFFD per consumed byte in
|
||||
// failed multi-byte sequences. Desktop WHATWG is slightly different but both work
|
||||
// because Desktop tries both variants when decrypting.
|
||||
let attachmentPassword = MessageCrypto.bytesToAndroidUtf8String(encrypted.plainKeyAndNonce)
|
||||
|
||||
#if DEBUG
|
||||
// Full diagnostic: log values needed to verify PBKDF2 key matches CryptoJS.
|
||||
@@ -361,92 +375,81 @@ final class SessionManager {
|
||||
Self.logger.debug("📎 pbkdf2Key: \(pbkdf2Key.hexString)")
|
||||
#endif
|
||||
|
||||
// Process each attachment: encrypt → upload → build metadata
|
||||
var messageAttachments: [MessageAttachment] = []
|
||||
// Phase 1: Encrypt all attachments sequentially (same password, CPU-bound).
|
||||
struct EncryptedAttachment {
|
||||
let original: PendingAttachment
|
||||
let encryptedData: Data
|
||||
let preview: String // partially built — tag placeholder
|
||||
}
|
||||
var encryptedAttachments: [EncryptedAttachment] = []
|
||||
for attachment in attachments {
|
||||
// Build data URI (desktop: FileReader.readAsDataURL)
|
||||
let dataURI = buildDataURI(attachment)
|
||||
|
||||
#if DEBUG
|
||||
Self.logger.debug("📎 DataURI prefix: \(String(dataURI.prefix(40)))… (\(dataURI.count) chars)")
|
||||
#endif
|
||||
|
||||
// Encrypt blob with desktop-compatible encryption
|
||||
let encryptedBlob = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
|
||||
Data(dataURI.utf8),
|
||||
password: attachmentPassword
|
||||
)
|
||||
|
||||
#if DEBUG
|
||||
// Log IV and ciphertext prefix for cross-platform verification.
|
||||
let blobParts = encryptedBlob.components(separatedBy: ":")
|
||||
if blobParts.count == 2, let ivData = Data(base64Encoded: blobParts[0]) {
|
||||
Self.logger.debug("📎 blob IV: \(ivData.hexString), ct(\(blobParts[1].count) b64chars)")
|
||||
}
|
||||
// Self-test: decrypt with the SAME WHATWG password.
|
||||
if let selfTestData = try? CryptoManager.shared.decryptWithPassword(
|
||||
encryptedBlob, password: attachmentPassword, requireCompression: true
|
||||
), String(data: selfTestData, encoding: .utf8)?.hasPrefix("data:") == true {
|
||||
Self.logger.debug("📎 Blob self-test PASSED")
|
||||
Self.logger.debug("📎 Blob self-test PASSED for \(attachment.id)")
|
||||
} else {
|
||||
Self.logger.error("📎 Blob self-test FAILED — blob may not decrypt on desktop")
|
||||
Self.logger.error("📎 Blob self-test FAILED for \(attachment.id)")
|
||||
}
|
||||
#endif
|
||||
|
||||
// Upload to transport server
|
||||
let uploadData = Data(encryptedBlob.utf8)
|
||||
let uploadHash = CryptoManager.shared.sha256(uploadData)
|
||||
let tag = try await TransportManager.shared.uploadFile(
|
||||
id: attachment.id,
|
||||
content: uploadData
|
||||
)
|
||||
|
||||
#if DEBUG
|
||||
Self.logger.debug("📎 Uploaded tag=\(tag), \(uploadData.count) bytes, sha256=\(uploadHash.hexString)")
|
||||
|
||||
// Transport round-trip verification: download the blob back and compare SHA256.
|
||||
// This catches CDN corruption, partial uploads, and encoding issues.
|
||||
do {
|
||||
let verifyData = try await TransportManager.shared.downloadFile(tag: tag)
|
||||
let verifyHash = CryptoManager.shared.sha256(verifyData)
|
||||
if uploadHash == verifyHash {
|
||||
Self.logger.debug("📎 Transport verify PASS: tag=\(tag), \(verifyData.count) bytes")
|
||||
} else {
|
||||
Self.logger.error("📎 ❌ TRANSPORT MISMATCH tag=\(tag): uploaded \(uploadData.count)b sha=\(uploadHash.hexString), downloaded \(verifyData.count)b sha=\(verifyHash.hexString)")
|
||||
// Log first 100 bytes of each for comparison
|
||||
let upPrefix = String(data: uploadData.prefix(100), encoding: .utf8) ?? "<non-utf8>"
|
||||
let downStr = String(data: verifyData.prefix(100), encoding: .utf8) ?? "<non-utf8>"
|
||||
Self.logger.error("📎 ❌ Upload prefix: \(upPrefix)")
|
||||
Self.logger.error("📎 ❌ Download prefix: \(downStr)")
|
||||
}
|
||||
} catch {
|
||||
Self.logger.error("📎 ❌ Transport verify FAILED to download tag=\(tag): \(error)")
|
||||
}
|
||||
#endif
|
||||
|
||||
// Build preview string (format depends on type)
|
||||
// Desktop parity: preview = "tag::blurhash" for images, "tag::size::filename" for files
|
||||
let preview: String
|
||||
// Pre-compute blurhash/preview prefix (everything except tag)
|
||||
let previewSuffix: String
|
||||
switch attachment.type {
|
||||
case .image:
|
||||
// Generate blurhash from thumbnail (android: BlurHash.encode(bitmap, 4, 3))
|
||||
let blurhash = attachment.thumbnail?.blurHash(numberOfComponents: (4, 3)) ?? ""
|
||||
preview = "\(tag)::\(blurhash)"
|
||||
previewSuffix = blurhash
|
||||
case .file:
|
||||
// Desktop: preview = "tag::size::filename"
|
||||
preview = "\(tag)::\(attachment.fileSize ?? 0)::\(attachment.fileName ?? "file")"
|
||||
previewSuffix = "\(attachment.fileSize ?? 0)::\(attachment.fileName ?? "file")"
|
||||
default:
|
||||
preview = "\(tag)::"
|
||||
previewSuffix = ""
|
||||
}
|
||||
|
||||
messageAttachments.append(MessageAttachment(
|
||||
id: attachment.id,
|
||||
preview: preview,
|
||||
blob: "", // Desktop parity: blob cleared after upload
|
||||
type: attachment.type
|
||||
encryptedAttachments.append(EncryptedAttachment(
|
||||
original: attachment,
|
||||
encryptedData: Data(encryptedBlob.utf8),
|
||||
preview: previewSuffix
|
||||
))
|
||||
}
|
||||
|
||||
Self.logger.info("📤 Attachment uploaded: type=\(String(describing: attachment.type)), tag=\(tag)")
|
||||
// Phase 2: Upload all attachments concurrently (Android parity: backgroundUploadScope).
|
||||
let messageAttachments: [MessageAttachment] = try await withThrowingTaskGroup(
|
||||
of: (Int, String).self
|
||||
) { group in
|
||||
for (index, item) in encryptedAttachments.enumerated() {
|
||||
group.addTask {
|
||||
let tag = try await TransportManager.shared.uploadFile(
|
||||
id: item.original.id,
|
||||
content: item.encryptedData
|
||||
)
|
||||
return (index, tag)
|
||||
}
|
||||
}
|
||||
|
||||
// Collect results, preserving original order.
|
||||
var tags = [Int: String]()
|
||||
for try await (index, tag) in group {
|
||||
tags[index] = tag
|
||||
}
|
||||
|
||||
return encryptedAttachments.enumerated().map { index, item in
|
||||
let tag = tags[index] ?? ""
|
||||
let preview = item.preview.isEmpty ? "\(tag)::" : "\(tag)::\(item.preview)"
|
||||
Self.logger.info("📤 Attachment uploaded: type=\(String(describing: item.original.type)), tag=\(tag)")
|
||||
return MessageAttachment(
|
||||
id: item.original.id,
|
||||
preview: preview,
|
||||
blob: "",
|
||||
type: item.original.type
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Build aesChachaKey (for sync/backup — same encoding as makeOutgoingPacket).
|
||||
@@ -606,9 +609,9 @@ final class SessionManager {
|
||||
recipientPublicKeyHex: toPublicKey
|
||||
)
|
||||
|
||||
// Desktop parity: reply blob password = WHATWG UTF-8 of raw plainKeyAndNonce bytes.
|
||||
// Android parity: reply blob password = Android-style UTF-8 of raw plainKeyAndNonce bytes.
|
||||
// Same as attachment password derivation.
|
||||
let replyPassword = String(decoding: encrypted.plainKeyAndNonce, as: UTF8.self)
|
||||
let replyPassword = MessageCrypto.bytesToAndroidUtf8String(encrypted.plainKeyAndNonce)
|
||||
|
||||
// Build the reply JSON blob
|
||||
let replyJSON = try JSONEncoder().encode(replyMessages)
|
||||
@@ -736,9 +739,47 @@ final class SessionManager {
|
||||
ProtocolManager.shared.sendPacket(packet)
|
||||
}
|
||||
|
||||
/// Sends read receipt for direct dialog.
|
||||
/// Android parity: sends read receipt for direct dialog.
|
||||
/// Uses timestamp dedup (not time-based throttle) — only sends if latest incoming
|
||||
/// message timestamp > last sent read receipt timestamp. Retries once after 2s.
|
||||
func sendReadReceipt(toPublicKey: String) {
|
||||
sendReadReceipt(toPublicKey: toPublicKey, force: false)
|
||||
let normalized = toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let connState = ProtocolManager.shared.connectionState
|
||||
guard normalized != currentPublicKey,
|
||||
!normalized.isEmpty,
|
||||
let hash = privateKeyHash,
|
||||
connState == .authenticated
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
// Android parity: timestamp dedup — only send if latest incoming message
|
||||
// timestamp is newer than what we already sent a receipt for.
|
||||
let latestTs = MessageRepository.shared.latestIncomingTimestamp(
|
||||
for: normalized, myPublicKey: currentPublicKey
|
||||
) ?? 0
|
||||
let lastSentTs = lastReadReceiptTimestamp[normalized] ?? 0
|
||||
if latestTs > 0, latestTs <= lastSentTs { return }
|
||||
|
||||
var packet = PacketRead()
|
||||
packet.privateKey = hash
|
||||
packet.fromPublicKey = currentPublicKey
|
||||
packet.toPublicKey = normalized
|
||||
ProtocolManager.shared.sendPacket(packet)
|
||||
lastReadReceiptTimestamp[normalized] = latestTs
|
||||
|
||||
// Android parity: retry once after 2 seconds in case send failed silently.
|
||||
Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
guard let self,
|
||||
ProtocolManager.shared.connectionState == .authenticated,
|
||||
let hash = self.privateKeyHash else { return }
|
||||
var retryPacket = PacketRead()
|
||||
retryPacket.privateKey = hash
|
||||
retryPacket.fromPublicKey = self.currentPublicKey
|
||||
retryPacket.toPublicKey = normalized
|
||||
ProtocolManager.shared.sendPacket(retryPacket)
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates locally cached display name and username (called from ProfileEditView).
|
||||
@@ -757,14 +798,12 @@ final class SessionManager {
|
||||
syncRequestInFlight = false
|
||||
pendingIncomingMessages.removeAll()
|
||||
isProcessingIncomingMessages = false
|
||||
pendingReadReceiptKeys.removeAll()
|
||||
lastReadReceiptSentAt.removeAll()
|
||||
lastReadReceiptTimestamp.removeAll()
|
||||
requestedUserInfoKeys.removeAll()
|
||||
pendingOutgoingRetryTasks.values.forEach { $0.cancel() }
|
||||
pendingOutgoingRetryTasks.removeAll()
|
||||
pendingOutgoingPackets.removeAll()
|
||||
pendingOutgoingAttempts.removeAll()
|
||||
lastUserInteractionTime = Date()
|
||||
isAuthenticated = false
|
||||
currentPublicKey = ""
|
||||
displayName = ""
|
||||
@@ -809,6 +848,10 @@ final class SessionManager {
|
||||
newTimestamp: deliveryTimestamp
|
||||
)
|
||||
self?.resolveOutgoingRetry(messageId: packet.messageId)
|
||||
// Desktop parity (useDialogFiber.ts): update sync cursor on delivery ACK.
|
||||
if let self, !self.syncBatchInProgress {
|
||||
self.saveLastSyncTimestamp(deliveryTimestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -845,6 +888,14 @@ final class SessionManager {
|
||||
opponentKey: opponentKey,
|
||||
myPublicKey: ownKey
|
||||
)
|
||||
// Resolve pending retry timers for all messages to this opponent —
|
||||
// read receipt proves delivery, no need to retry further.
|
||||
self.resolveAllOutgoingRetries(toPublicKey: opponentKey)
|
||||
// Desktop parity (useDialogFiber.ts): update sync cursor on read receipt.
|
||||
if !self.syncBatchInProgress {
|
||||
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
self.saveLastSyncTimestamp(nowMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -964,12 +1015,16 @@ final class SessionManager {
|
||||
await self.waitForInboundQueueToDrain()
|
||||
let serverCursor = packet.timestamp
|
||||
self.saveLastSyncTimestamp(serverCursor)
|
||||
// Android parity: reconcile unread counts after each batch.
|
||||
// Sync messages may have been read on another device — PacketRead
|
||||
// arrives during sync and marks them read, but incremental counters
|
||||
// can drift. Reconcile from actual isRead state.
|
||||
DialogRepository.shared.reconcileUnreadCounts()
|
||||
Self.logger.debug("SYNC BATCH_END cursor=\(serverCursor)")
|
||||
self.requestSynchronize(cursor: serverCursor)
|
||||
|
||||
case .notNeeded:
|
||||
self.syncBatchInProgress = false
|
||||
self.flushPendingReadReceipts()
|
||||
DialogRepository.shared.reconcileDeliveryStatuses()
|
||||
DialogRepository.shared.reconcileUnreadCounts()
|
||||
Self.logger.debug("SYNC NOT_NEEDED")
|
||||
@@ -1228,13 +1283,12 @@ final class SessionManager {
|
||||
// Sending 0x08 for every received message was causing a packet flood
|
||||
// that triggered server RST disconnects.
|
||||
|
||||
// Desktop parity: only mark as read if user is NOT idle AND app is in foreground.
|
||||
// Desktop also skips system accounts and blocked users.
|
||||
// Android parity: mark as read if dialog is active AND app is in foreground.
|
||||
// Android has NO idle detection — only isDialogActive flag (ON_RESUME/ON_PAUSE).
|
||||
let dialogIsActive = MessageRepository.shared.isDialogActive(opponentKey)
|
||||
let isSystem = SystemAccounts.isSystemAccount(opponentKey)
|
||||
let idle = isUserIdle
|
||||
let fg = isAppInForeground
|
||||
let shouldMarkRead = dialogIsActive && !idle && fg && !isSystem
|
||||
let shouldMarkRead = dialogIsActive && fg && !isSystem
|
||||
|
||||
if shouldMarkRead {
|
||||
DialogRepository.shared.markAsRead(opponentKey: opponentKey)
|
||||
@@ -1243,13 +1297,11 @@ final class SessionManager {
|
||||
myPublicKey: myKey
|
||||
)
|
||||
if !fromMe && !wasKnownBefore {
|
||||
if syncBatchInProgress {
|
||||
pendingReadReceiptKeys.insert(opponentKey)
|
||||
} else {
|
||||
// Android/Desktop parity: send read receipt immediately,
|
||||
// even during sync. 400ms debounce prevents flooding.
|
||||
sendReadReceipt(toPublicKey: opponentKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Desktop parity (useUpdateSyncTime.ts): no-op during SYNCHRONIZATION.
|
||||
// Sync cursor is updated once at BATCH_END with the server's timestamp.
|
||||
@@ -1327,9 +1379,8 @@ final class SessionManager {
|
||||
guard !syncRequestInFlight else { return }
|
||||
|
||||
syncRequestInFlight = true
|
||||
// Desktop parity: pass server cursor as-is (seconds). NO normalization —
|
||||
// server uses seconds, converting to milliseconds made the server see a
|
||||
// "future" cursor and respond NOT_NEEDED, breaking all subsequent syncs.
|
||||
// Server and all platforms use MILLISECONDS for sync cursors.
|
||||
// Pass cursor as-is — no normalization needed.
|
||||
let lastSync = cursor ?? loadLastSyncTimestamp()
|
||||
|
||||
var packet = PacketSync()
|
||||
@@ -1343,10 +1394,11 @@ final class SessionManager {
|
||||
private func loadLastSyncTimestamp() -> Int64 {
|
||||
guard !currentPublicKey.isEmpty else { return 0 }
|
||||
let stored = Int64(UserDefaults.standard.integer(forKey: syncCursorKey))
|
||||
// Migration: old code normalized seconds → milliseconds. If the stored value
|
||||
// is in milliseconds (>= 1 trillion), convert back to seconds for server parity.
|
||||
if stored >= 1_000_000_000_000 {
|
||||
let corrected = stored / 1000
|
||||
// Android parity (normalizeSyncTimestamp): all platforms store milliseconds.
|
||||
// If stored value is in seconds (< 1 trillion), convert to milliseconds.
|
||||
// Values >= 1 trillion are already in milliseconds — return as-is.
|
||||
if stored > 0, stored < 1_000_000_000_000 {
|
||||
let corrected = stored * 1000
|
||||
UserDefaults.standard.set(Int(corrected), forKey: syncCursorKey)
|
||||
return corrected
|
||||
}
|
||||
@@ -1355,7 +1407,7 @@ final class SessionManager {
|
||||
|
||||
private func saveLastSyncTimestamp(_ raw: Int64) {
|
||||
guard !currentPublicKey.isEmpty else { return }
|
||||
// Desktop parity: store server cursor as-is (seconds), no normalization.
|
||||
// Store server cursor as-is (milliseconds). No normalization.
|
||||
guard raw > 0 else { return }
|
||||
let existing = loadLastSyncTimestamp()
|
||||
guard raw > existing else { return }
|
||||
@@ -1632,11 +1684,18 @@ final class SessionManager {
|
||||
)
|
||||
|
||||
do {
|
||||
// Use fresh timestamp for the packet so the 80s delivery timeout
|
||||
// starts from NOW, not from the original send time.
|
||||
// Without this, messages sent 70s before reconnect would expire
|
||||
// in ~10s — not enough time for the server to respond with 0x08.
|
||||
// Also fixes server-side messageId dedup: fresh timestamp lets the
|
||||
// server accept the retried message instead of silently dropping it.
|
||||
let retryTimestamp = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
let packet = try makeOutgoingPacket(
|
||||
text: text,
|
||||
toPublicKey: message.toPublicKey,
|
||||
messageId: message.id,
|
||||
timestamp: message.timestamp,
|
||||
timestamp: retryTimestamp,
|
||||
privateKeyHex: privateKeyHex,
|
||||
privateKeyHash: privateKeyHash
|
||||
)
|
||||
@@ -1655,42 +1714,6 @@ final class SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
private func flushPendingReadReceipts() {
|
||||
guard !pendingReadReceiptKeys.isEmpty else { return }
|
||||
let keys = pendingReadReceiptKeys
|
||||
pendingReadReceiptKeys.removeAll()
|
||||
for key in keys {
|
||||
sendReadReceipt(toPublicKey: key, force: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendReadReceipt(toPublicKey: String, force: Bool) {
|
||||
let normalized = toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let connState = ProtocolManager.shared.connectionState
|
||||
guard normalized != currentPublicKey,
|
||||
!normalized.isEmpty,
|
||||
let hash = privateKeyHash,
|
||||
connState == .authenticated
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
let now = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
if !force {
|
||||
let lastSent = lastReadReceiptSentAt[normalized] ?? 0
|
||||
if now - lastSent < 400 {
|
||||
return
|
||||
}
|
||||
}
|
||||
lastReadReceiptSentAt[normalized] = now
|
||||
|
||||
var packet = PacketRead()
|
||||
packet.privateKey = hash
|
||||
packet.fromPublicKey = currentPublicKey
|
||||
packet.toPublicKey = normalized
|
||||
ProtocolManager.shared.sendPacket(packet)
|
||||
}
|
||||
|
||||
private func registerOutgoingRetry(for packet: PacketMessage) {
|
||||
let messageId = packet.messageId
|
||||
pendingOutgoingRetryTasks[messageId]?.cancel()
|
||||
@@ -1708,20 +1731,26 @@ final class SessionManager {
|
||||
guard let packet = self.pendingOutgoingPackets[messageId] else { return }
|
||||
let attempts = self.pendingOutgoingAttempts[messageId] ?? 0
|
||||
|
||||
// Check if message exceeded delivery timeout (80s) — mark as error.
|
||||
// Android parity: 80s × max(1, attachmentCount).
|
||||
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
let ageMs = nowMs - packet.timestamp
|
||||
if ageMs >= self.maxOutgoingWaitingLifetimeMs {
|
||||
Self.logger.warning("Message \(messageId) expired after \(ageMs)ms — marking as error")
|
||||
self.markOutgoingAsError(messageId: messageId, packet: packet)
|
||||
let attachCount = max(1, Int64(packet.attachments.count))
|
||||
let timeoutMs = self.maxOutgoingWaitingLifetimeMs * attachCount
|
||||
if ageMs >= timeoutMs {
|
||||
// Server didn't send 0x08, but we were authenticated and packets
|
||||
// were sent successfully. Most likely cause: server deduplicates
|
||||
// by messageId (original was delivered before disconnect, ACK lost).
|
||||
// Mark as DELIVERED (optimistic) rather than ERROR.
|
||||
Self.logger.info("Message \(messageId) — no ACK after \(ageMs)ms, marking as delivered (optimistic)")
|
||||
self.markOutgoingAsDelivered(messageId: messageId, packet: packet)
|
||||
return
|
||||
}
|
||||
|
||||
guard attempts < self.maxOutgoingRetryAttempts else {
|
||||
// Max retries exhausted for this connection session — mark as error.
|
||||
// The user sees the error icon immediately instead of a stuck clock.
|
||||
Self.logger.warning("Message \(messageId) exhausted \(attempts) retries — marking as error")
|
||||
self.markOutgoingAsError(messageId: messageId, packet: packet)
|
||||
// Max retries exhausted while connected — same reasoning:
|
||||
// packets were sent, no error from server, likely delivered.
|
||||
Self.logger.info("Message \(messageId) — no ACK after \(attempts) retries, marking as delivered (optimistic)")
|
||||
self.markOutgoingAsDelivered(messageId: messageId, packet: packet)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1741,10 +1770,48 @@ final class SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Optimistically mark an outgoing message as delivered when no ACK was received
|
||||
/// but packets were successfully sent while authenticated. Most common cause:
|
||||
/// server deduplicates by messageId (original was delivered before disconnect).
|
||||
private func markOutgoingAsDelivered(messageId: String, packet: PacketMessage) {
|
||||
let fromMe = packet.fromPublicKey == currentPublicKey
|
||||
let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey
|
||||
|
||||
let currentStatus = MessageRepository.shared.deliveryStatus(forMessageId: messageId)
|
||||
if currentStatus == .read {
|
||||
// Already read — don't downgrade to delivered.
|
||||
resolveOutgoingRetry(messageId: messageId)
|
||||
return
|
||||
}
|
||||
|
||||
let deliveryTimestamp = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
MessageRepository.shared.updateDeliveryStatus(
|
||||
messageId: messageId,
|
||||
status: .delivered,
|
||||
newTimestamp: deliveryTimestamp
|
||||
)
|
||||
DialogRepository.shared.updateDeliveryStatus(
|
||||
messageId: messageId,
|
||||
opponentKey: opponentKey,
|
||||
status: .delivered
|
||||
)
|
||||
resolveOutgoingRetry(messageId: messageId)
|
||||
}
|
||||
|
||||
/// Mark an outgoing message as error in both repositories and clean up retry state.
|
||||
/// Guards against downgrading .delivered/.read → .error (e.g. if PacketDelivery/PacketRead
|
||||
/// arrived while the retry timer was still running).
|
||||
private func markOutgoingAsError(messageId: String, packet: PacketMessage) {
|
||||
let fromMe = packet.fromPublicKey == currentPublicKey
|
||||
let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey
|
||||
|
||||
let currentStatus = MessageRepository.shared.deliveryStatus(forMessageId: messageId)
|
||||
if currentStatus == .delivered || currentStatus == .read {
|
||||
Self.logger.info("Skipping markOutgoingAsError for \(messageId.prefix(8))… — already \(currentStatus?.rawValue ?? -1)")
|
||||
resolveOutgoingRetry(messageId: messageId)
|
||||
return
|
||||
}
|
||||
|
||||
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .error)
|
||||
DialogRepository.shared.updateDeliveryStatus(
|
||||
messageId: messageId,
|
||||
@@ -1761,6 +1828,18 @@ final class SessionManager {
|
||||
pendingOutgoingAttempts.removeValue(forKey: messageId)
|
||||
}
|
||||
|
||||
/// Resolve all pending outgoing retries for messages to a specific opponent.
|
||||
/// Called when PacketRead (0x07) proves that messages were delivered.
|
||||
private func resolveAllOutgoingRetries(toPublicKey: String) {
|
||||
let matchingIds = pendingOutgoingPackets
|
||||
.filter { $0.value.toPublicKey == toPublicKey }
|
||||
.map { $0.key }
|
||||
for messageId in matchingIds {
|
||||
Self.logger.info("Resolving retry for \(messageId.prefix(8))… — read receipt received")
|
||||
resolveOutgoingRetry(messageId: messageId)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Push Notifications
|
||||
|
||||
/// Stores the APNs device token received from AppDelegate.
|
||||
@@ -1795,16 +1874,16 @@ final class SessionManager {
|
||||
/// Desktop equivalent: `useUpdateMessage.ts` → `useSendSystemMessage("updates")`.
|
||||
private func sendReleaseNotesIfNeeded(publicKey: String) {
|
||||
let key = "lastReleaseNoticeVersion_\(publicKey)"
|
||||
let lastVersion = UserDefaults.standard.string(forKey: key) ?? ""
|
||||
let currentVersion = ReleaseNotes.appVersion
|
||||
|
||||
guard lastVersion != currentVersion else { return }
|
||||
|
||||
let lastKey = UserDefaults.standard.string(forKey: key) ?? ""
|
||||
// Android parity: version + text hash — re-sends if text changed within same version.
|
||||
let noticeText = ReleaseNotes.releaseNoticeText
|
||||
guard !noticeText.isEmpty else { return }
|
||||
let currentKey = "\(ReleaseNotes.appVersion)_\(noticeText.hashValue)"
|
||||
|
||||
guard lastKey != currentKey else { return }
|
||||
|
||||
let now = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
let messageId = "release_notes_\(currentVersion)"
|
||||
let messageId = "release_notes_\(ReleaseNotes.appVersion)"
|
||||
|
||||
// Create synthetic PacketMessage — local-only, never sent to server.
|
||||
var packet = PacketMessage()
|
||||
@@ -1837,23 +1916,23 @@ final class SessionManager {
|
||||
verified: 1
|
||||
)
|
||||
|
||||
UserDefaults.standard.set(currentVersion, forKey: key)
|
||||
Self.logger.info("Release notes v\(currentVersion) sent to Updates chat")
|
||||
UserDefaults.standard.set(currentKey, forKey: key)
|
||||
Self.logger.info("Release notes v\(ReleaseNotes.appVersion) sent to Updates chat")
|
||||
}
|
||||
|
||||
// MARK: - Idle Detection Setup
|
||||
// MARK: - Foreground Observer (Android parity)
|
||||
|
||||
private func setupIdleDetection() {
|
||||
// Track app going to background/foreground to reset idle state + reconnect.
|
||||
idleObserverToken = NotificationCenter.default.addObserver(
|
||||
private func setupForegroundObserver() {
|
||||
// Android parity: ON_RESUME → markVisibleMessagesAsRead() + reconnect.
|
||||
foregroundObserverToken = NotificationCenter.default.addObserver(
|
||||
forName: UIApplication.willEnterForegroundNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.lastUserInteractionTime = Date()
|
||||
// Android: ON_RESUME calls markVisibleMessagesAsRead() for active dialog.
|
||||
self?.markActiveDialogsAsRead()
|
||||
// Always verify connection on foreground — don't trust cached state.
|
||||
// reconnectIfNeeded() pings to check if "authenticated" connection is alive.
|
||||
ProtocolManager.shared.reconnectIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,13 @@ enum ProfileValidator {
|
||||
enum DisplayNameError: LocalizedError {
|
||||
case empty
|
||||
case tooLong
|
||||
case tooManyWords
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .empty: return "Name cannot be empty"
|
||||
case .tooLong: return "Name cannot exceed 64 characters"
|
||||
case .tooManyWords: return "Maximum two words (first and last name)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,9 +24,28 @@ enum ProfileValidator {
|
||||
let trimmed = name.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.isEmpty { return .empty }
|
||||
if trimmed.count > 64 { return .tooLong }
|
||||
// Maximum 2 words (first name + last name)
|
||||
let words = trimmed.split(whereSeparator: { $0.isWhitespace })
|
||||
if words.count > 2 { return .tooManyWords }
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Sanitizes display name input: collapses multiple spaces,
|
||||
/// prevents more than one space (two words max).
|
||||
static func sanitizeDisplayName(_ name: String) -> String {
|
||||
// Collapse multiple consecutive spaces into one
|
||||
let collapsed = name.replacingOccurrences(
|
||||
of: "\\s{2,}", with: " ", options: .regularExpression
|
||||
)
|
||||
// If already has one space and user tries to add another, block it
|
||||
let spaceCount = collapsed.filter { $0 == " " }.count
|
||||
if spaceCount > 1 {
|
||||
let parts = collapsed.split(separator: " ", maxSplits: 1)
|
||||
return parts.joined(separator: " ")
|
||||
}
|
||||
return collapsed
|
||||
}
|
||||
|
||||
// MARK: - Username
|
||||
|
||||
enum UsernameError: LocalizedError {
|
||||
|
||||
@@ -9,16 +9,15 @@ enum ProtocolConstants {
|
||||
static let maxMessagesLoad = 20
|
||||
|
||||
/// Maximum messages kept in memory per dialog.
|
||||
static let messageMaxCached = 40
|
||||
/// Android: unlimited in SQLite, 500 in-memory cache.
|
||||
/// Desktop: MESSAGE_MAX_LOADED = 40 (but stores all in IndexedDB).
|
||||
/// iOS: keep 200 in memory + persist to disk. Phase 2: SQLite + pagination.
|
||||
static let messageMaxCached = 200
|
||||
|
||||
/// Outgoing message delivery timeout in seconds.
|
||||
/// If a WAITING message is older than this, mark it as ERROR.
|
||||
static let messageDeliveryTimeoutS: Int64 = 80
|
||||
|
||||
/// User idle timeout for marking incoming messages as unread (seconds).
|
||||
/// Desktop: `TIME_TO_INACTIVE_FOR_MESSAGES_UNREAD = 20`.
|
||||
static let idleTimeoutForUnreadS: TimeInterval = 20
|
||||
|
||||
/// Maximum number of file attachments per message.
|
||||
static let maxAttachmentsInMessage = 5
|
||||
|
||||
@@ -41,9 +40,6 @@ enum ProtocolConstants {
|
||||
/// Maximum number of outgoing message retry attempts.
|
||||
static let maxOutgoingRetryAttempts = 3
|
||||
|
||||
/// Read receipt throttle interval in milliseconds.
|
||||
static let readReceiptThrottleMs: Int64 = 400
|
||||
|
||||
/// Typing indicator throttle interval in milliseconds (desktop parity).
|
||||
static let typingThrottleMs: Int64 = 3_000
|
||||
|
||||
|
||||
@@ -11,23 +11,17 @@ enum ReleaseNotes {
|
||||
Entry(
|
||||
version: appVersion,
|
||||
body: """
|
||||
**Синхронизация**
|
||||
- Исправлена критическая ошибка, из-за которой синхронизация могла не запускаться после подключения к серверу
|
||||
- Исправлена ошибка с курсором синхронизации — теперь курсор передаётся без преобразования, как в Desktop
|
||||
- Исправлены ложные непрочитанные сообщения после синхронизации
|
||||
**Уведомления**
|
||||
Вибрация и бейдж работают когда приложение закрыто. Счётчик непрочитанных обновляется в фоне.
|
||||
|
||||
**Мульти-девайс**
|
||||
- Сообщения с другого устройства того же аккаунта теперь корректно показываются со статусом «доставлено»
|
||||
**Фото и файлы**
|
||||
Фото скачиваются только по тапу — блюр-превью со стрелкой загрузки. Пересланные фото подтягиваются из кэша автоматически. Файлы открываются по тапу — скачивание и «Поделиться». Меню вложений (скрепка) работает на iOS 26+.
|
||||
|
||||
**UI (iOS 26)**
|
||||
- Исправлен баг с размытием экрана чата при скролле
|
||||
**Оптимизация производительности**
|
||||
Улучшен FPS скролла и клавиатуры в длинных переписках.
|
||||
|
||||
**Swipe-to-Reply** — свайп влево по сообщению для ответа, как в Telegram
|
||||
**Reply Quote** — обновлённый дизайн цитаты ответа. Если ответ на фото — миниатюра из BlurHash
|
||||
**Навигация по цитате** — тап на цитату скроллит к оригиналу с плавной подсветкой
|
||||
**Коллаж фотографий** — несколько фото в сообщении отображаются в сетке в стиле Telegram
|
||||
**Рамка вокруг фото** — фото обрамлены цветом пузырька с точным совпадением углов
|
||||
**Просмотр фото** — полноэкранный просмотрщик с зумом, перетаскиванием и свайпом вниз для закрытия
|
||||
**Исправления**
|
||||
Убрана рамка у сообщений с аватаром. Saved Messages: иконка закладки вместо аватара. Read receipts: паритет с Android.
|
||||
"""
|
||||
)
|
||||
]
|
||||
|
||||
@@ -26,10 +26,7 @@ struct GlassBackButton: View {
|
||||
.fill(Color.white.opacity(0.08))
|
||||
.glassEffect(.regular, in: .circle)
|
||||
} else {
|
||||
Circle()
|
||||
.fill(.thinMaterial)
|
||||
.overlay { Circle().strokeBorder(Color.white.opacity(0.18), lineWidth: 0.5) }
|
||||
.shadow(color: .black.opacity(0.12), radius: 20, y: 8)
|
||||
TelegramGlassCircle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,12 +40,9 @@ struct GlassCard<Content: View>: View {
|
||||
content()
|
||||
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: cornerRadius))
|
||||
} else {
|
||||
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
content()
|
||||
.background {
|
||||
shape.fill(.thinMaterial)
|
||||
.overlay { shape.strokeBorder(Color.white.opacity(0.10), lineWidth: 0.5) }
|
||||
.shadow(color: .black.opacity(0.10), radius: 16, y: 6)
|
||||
TelegramGlassRoundedRect(cornerRadius: cornerRadius)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import SwiftUI
|
||||
// MARK: - Glass Modifier
|
||||
//
|
||||
// iOS 26+: native .glassEffect API
|
||||
// iOS < 26: .thinMaterial blur + stroke + shadow
|
||||
// iOS < 26: Telegram-style glass (CABackdropLayer + gaussianBlur)
|
||||
|
||||
struct GlassModifier: ViewModifier {
|
||||
let cornerRadius: CGFloat
|
||||
@@ -20,9 +20,7 @@ struct GlassModifier: ViewModifier {
|
||||
} else {
|
||||
content
|
||||
.background {
|
||||
shape.fill(.thinMaterial)
|
||||
.overlay { shape.strokeBorder(Color.white.opacity(0.10), lineWidth: 0.5) }
|
||||
.shadow(color: .black.opacity(0.10), radius: 16, y: 6)
|
||||
TelegramGlassRoundedRect(cornerRadius: cornerRadius)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,9 +44,7 @@ extension View {
|
||||
}
|
||||
} else {
|
||||
background {
|
||||
Capsule().fill(.thinMaterial)
|
||||
.overlay { Capsule().strokeBorder(Color.white.opacity(0.10), lineWidth: 0.5) }
|
||||
.shadow(color: .black.opacity(0.10), radius: 16, y: 6)
|
||||
TelegramGlassCapsule()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,9 +59,7 @@ extension View {
|
||||
}
|
||||
} else {
|
||||
background {
|
||||
Circle().fill(.thinMaterial)
|
||||
.overlay { Circle().strokeBorder(Color.white.opacity(0.10), lineWidth: 0.5) }
|
||||
.shadow(color: .black.opacity(0.10), radius: 16, y: 6)
|
||||
TelegramGlassCircle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
274
Rosetta/DesignSystem/Components/TelegramGlassView.swift
Normal file
274
Rosetta/DesignSystem/Components/TelegramGlassView.swift
Normal file
@@ -0,0 +1,274 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
// MARK: - Environment Key: Glass Active
|
||||
|
||||
/// When false, TelegramGlassUIView removes its CABackdropLayer from the
|
||||
/// layer tree, stopping real-time blur (zero GPU cost). When true, the
|
||||
/// backdrop layer is re-inserted and blur resumes.
|
||||
///
|
||||
/// Usage: set `.environment(\.telegramGlassActive, false)` on views whose
|
||||
/// glass effects should be frozen (e.g. hidden tabs in a ZStack pager).
|
||||
private struct TelegramGlassActiveKey: EnvironmentKey {
|
||||
static let defaultValue: Bool = true
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var telegramGlassActive: Bool {
|
||||
get { self[TelegramGlassActiveKey.self] }
|
||||
set { self[TelegramGlassActiveKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Telegram Glass (CABackdropLayer + CAFilter)
|
||||
//
|
||||
// Exact port of Telegram iOS LegacyGlassView + GlassBackgroundView foreground.
|
||||
// iOS < 26: CABackdropLayer with gaussianBlur radius 2.0 + dark foreground overlay.
|
||||
// iOS 26+: native UIGlassEffect(style: .regular).
|
||||
|
||||
/// SwiftUI wrapper for Telegram-style glass background.
|
||||
/// Capsule shape with proper corner radius.
|
||||
struct TelegramGlassCapsule: UIViewRepresentable {
|
||||
func makeUIView(context: Context) -> TelegramGlassUIView {
|
||||
let view = TelegramGlassUIView(frame: .zero)
|
||||
view.backgroundColor = .clear
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: TelegramGlassUIView, context: Context) {
|
||||
uiView.isFrozen = !context.environment.telegramGlassActive
|
||||
uiView.updateGlass()
|
||||
}
|
||||
}
|
||||
|
||||
/// SwiftUI wrapper for Telegram-style glass circle.
|
||||
struct TelegramGlassCircle: UIViewRepresentable {
|
||||
func makeUIView(context: Context) -> TelegramGlassUIView {
|
||||
let view = TelegramGlassUIView(frame: .zero)
|
||||
view.backgroundColor = .clear
|
||||
view.isCircle = true
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: TelegramGlassUIView, context: Context) {
|
||||
uiView.isFrozen = !context.environment.telegramGlassActive
|
||||
uiView.updateGlass()
|
||||
}
|
||||
}
|
||||
|
||||
/// SwiftUI wrapper for Telegram-style glass with custom corner radius.
|
||||
struct TelegramGlassRoundedRect: UIViewRepresentable {
|
||||
let cornerRadius: CGFloat
|
||||
|
||||
func makeUIView(context: Context) -> TelegramGlassUIView {
|
||||
let view = TelegramGlassUIView(frame: .zero)
|
||||
view.backgroundColor = .clear
|
||||
view.fixedCornerRadius = cornerRadius
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: TelegramGlassUIView, context: Context) {
|
||||
uiView.isFrozen = !context.environment.telegramGlassActive
|
||||
uiView.fixedCornerRadius = cornerRadius
|
||||
uiView.updateGlass()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIKit Implementation
|
||||
|
||||
final class TelegramGlassUIView: UIView {
|
||||
var isCircle = false
|
||||
/// When set, overrides auto-calculated corner radius (height/2 for capsule, min/2 for circle).
|
||||
var fixedCornerRadius: CGFloat?
|
||||
|
||||
/// When true, the CABackdropLayer is removed from the layer tree,
|
||||
/// stopping real-time blur capture. Set to true for views inside
|
||||
/// hidden tabs to eliminate GPU work from invisible glass effects.
|
||||
var isFrozen: Bool = false {
|
||||
didSet {
|
||||
guard isFrozen != oldValue else { return }
|
||||
if isFrozen {
|
||||
backdropLayer?.removeFromSuperlayer()
|
||||
} else if let backdrop = backdropLayer, backdrop.superlayer == nil {
|
||||
clippingContainer.insertSublayer(backdrop, at: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Layers
|
||||
private var backdropLayer: CALayer?
|
||||
private let clippingContainer = CALayer()
|
||||
private let foregroundLayer = CALayer()
|
||||
private let borderLayer = CAShapeLayer()
|
||||
|
||||
// iOS 26+ native glass
|
||||
private var nativeGlassView: UIVisualEffectView?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
clipsToBounds = false
|
||||
|
||||
if #available(iOS 26.0, *) {
|
||||
setupNativeGlass()
|
||||
} else {
|
||||
setupLegacyGlass()
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: - iOS 26+ (native UIGlassEffect)
|
||||
|
||||
@available(iOS 26.0, *)
|
||||
private func setupNativeGlass() {
|
||||
let effect = UIGlassEffect(style: .regular)
|
||||
effect.isInteractive = false
|
||||
// Telegram dark mode tint: UIColor(white: 1.0, alpha: 0.025)
|
||||
effect.tintColor = UIColor(white: 1.0, alpha: 0.025)
|
||||
let glassView = UIVisualEffectView(effect: effect)
|
||||
glassView.layer.cornerCurve = .continuous
|
||||
addSubview(glassView)
|
||||
nativeGlassView = glassView
|
||||
}
|
||||
|
||||
// MARK: - iOS < 26 (CABackdropLayer — Telegram LegacyGlassView)
|
||||
|
||||
private func setupLegacyGlass() {
|
||||
// Clipping container — holds backdrop + foreground, clips to pill shape.
|
||||
// Border is added to main layer OUTSIDE the clip so it's fully visible.
|
||||
clippingContainer.masksToBounds = true
|
||||
clippingContainer.cornerCurve = .circular
|
||||
layer.addSublayer(clippingContainer)
|
||||
|
||||
// 1. CABackdropLayer — blurs content behind this view
|
||||
if let backdrop = Self.createBackdropLayer() {
|
||||
backdrop.rasterizationScale = 1.0
|
||||
Self.setBackdropScale(backdrop, scale: 1.0)
|
||||
|
||||
// gaussianBlur filter with radius 2.0 (Telegram .normal style)
|
||||
if let blurFilter = Self.makeBlurFilter() {
|
||||
blurFilter.setValue(2.0 as NSNumber, forKey: "inputRadius")
|
||||
backdrop.filters = [blurFilter]
|
||||
}
|
||||
|
||||
clippingContainer.addSublayer(backdrop)
|
||||
self.backdropLayer = backdrop
|
||||
}
|
||||
|
||||
// 2. Foreground — dark semi-transparent fill
|
||||
foregroundLayer.backgroundColor = UIColor(white: 0.11, alpha: 0.85).cgColor
|
||||
clippingContainer.addSublayer(foregroundLayer)
|
||||
|
||||
// 3. Border — on main layer, NOT inside clipping container
|
||||
borderLayer.fillColor = UIColor.clear.cgColor
|
||||
borderLayer.strokeColor = UIColor(red: 0x38/255.0, green: 0x38/255.0, blue: 0x38/255.0, alpha: 1.0).cgColor
|
||||
borderLayer.lineWidth = 0.5
|
||||
layer.addSublayer(borderLayer)
|
||||
|
||||
layer.cornerCurve = .circular
|
||||
}
|
||||
|
||||
// MARK: - Layout
|
||||
|
||||
func updateGlass() {
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
let bounds = bounds
|
||||
guard bounds.width > 0, bounds.height > 0 else { return }
|
||||
|
||||
let cornerRadius: CGFloat
|
||||
if let fixed = fixedCornerRadius {
|
||||
cornerRadius = fixed
|
||||
} else if isCircle {
|
||||
cornerRadius = min(bounds.width, bounds.height) / 2
|
||||
} else {
|
||||
cornerRadius = bounds.height / 2
|
||||
}
|
||||
|
||||
if #available(iOS 26.0, *), let glassView = nativeGlassView {
|
||||
glassView.frame = bounds
|
||||
glassView.layer.cornerRadius = cornerRadius
|
||||
return
|
||||
}
|
||||
|
||||
// Legacy layout
|
||||
clippingContainer.frame = bounds
|
||||
clippingContainer.cornerRadius = cornerRadius
|
||||
backdropLayer?.frame = bounds
|
||||
foregroundLayer.frame = bounds
|
||||
foregroundLayer.cornerRadius = cornerRadius
|
||||
|
||||
let halfBorder = borderLayer.lineWidth / 2
|
||||
let borderRect = bounds.insetBy(dx: halfBorder, dy: halfBorder)
|
||||
let borderRadius = max(0, cornerRadius - halfBorder)
|
||||
let borderPath = UIBezierPath(roundedRect: borderRect, cornerRadius: borderRadius)
|
||||
borderLayer.path = borderPath.cgPath
|
||||
borderLayer.frame = bounds
|
||||
}
|
||||
|
||||
// MARK: - Shadow (drawn as separate image — Telegram parity)
|
||||
|
||||
/// Call from parent to add Telegram-exact shadow.
|
||||
/// Telegram: blur 40, color black 4%, offset (0,1), inset 32pt.
|
||||
static func makeShadowImage(cornerRadius: CGFloat) -> UIImage? {
|
||||
let inset: CGFloat = 32
|
||||
let innerInset: CGFloat = 0.5
|
||||
let diameter = cornerRadius * 2
|
||||
let totalSize = CGSize(width: inset * 2 + diameter, height: inset * 2 + diameter)
|
||||
|
||||
let image = UIGraphicsImageRenderer(size: totalSize).image { ctx in
|
||||
let context = ctx.cgContext
|
||||
context.clear(CGRect(origin: .zero, size: totalSize))
|
||||
|
||||
context.setFillColor(UIColor.black.cgColor)
|
||||
context.setShadow(
|
||||
offset: CGSize(width: 0, height: 1),
|
||||
blur: 40.0,
|
||||
color: UIColor(white: 0.0, alpha: 0.04).cgColor
|
||||
)
|
||||
let ellipseRect = CGRect(
|
||||
x: inset + innerInset,
|
||||
y: inset + innerInset,
|
||||
width: totalSize.width - (inset + innerInset) * 2,
|
||||
height: totalSize.height - (inset + innerInset) * 2
|
||||
)
|
||||
context.fillEllipse(in: ellipseRect)
|
||||
|
||||
// Punch out the center (shadow only, no fill)
|
||||
context.setFillColor(UIColor.clear.cgColor)
|
||||
context.setBlendMode(.copy)
|
||||
context.fillEllipse(in: ellipseRect)
|
||||
}
|
||||
return image.stretchableImage(
|
||||
withLeftCapWidth: Int(inset + cornerRadius),
|
||||
topCapHeight: Int(inset + cornerRadius)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Private API Helpers (same approach as Telegram)
|
||||
|
||||
private static func createBackdropLayer() -> CALayer? {
|
||||
let className = ["CA", "Backdrop", "Layer"].joined()
|
||||
guard let cls = NSClassFromString(className) as? CALayer.Type else { return nil }
|
||||
return cls.init()
|
||||
}
|
||||
|
||||
private static func setBackdropScale(_ layer: CALayer, scale: Double) {
|
||||
let sel = NSSelectorFromString("setScale:")
|
||||
guard layer.responds(to: sel) else { return }
|
||||
layer.perform(sel, with: NSNumber(value: scale))
|
||||
}
|
||||
|
||||
private static func makeBlurFilter() -> NSObject? {
|
||||
let className = ["CA", "Filter"].joined()
|
||||
guard let cls = NSClassFromString(className) as? NSObject.Type else { return nil }
|
||||
let sel = NSSelectorFromString("filterWithName:")
|
||||
guard cls.responds(to: sel) else { return nil }
|
||||
return cls.perform(sel, with: "gaussianBlur")?.takeUnretainedValue() as? NSObject
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,10 @@ struct AttachmentPanelView: View {
|
||||
|
||||
let onSend: ([PendingAttachment], String) -> Void
|
||||
let onSendAvatar: () -> Void
|
||||
/// When false, tapping avatar tab offers to set an avatar instead of sending.
|
||||
var hasAvatar: Bool = true
|
||||
/// Called when user has no avatar and taps the avatar tab — navigate to profile.
|
||||
var onSetAvatar: (() -> Void)?
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@@ -190,10 +194,7 @@ struct AttachmentPanelView: View {
|
||||
.fill(Color.white.opacity(0.08))
|
||||
.glassEffect(.regular, in: .circle)
|
||||
} else {
|
||||
Circle()
|
||||
.fill(.thinMaterial)
|
||||
.overlay { Circle().strokeBorder(Color.white.opacity(0.18), lineWidth: 0.5) }
|
||||
.shadow(color: .black.opacity(0.12), radius: 20, y: 8)
|
||||
TelegramGlassCircle()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,10 +325,7 @@ struct AttachmentPanelView: View {
|
||||
.fill(.clear)
|
||||
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: 21, style: .continuous))
|
||||
} else {
|
||||
let shape = RoundedRectangle(cornerRadius: 21, style: .continuous)
|
||||
shape.fill(.thinMaterial)
|
||||
.overlay { shape.strokeBorder(Color.white.opacity(0.18), lineWidth: 0.5) }
|
||||
.shadow(color: .black.opacity(0.12), radius: 20, y: 8)
|
||||
TelegramGlassRoundedRect(cornerRadius: 21)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,13 +356,7 @@ struct AttachmentPanelView: View {
|
||||
.fill(.clear)
|
||||
.glassEffect(.regular, in: .capsule)
|
||||
} else {
|
||||
// iOS < 26 — frosted glass material (matches RosettaTabBar)
|
||||
Capsule()
|
||||
.fill(.regularMaterial)
|
||||
.overlay(
|
||||
Capsule()
|
||||
.strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5)
|
||||
)
|
||||
TelegramGlassCapsule()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,9 +367,15 @@ struct AttachmentPanelView: View {
|
||||
|
||||
return Button {
|
||||
if tab == .avatar {
|
||||
// Avatar is an action tab — immediately sends avatar + dismisses
|
||||
if hasAvatar {
|
||||
onSendAvatar()
|
||||
dismiss()
|
||||
} else {
|
||||
// No avatar set — offer to set one
|
||||
dismiss()
|
||||
onSetAvatar?()
|
||||
}
|
||||
return
|
||||
} else {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
selectedTab = tab
|
||||
@@ -395,13 +393,17 @@ struct AttachmentPanelView: View {
|
||||
.foregroundStyle(isSelected ? Color(hex: 0x008BFF) : .white)
|
||||
.frame(minWidth: 66, maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
// Selected tab: thin material pill (matches RosettaTabBar selection style)
|
||||
isSelected
|
||||
? AnyShapeStyle(.thinMaterial)
|
||||
: AnyShapeStyle(.clear),
|
||||
in: Capsule()
|
||||
)
|
||||
.background {
|
||||
if isSelected {
|
||||
if #available(iOS 26, *) {
|
||||
Capsule()
|
||||
.fill(.clear)
|
||||
.glassEffect(.regular, in: .capsule)
|
||||
} else {
|
||||
TelegramGlassCapsule()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
@@ -24,8 +24,9 @@ struct BubbleContextMenuOverlay: UIViewRepresentable {
|
||||
let previewShape: MessageBubbleShape
|
||||
let readStatusText: String?
|
||||
|
||||
/// Called when user single-taps the bubble (e.g., to open fullscreen image).
|
||||
var onTap: (() -> Void)?
|
||||
/// Called when user single-taps the bubble. Receives tap location in the overlay's
|
||||
/// coordinate space (for determining which sub-element was tapped, e.g., which photo in a collage).
|
||||
var onTap: ((CGPoint) -> Void)?
|
||||
|
||||
/// Height of the reply quote area at the top of the bubble (0 = no reply quote).
|
||||
/// Taps within this region call `onReplyQuoteTap` instead of `onTap`.
|
||||
@@ -41,6 +42,9 @@ struct BubbleContextMenuOverlay: UIViewRepresentable {
|
||||
view.addInteraction(interaction)
|
||||
|
||||
// Single tap recognizer — coexists with context menu's long press.
|
||||
// ALL taps go through this (overlay UIView blocks SwiftUI gestures below).
|
||||
// onTap handler in ChatDetailView routes to image viewer, file share,
|
||||
// or posts .triggerAttachmentDownload notification for downloads.
|
||||
let tap = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap(_:)))
|
||||
view.addGestureRecognizer(tap)
|
||||
|
||||
@@ -62,7 +66,7 @@ struct BubbleContextMenuOverlay: UIViewRepresentable {
|
||||
var actions: [BubbleContextAction]
|
||||
var previewShape: MessageBubbleShape
|
||||
var readStatusText: String?
|
||||
var onTap: (() -> Void)?
|
||||
var onTap: ((CGPoint) -> Void)?
|
||||
var replyQuoteHeight: CGFloat = 0
|
||||
var onReplyQuoteTap: (() -> Void)?
|
||||
private var snapshotView: UIImageView?
|
||||
@@ -85,7 +89,8 @@ struct BubbleContextMenuOverlay: UIViewRepresentable {
|
||||
return
|
||||
}
|
||||
}
|
||||
onTap?()
|
||||
let location = recognizer.location(in: recognizer.view)
|
||||
onTap?(location)
|
||||
}
|
||||
|
||||
func contextMenuInteraction(
|
||||
|
||||
@@ -80,14 +80,15 @@ struct ChatDetailView: View {
|
||||
@State private var firstUnreadMessageId: String?
|
||||
@State private var isSendingAvatar = false
|
||||
@State private var showAttachmentPanel = false
|
||||
@State private var showNoAvatarAlert = false
|
||||
@State private var pendingAttachments: [PendingAttachment] = []
|
||||
@State private var showOpponentProfile = false
|
||||
@State private var replyingToMessage: ChatMessage?
|
||||
@State private var showForwardPicker = false
|
||||
@State private var forwardingMessage: ChatMessage?
|
||||
@State private var messageToDelete: ChatMessage?
|
||||
/// Attachment ID for full-screen image viewer (nil = dismissed).
|
||||
@State private var fullScreenAttachmentId: String?
|
||||
/// State for the multi-photo gallery viewer (nil = dismissed).
|
||||
@State private var imageViewerState: ImageViewerState?
|
||||
/// ID of message to scroll to (set when tapping a reply quote).
|
||||
@State private var scrollToMessageId: String?
|
||||
/// ID of message currently highlighted after scroll-to-reply navigation.
|
||||
@@ -236,8 +237,6 @@ struct ChatDetailView: View {
|
||||
// does NOT mutate DialogRepository, so ForEach won't rebuild.
|
||||
MessageRepository.shared.setDialogActive(route.publicKey, isActive: true)
|
||||
clearDeliveredNotifications(for: route.publicKey)
|
||||
// Reset idle timer — user is actively viewing a chat.
|
||||
SessionManager.shared.recordUserInteraction()
|
||||
// Request user info (non-mutating, won't trigger list rebuild)
|
||||
requestUserInfoIfNeeded()
|
||||
// Delay DialogRepository mutations to let navigation transition complete.
|
||||
@@ -261,6 +260,13 @@ struct ChatDetailView: View {
|
||||
.onDisappear {
|
||||
isViewActive = false
|
||||
firstUnreadMessageId = nil
|
||||
// Android parity: mark all messages as read when leaving dialog.
|
||||
// Android's unmount callback does SQL UPDATE messages SET read = 1.
|
||||
// Don't re-send read receipt — it was already sent during the session.
|
||||
DialogRepository.shared.markAsRead(opponentKey: route.publicKey)
|
||||
MessageRepository.shared.markIncomingAsRead(
|
||||
opponentKey: route.publicKey, myPublicKey: currentPublicKey
|
||||
)
|
||||
MessageRepository.shared.setDialogActive(route.publicKey, isActive: false)
|
||||
// Desktop parity: save draft text on chat close.
|
||||
DraftManager.shared.saveDraft(for: route.publicKey, text: messageText)
|
||||
@@ -281,14 +287,16 @@ struct ChatDetailView: View {
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: Binding(
|
||||
get: { fullScreenAttachmentId != nil },
|
||||
set: { if !$0 { fullScreenAttachmentId = nil } }
|
||||
get: { imageViewerState != nil },
|
||||
set: { if !$0 { imageViewerState = nil } }
|
||||
)) {
|
||||
FullScreenImageFromCache(
|
||||
attachmentId: fullScreenAttachmentId ?? "",
|
||||
onDismiss: { fullScreenAttachmentId = nil }
|
||||
if let state = imageViewerState {
|
||||
ImageGalleryViewer(
|
||||
state: state,
|
||||
onDismiss: { imageViewerState = nil }
|
||||
)
|
||||
}
|
||||
}
|
||||
.alert("Delete Message", isPresented: Binding(
|
||||
get: { messageToDelete != nil },
|
||||
set: { if !$0 { messageToDelete = nil } }
|
||||
@@ -305,6 +313,30 @@ struct ChatDetailView: View {
|
||||
} message: {
|
||||
Text("Are you sure you want to delete this message? This action cannot be undone.")
|
||||
}
|
||||
.alert("No Avatar", isPresented: $showNoAvatarAlert) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Set a profile photo in Settings to share it with contacts.")
|
||||
}
|
||||
.sheet(isPresented: $showAttachmentPanel) {
|
||||
AttachmentPanelView(
|
||||
onSend: { attachments, caption in
|
||||
// Pre-fill caption as message text (sent alongside attachments)
|
||||
let trimmedCaption = caption.trimmingCharacters(in: .whitespaces)
|
||||
if !trimmedCaption.isEmpty {
|
||||
messageText = trimmedCaption
|
||||
}
|
||||
handleAttachmentsSend(attachments)
|
||||
},
|
||||
onSendAvatar: {
|
||||
sendAvatarToChat()
|
||||
},
|
||||
hasAvatar: AvatarRepository.shared.loadAvatar(publicKey: currentPublicKey) != nil,
|
||||
onSetAvatar: {
|
||||
showNoAvatarAlert = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -673,9 +705,10 @@ private extension ChatDetailView {
|
||||
.onAppear { withAnimation(.easeOut(duration: 0.2)) { isAtBottom = true } }
|
||||
.onDisappear { withAnimation(.easeOut(duration: 0.2)) { isAtBottom = false } }
|
||||
|
||||
// PERF: use message.id as ForEach identity (stable).
|
||||
// Integer indices shift on every insert, forcing full diff.
|
||||
ForEach(Array(messages.enumerated()).reversed(), id: \.element.id) { index, message in
|
||||
// PERF: iterate reversed messages directly, avoid Array(enumerated()) allocation.
|
||||
// Use message.id identity (stable) — integer indices shift on insert.
|
||||
ForEach(messages.reversed()) { message in
|
||||
let index = messageIndex(for: message.id)
|
||||
let position = bubblePosition(for: index)
|
||||
messageRow(
|
||||
message,
|
||||
@@ -717,10 +750,14 @@ private extension ChatDetailView {
|
||||
}
|
||||
shouldScrollOnNextMessage = false
|
||||
}
|
||||
// Android parity: markVisibleMessagesAsRead — when new incoming
|
||||
// messages appear while chat is open, mark as read and send receipt.
|
||||
// Safe to call repeatedly: markAsRead guards unreadCount > 0,
|
||||
// sendReadReceipt deduplicates by timestamp.
|
||||
if isViewActive && !lastIsOutgoing
|
||||
&& !route.isSavedMessages && !route.isSystemAccount {
|
||||
markDialogAsRead()
|
||||
}
|
||||
.onChange(of: isInputFocused) { _, focused in
|
||||
guard focused else { return }
|
||||
SessionManager.shared.recordUserInteraction()
|
||||
}
|
||||
// Scroll-to-reply: navigate to the original message and highlight it briefly.
|
||||
.onChange(of: scrollToMessageId) { _, targetId in
|
||||
@@ -828,15 +865,19 @@ private extension ChatDetailView {
|
||||
let messageText = message.text.isEmpty ? " " : message.text
|
||||
let replyAttachment = message.attachments.first(where: { $0.type == .messages })
|
||||
let replyData = replyAttachment.flatMap { parseReplyBlob($0.blob) }?.first
|
||||
// Forward detection: text is empty/space, but has a MESSAGES attachment with data.
|
||||
let isForward = message.text.trimmingCharacters(in: .whitespaces).isEmpty && replyData != nil
|
||||
|
||||
if isForward, let reply = replyData {
|
||||
forwardedMessageBubble(message: message, reply: reply, outgoing: outgoing, hasTail: hasTail, maxBubbleWidth: maxBubbleWidth, position: position)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Reply/forward quote (if present)
|
||||
// Reply quote (if present, not a forward)
|
||||
if let reply = replyData {
|
||||
replyQuoteView(reply: reply, outgoing: outgoing)
|
||||
}
|
||||
|
||||
// Telegram-style compact bubble: inline time+status at bottom-trailing.
|
||||
// Right padding reserves space for the timestamp overlay (64pt outgoing, 48pt incoming).
|
||||
Text(parsedMarkdown(messageText))
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
.tracking(-0.43)
|
||||
@@ -852,10 +893,8 @@ private extension ChatDetailView {
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
timestampOverlay(message: message, outgoing: outgoing)
|
||||
}
|
||||
// Tail protrusion space: the unified shape draws the tail in this padding area
|
||||
.padding(.trailing, outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
|
||||
.padding(.leading, !outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
|
||||
// Single unified background: body + tail drawn in one fill (no seam)
|
||||
.background { bubbleBackground(outgoing: outgoing, position: position) }
|
||||
.overlay {
|
||||
BubbleContextMenuOverlay(
|
||||
@@ -870,6 +909,161 @@ private extension ChatDetailView {
|
||||
}
|
||||
.frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Forwarded Message Bubble (Telegram-style)
|
||||
|
||||
/// Renders a forwarded message with "Forwarded from" header, small avatar, sender name,
|
||||
/// optional image/file previews, and the forwarded text as the main bubble content.
|
||||
/// Android parity: `ForwardedMessagesBubble` + `ForwardedImagePreview` in ChatDetailComponents.kt.
|
||||
@ViewBuilder
|
||||
private func forwardedMessageBubble(
|
||||
message: ChatMessage,
|
||||
reply: ReplyMessageData,
|
||||
outgoing: Bool,
|
||||
hasTail: Bool,
|
||||
maxBubbleWidth: CGFloat,
|
||||
position: BubblePosition
|
||||
) -> some View {
|
||||
let senderName = senderDisplayName(for: reply.publicKey)
|
||||
let senderInitials = RosettaColors.initials(name: senderName, publicKey: reply.publicKey)
|
||||
let senderColorIndex = RosettaColors.avatarColorIndex(for: senderName, publicKey: reply.publicKey)
|
||||
let senderAvatar = AvatarRepository.shared.loadAvatar(publicKey: reply.publicKey)
|
||||
|
||||
// Categorize forwarded attachments (inside the ReplyMessageData, NOT on message itself).
|
||||
let imageAttachments = reply.attachments.filter { $0.type == 0 }
|
||||
let fileAttachments = reply.attachments.filter { $0.type == 2 }
|
||||
let hasVisualAttachments = !imageAttachments.isEmpty || !fileAttachments.isEmpty
|
||||
|
||||
// Text: show as caption below visual attachments, or as main content if no attachments.
|
||||
let hasCaption = !reply.message.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
|
||||
// Fallback label when no visual attachments and no text.
|
||||
let fallbackText: String = {
|
||||
if hasCaption { return reply.message }
|
||||
if !imageAttachments.isEmpty { return hasVisualAttachments ? "" : "Photo" }
|
||||
if let file = fileAttachments.first { return file.id.isEmpty ? "File" : file.id }
|
||||
if reply.attachments.contains(where: { $0.type == 3 }) { return "Avatar" }
|
||||
return " "
|
||||
}()
|
||||
|
||||
let imageContentWidth = maxBubbleWidth - 22 - (outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0) - (!outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// "Forwarded from" label
|
||||
Text("Forwarded from")
|
||||
.font(.system(size: 13, weight: .regular))
|
||||
.foregroundStyle(outgoing ? Color.white.opacity(0.5) : RosettaColors.Adaptive.textSecondary)
|
||||
.padding(.leading, 11)
|
||||
.padding(.top, 6)
|
||||
|
||||
// Avatar + sender name
|
||||
HStack(spacing: 6) {
|
||||
AvatarView(
|
||||
initials: senderInitials,
|
||||
colorIndex: senderColorIndex,
|
||||
size: 20,
|
||||
image: senderAvatar
|
||||
)
|
||||
Text(senderName)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(outgoing ? Color.white : RosettaColors.figmaBlue)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.leading, 11)
|
||||
.padding(.top, 3)
|
||||
|
||||
// Forwarded image attachments — blurhash thumbnails (Android parity: ForwardedImagePreview).
|
||||
ForEach(Array(imageAttachments.enumerated()), id: \.element.id) { _, att in
|
||||
forwardedImagePreview(attachment: att, width: imageContentWidth, outgoing: outgoing)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
// Forwarded file attachments.
|
||||
ForEach(Array(fileAttachments.enumerated()), id: \.element.id) { _, att in
|
||||
forwardedFilePreview(attachment: att, outgoing: outgoing)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
// Caption text (if original message had text) or fallback label.
|
||||
if hasCaption {
|
||||
Text(parsedMarkdown(reply.message))
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
.tracking(-0.43)
|
||||
.foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineSpacing(0)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.leading, 11)
|
||||
.padding(.trailing, outgoing ? 64 : 48)
|
||||
.padding(.top, 3)
|
||||
.padding(.bottom, 5)
|
||||
} else if !hasVisualAttachments {
|
||||
// No attachments and no text — show fallback.
|
||||
Text(fallbackText)
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
.tracking(-0.43)
|
||||
.foregroundStyle(outgoing ? Color.white.opacity(0.7) : RosettaColors.Adaptive.textSecondary)
|
||||
.padding(.leading, 11)
|
||||
.padding(.trailing, outgoing ? 64 : 48)
|
||||
.padding(.top, 3)
|
||||
.padding(.bottom, 5)
|
||||
} else {
|
||||
// Visual attachments shown but no caption — just add bottom padding for timestamp.
|
||||
Spacer().frame(height: 5)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: outgoing ? 86 : 66, alignment: .leading)
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
timestampOverlay(message: message, outgoing: outgoing)
|
||||
}
|
||||
.padding(.trailing, outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
|
||||
.padding(.leading, !outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
|
||||
.background { bubbleBackground(outgoing: outgoing, position: position) }
|
||||
.overlay {
|
||||
BubbleContextMenuOverlay(
|
||||
actions: bubbleActions(for: message),
|
||||
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
|
||||
readStatusText: contextMenuReadStatus(for: message)
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading)
|
||||
}
|
||||
|
||||
/// Wrapper that delegates to `ForwardedImagePreviewCell` — a proper View struct with
|
||||
/// `@State` so the image updates when `AttachmentCache` is populated by `MessageImageView`.
|
||||
@ViewBuilder
|
||||
private func forwardedImagePreview(attachment: ReplyAttachmentData, width: CGFloat, outgoing: Bool) -> some View {
|
||||
ForwardedImagePreviewCell(
|
||||
attachment: attachment,
|
||||
width: width,
|
||||
outgoing: outgoing,
|
||||
onTapCachedImage: { openImageViewer(attachmentId: attachment.id) }
|
||||
)
|
||||
}
|
||||
|
||||
/// File attachment preview inside a forwarded message bubble.
|
||||
@ViewBuilder
|
||||
private func forwardedFilePreview(attachment: ReplyAttachmentData, outgoing: Bool) -> some View {
|
||||
let filename = attachment.id.isEmpty ? "File" : attachment.id
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "doc.fill")
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(outgoing ? Color.white.opacity(0.7) : RosettaColors.figmaBlue)
|
||||
Text(filename)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(outgoing ? Color.white.opacity(0.08) : Color.white.opacity(0.06))
|
||||
)
|
||||
}
|
||||
|
||||
/// PERF: static cache for decoded reply blobs — avoids JSON decode on every re-render.
|
||||
@MainActor private static var replyBlobCache: [String: [ReplyMessageData]] = [:]
|
||||
@@ -880,7 +1074,10 @@ private extension ChatDetailView {
|
||||
if let cached = Self.replyBlobCache[blob] { return cached }
|
||||
guard let data = blob.data(using: .utf8) else { return nil }
|
||||
guard let result = try? JSONDecoder().decode([ReplyMessageData].self, from: data) else { return nil }
|
||||
if Self.replyBlobCache.count > 200 { Self.replyBlobCache.removeAll(keepingCapacity: true) }
|
||||
if Self.replyBlobCache.count > 300 {
|
||||
let keysToRemove = Array(Self.replyBlobCache.keys.prefix(150))
|
||||
for key in keysToRemove { Self.replyBlobCache.removeValue(forKey: key) }
|
||||
}
|
||||
Self.replyBlobCache[blob] = result
|
||||
return result
|
||||
}
|
||||
@@ -910,15 +1107,11 @@ private extension ChatDetailView {
|
||||
.frame(width: 3)
|
||||
.padding(.vertical, 4)
|
||||
|
||||
// Optional image thumbnail for media replies (32×32)
|
||||
// PERF: uses static cache — BlurHash decode is expensive (DCT transform).
|
||||
if let hash = blurHash,
|
||||
let image = Self.cachedBlurHash(hash, width: 32, height: 32) {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 32, height: 32)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
// Optional image thumbnail for media replies (32×32).
|
||||
// Uses ReplyQuoteThumbnail struct with @State + .task to check AttachmentCache
|
||||
// first (shows actual image), falling back to blurhash if not cached.
|
||||
if let att = imageAttachment {
|
||||
ReplyQuoteThumbnail(attachment: att, blurHash: blurHash)
|
||||
.padding(.leading, 6)
|
||||
}
|
||||
|
||||
@@ -948,9 +1141,14 @@ private extension ChatDetailView {
|
||||
.padding(.bottom, 0)
|
||||
}
|
||||
|
||||
/// PERF: static cache for sender display names — avoids DialogRepository read per cell render.
|
||||
/// DialogRepository is @Observable; reading `.dialogs[key]` in the body path creates observation
|
||||
/// on the entire dictionary, causing re-render cascades on any dialog mutation.
|
||||
@MainActor private static var senderNameCache: [String: String] = [:]
|
||||
|
||||
/// Resolves a public key to a display name for reply/forward quotes.
|
||||
/// PERF: avoids reading DialogRepository.shared.dialogs (Observable) in the
|
||||
/// body path — uses route data instead. Only the current opponent is resolved.
|
||||
/// Checks: current user → "You", current opponent → route.title, any known dialog → title (cached).
|
||||
/// Falls back to truncated public key if unknown.
|
||||
private func senderDisplayName(for publicKey: String) -> String {
|
||||
if publicKey == currentPublicKey {
|
||||
return "You"
|
||||
@@ -959,7 +1157,33 @@ private extension ChatDetailView {
|
||||
if publicKey == route.publicKey {
|
||||
return route.title.isEmpty ? String(publicKey.prefix(8)) + "…" : route.title
|
||||
}
|
||||
return String(publicKey.prefix(8)) + "…"
|
||||
// PERF: cached lookup — avoids creating @Observable tracking on DialogRepository.dialogs
|
||||
// in the per-cell render path. Cache is populated once per contact, valid for session.
|
||||
if let cached = Self.senderNameCache[publicKey] {
|
||||
return cached
|
||||
}
|
||||
if let dialog = DialogRepository.shared.dialogs[publicKey],
|
||||
!dialog.opponentTitle.isEmpty {
|
||||
Self.senderNameCache[publicKey] = dialog.opponentTitle
|
||||
return dialog.opponentTitle
|
||||
}
|
||||
let fallback = String(publicKey.prefix(8)) + "…"
|
||||
Self.senderNameCache[publicKey] = fallback
|
||||
return fallback
|
||||
}
|
||||
|
||||
/// PERF: single-pass partition of attachments into image vs non-image.
|
||||
/// Avoids 3 separate .filter() calls per cell in @ViewBuilder context.
|
||||
private static func partitionAttachments(
|
||||
_ attachments: [MessageAttachment]
|
||||
) -> (images: [MessageAttachment], others: [MessageAttachment]) {
|
||||
var images: [MessageAttachment] = []
|
||||
var others: [MessageAttachment] = []
|
||||
for att in attachments {
|
||||
if att.type == .image { images.append(att) }
|
||||
else { others.append(att) }
|
||||
}
|
||||
return (images, others)
|
||||
}
|
||||
|
||||
/// Attachment message bubble: images/files with optional text caption.
|
||||
@@ -978,8 +1202,10 @@ private extension ChatDetailView {
|
||||
position: BubblePosition
|
||||
) -> some View {
|
||||
let hasCaption = !message.text.trimmingCharacters(in: .whitespaces).isEmpty && message.text != " "
|
||||
let imageAttachments = attachments.filter { $0.type == .image }
|
||||
let otherAttachments = attachments.filter { $0.type != .image }
|
||||
// PERF: single-pass partition instead of 3 separate .filter() calls per cell.
|
||||
let partitioned = Self.partitionAttachments(attachments)
|
||||
let imageAttachments = partitioned.images
|
||||
let otherAttachments = partitioned.others
|
||||
let isImageOnly = !imageAttachments.isEmpty && otherAttachments.isEmpty && !hasCaption
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
@@ -1051,10 +1277,24 @@ private extension ChatDetailView {
|
||||
actions: bubbleActions(for: message),
|
||||
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
|
||||
readStatusText: contextMenuReadStatus(for: message),
|
||||
onTap: !imageAttachments.isEmpty ? {
|
||||
// Open the first image attachment in fullscreen viewer
|
||||
if let firstImage = imageAttachments.first {
|
||||
fullScreenAttachmentId = firstImage.id
|
||||
onTap: !attachments.isEmpty ? { tapLocation in
|
||||
// All taps go through the overlay (UIView blocks SwiftUI below).
|
||||
// Route to the correct handler based on what was tapped.
|
||||
if !imageAttachments.isEmpty {
|
||||
let tappedId = imageAttachments.count == 1
|
||||
? imageAttachments[0].id
|
||||
: collageAttachmentId(at: tapLocation, attachments: imageAttachments, maxWidth: maxBubbleWidth)
|
||||
if AttachmentCache.shared.loadImage(forAttachmentId: tappedId) != nil {
|
||||
openImageViewer(attachmentId: tappedId)
|
||||
} else {
|
||||
// Image not cached — trigger download via notification.
|
||||
NotificationCenter.default.post(name: .triggerAttachmentDownload, object: tappedId)
|
||||
}
|
||||
} else {
|
||||
// No images — tap is on file/avatar area.
|
||||
for att in otherAttachments {
|
||||
NotificationCenter.default.post(name: .triggerAttachmentDownload, object: att.id)
|
||||
}
|
||||
}
|
||||
} : nil
|
||||
)
|
||||
@@ -1117,11 +1357,14 @@ private extension ChatDetailView {
|
||||
@MainActor private static var blurHashCache: [String: UIImage] = [:]
|
||||
|
||||
@MainActor
|
||||
private static func cachedBlurHash(_ hash: String, width: Int, height: Int) -> UIImage? {
|
||||
static func cachedBlurHash(_ hash: String, width: Int, height: Int) -> UIImage? {
|
||||
let key = "\(hash)_\(width)x\(height)"
|
||||
if let cached = blurHashCache[key] { return cached }
|
||||
guard let image = UIImage.fromBlurHash(hash, width: width, height: height) else { return nil }
|
||||
if blurHashCache.count > 100 { blurHashCache.removeAll(keepingCapacity: true) }
|
||||
if blurHashCache.count > 300 {
|
||||
let keysToRemove = Array(blurHashCache.keys.prefix(150))
|
||||
for key in keysToRemove { blurHashCache.removeValue(forKey: key) }
|
||||
}
|
||||
blurHashCache[key] = image
|
||||
return image
|
||||
}
|
||||
@@ -1155,8 +1398,10 @@ private extension ChatDetailView {
|
||||
} else {
|
||||
result = AttributedString(withEmoji)
|
||||
}
|
||||
if Self.markdownCache.count > 200 {
|
||||
Self.markdownCache.removeAll(keepingCapacity: true)
|
||||
// PERF: evict oldest half instead of clearing all — preserves hot entries during scroll.
|
||||
if Self.markdownCache.count > 500 {
|
||||
let keysToRemove = Array(Self.markdownCache.keys.prefix(250))
|
||||
for key in keysToRemove { Self.markdownCache.removeValue(forKey: key) }
|
||||
}
|
||||
Self.markdownCache[text] = result
|
||||
return result
|
||||
@@ -1180,11 +1425,6 @@ private extension ChatDetailView {
|
||||
|
||||
var composer: some View {
|
||||
VStack(spacing: 6) {
|
||||
// Reply preview bar (Telegram-style)
|
||||
if let replyMessage = replyingToMessage {
|
||||
replyBar(for: replyMessage)
|
||||
}
|
||||
|
||||
// Attachment preview strip — shows selected images/files before send
|
||||
if !pendingAttachments.isEmpty {
|
||||
AttachmentPreviewStrip(pendingAttachments: $pendingAttachments)
|
||||
@@ -1214,20 +1454,11 @@ private extension ChatDetailView {
|
||||
}
|
||||
.accessibilityLabel("Attach")
|
||||
.buttonStyle(ChatDetailGlassPressButtonStyle())
|
||||
.sheet(isPresented: $showAttachmentPanel) {
|
||||
AttachmentPanelView(
|
||||
onSend: { attachments, caption in
|
||||
// Pre-fill caption as message text (sent alongside attachments)
|
||||
let trimmedCaption = caption.trimmingCharacters(in: .whitespaces)
|
||||
if !trimmedCaption.isEmpty {
|
||||
messageText = trimmedCaption
|
||||
}
|
||||
handleAttachmentsSend(attachments)
|
||||
},
|
||||
onSendAvatar: {
|
||||
sendAvatarToChat()
|
||||
}
|
||||
)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Reply preview bar — inside the glass container
|
||||
if let replyMessage = replyingToMessage {
|
||||
replyBar(for: replyMessage)
|
||||
}
|
||||
|
||||
HStack(alignment: .bottom, spacing: 0) {
|
||||
@@ -1289,6 +1520,7 @@ private extension ChatDetailView {
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(3)
|
||||
.frame(minHeight: 42, alignment: .bottom)
|
||||
.background { glass(shape: .rounded(21), strokeOpacity: 0.18) }
|
||||
@@ -1328,6 +1560,26 @@ private extension ChatDetailView {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Message Index Lookup
|
||||
|
||||
/// PERF: O(1) index lookup via cached dictionary. Rebuilt lazily when messages change.
|
||||
/// Avoids O(n) `firstIndex(where:)` per cell in reversed ForEach.
|
||||
@MainActor private static var messageIndexCache: [String: Int] = [:]
|
||||
@MainActor private static var messageIndexCacheKey: String = ""
|
||||
|
||||
private func messageIndex(for messageId: String) -> Int {
|
||||
// Rebuild cache if messages array changed (first+last+count fingerprint).
|
||||
let cacheKey = "\(messages.count)_\(messages.first?.id ?? "")_\(messages.last?.id ?? "")"
|
||||
if Self.messageIndexCacheKey != cacheKey {
|
||||
Self.messageIndexCache.removeAll(keepingCapacity: true)
|
||||
for (i, msg) in messages.enumerated() {
|
||||
Self.messageIndexCache[msg.id] = i
|
||||
}
|
||||
Self.messageIndexCacheKey = cacheKey
|
||||
}
|
||||
return Self.messageIndexCache[messageId] ?? 0
|
||||
}
|
||||
|
||||
// MARK: - Bubble Position (Figma: Single / Top / Mid / Bottom)
|
||||
|
||||
/// Determines bubble position within a group of consecutive same-sender plain-text messages.
|
||||
@@ -1391,21 +1643,14 @@ private extension ChatDetailView {
|
||||
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: radius, style: .continuous))
|
||||
}
|
||||
} else {
|
||||
// iOS < 26: frosted glass with stroke + shadow (Figma spec)
|
||||
// iOS < 26: Telegram glass (CABackdropLayer + blur 2.0 + dark foreground)
|
||||
switch shape {
|
||||
case .capsule:
|
||||
Capsule().fill(.thinMaterial)
|
||||
.overlay { Capsule().strokeBorder(strokeColor.opacity(strokeOpacity), lineWidth: 0.5) }
|
||||
.shadow(color: .black.opacity(0.12), radius: 20, y: 8)
|
||||
TelegramGlassCapsule()
|
||||
case .circle:
|
||||
Circle().fill(.thinMaterial)
|
||||
.overlay { Circle().strokeBorder(strokeColor.opacity(strokeOpacity), lineWidth: 0.5) }
|
||||
.shadow(color: .black.opacity(0.12), radius: 20, y: 8)
|
||||
TelegramGlassCircle()
|
||||
case let .rounded(radius):
|
||||
let r = RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||
r.fill(.thinMaterial)
|
||||
.overlay { r.strokeBorder(strokeColor.opacity(strokeOpacity), lineWidth: 0.5) }
|
||||
.shadow(color: .black.opacity(0.12), radius: 20, y: 8)
|
||||
TelegramGlassRoundedRect(cornerRadius: radius)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1642,6 +1887,65 @@ private extension ChatDetailView {
|
||||
return actions
|
||||
}
|
||||
|
||||
/// Determines which attachment was tapped in a photo collage based on tap location.
|
||||
/// Mirrors the layout logic in PhotoCollageView (spacing=2, same proportions).
|
||||
func collageAttachmentId(at point: CGPoint, attachments: [MessageAttachment], maxWidth: CGFloat) -> String {
|
||||
let spacing: CGFloat = 2
|
||||
let count = attachments.count
|
||||
let x = point.x
|
||||
let y = point.y
|
||||
|
||||
switch count {
|
||||
case 2:
|
||||
let half = (maxWidth - spacing) / 2
|
||||
return attachments[x < half ? 0 : 1].id
|
||||
|
||||
case 3:
|
||||
let rightWidth = maxWidth * 0.34
|
||||
let leftWidth = maxWidth - spacing - rightWidth
|
||||
let totalHeight = min(leftWidth * 1.1, 300)
|
||||
let rightCellHeight = (totalHeight - spacing) / 2
|
||||
if x < leftWidth {
|
||||
return attachments[0].id
|
||||
} else {
|
||||
return attachments[y < rightCellHeight ? 1 : 2].id
|
||||
}
|
||||
|
||||
case 4:
|
||||
let half = (maxWidth - spacing) / 2
|
||||
let cellHeight = min(half * 0.85, 150)
|
||||
let row = y < cellHeight ? 0 : 1
|
||||
let col = x < half ? 0 : 1
|
||||
return attachments[row * 2 + col].id
|
||||
|
||||
case 5:
|
||||
let topCellWidth = (maxWidth - spacing) / 2
|
||||
let bottomCellWidth = (maxWidth - spacing * 2) / 3
|
||||
let topHeight = min(topCellWidth * 0.85, 165)
|
||||
if y < topHeight {
|
||||
return attachments[x < topCellWidth ? 0 : 1].id
|
||||
} else {
|
||||
let col = min(Int(x / (bottomCellWidth + spacing)), 2)
|
||||
return attachments[2 + col].id
|
||||
}
|
||||
|
||||
default:
|
||||
return attachments[0].id
|
||||
}
|
||||
}
|
||||
|
||||
/// Collects all image attachment IDs from the current chat and opens the gallery.
|
||||
func openImageViewer(attachmentId: String) {
|
||||
var allImageIds: [String] = []
|
||||
for message in messages {
|
||||
for attachment in message.attachments where attachment.type == .image {
|
||||
allImageIds.append(attachment.id)
|
||||
}
|
||||
}
|
||||
let index = allImageIds.firstIndex(of: attachmentId) ?? 0
|
||||
imageViewerState = ImageViewerState(attachmentIds: allImageIds, initialIndex: index)
|
||||
}
|
||||
|
||||
func retryMessage(_ message: ChatMessage) {
|
||||
let text = message.text
|
||||
let toKey = message.toPublicKey
|
||||
@@ -1661,26 +1965,44 @@ private extension ChatDetailView {
|
||||
|
||||
@ViewBuilder
|
||||
func replyBar(for message: ChatMessage) -> some View {
|
||||
// PERF: use route.title (non-observable) instead of dialog?.opponentTitle.
|
||||
// Reading `dialog` here creates @Observable tracking on DialogRepository in the
|
||||
// composer's render path, which is part of the main body.
|
||||
let senderName = message.isFromMe(myPublicKey: currentPublicKey)
|
||||
? "You"
|
||||
: (dialog?.opponentTitle ?? route.title)
|
||||
let previewText = message.text.isEmpty
|
||||
? (message.attachments.isEmpty ? "" : "Attachment")
|
||||
: message.text
|
||||
: (route.title.isEmpty ? String(route.publicKey.prefix(8)) + "…" : route.title)
|
||||
let previewText: String = {
|
||||
let trimmed = message.text.trimmingCharacters(in: .whitespaces)
|
||||
if !trimmed.isEmpty { return message.text }
|
||||
if message.attachments.contains(where: { $0.type == .image }) { return "Photo" }
|
||||
if let file = message.attachments.first(where: { $0.type == .file }) {
|
||||
return file.id.isEmpty ? "File" : file.id
|
||||
}
|
||||
if message.attachments.contains(where: { $0.type == .avatar }) { return "Avatar" }
|
||||
if message.attachments.contains(where: { $0.type == .messages }) { return "Forwarded message" }
|
||||
if !message.attachments.isEmpty { return "Attachment" }
|
||||
return ""
|
||||
}()
|
||||
|
||||
HStack(spacing: 0) {
|
||||
RoundedRectangle(cornerRadius: 1.5)
|
||||
.fill(RosettaColors.figmaBlue)
|
||||
.frame(width: 3, height: 36)
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 0) {
|
||||
Text("Reply to ")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
Text(senderName)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.figmaBlue)
|
||||
}
|
||||
.lineLimit(1)
|
||||
|
||||
Text(previewText)
|
||||
.font(.system(size: 14, weight: .regular))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.leading, 8)
|
||||
@@ -1693,12 +2015,15 @@ private extension ChatDetailView {
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.frame(width: 30, height: 30)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.leading, 6)
|
||||
.padding(.trailing, 4)
|
||||
.padding(.top, 6)
|
||||
.padding(.bottom, 4)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
|
||||
@@ -1755,7 +2080,11 @@ private extension ChatDetailView {
|
||||
func messageTime(_ timestamp: Int64) -> String {
|
||||
if let cached = Self.timeCache[timestamp] { return cached }
|
||||
let result = Self.timeFormatter.string(from: Date(timeIntervalSince1970: Double(timestamp) / 1000))
|
||||
if Self.timeCache.count > 200 { Self.timeCache.removeAll(keepingCapacity: true) }
|
||||
// PERF: evict half instead of clearing all — timestamps are reused during scroll.
|
||||
if Self.timeCache.count > 500 {
|
||||
let keysToRemove = Array(Self.timeCache.keys.prefix(250))
|
||||
for key in keysToRemove { Self.timeCache.removeValue(forKey: key) }
|
||||
}
|
||||
Self.timeCache[timestamp] = result
|
||||
return result
|
||||
}
|
||||
@@ -1823,8 +2152,6 @@ private extension ChatDetailView {
|
||||
// Must have either text or attachments
|
||||
guard !message.isEmpty || !attachments.isEmpty else { return }
|
||||
|
||||
// User is sending a message — reset idle timer.
|
||||
SessionManager.shared.recordUserInteraction()
|
||||
shouldScrollOnNextMessage = true
|
||||
messageText = ""
|
||||
pendingAttachments = []
|
||||
@@ -2030,6 +2357,137 @@ private struct DisableScrollEdgeEffectModifier: ViewModifier {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ForwardedImagePreviewCell
|
||||
|
||||
/// Proper View struct for forwarded image previews — uses `@State` + `.task` so the image
|
||||
/// updates when `AttachmentCache` is populated by `MessageImageView` downloading the original.
|
||||
/// Without this, a plain `let cachedImage = ...` in the parent body is a one-shot evaluation
|
||||
/// that never re-checks the cache.
|
||||
private struct ForwardedImagePreviewCell: View {
|
||||
let attachment: ReplyAttachmentData
|
||||
let width: CGFloat
|
||||
let outgoing: Bool
|
||||
let onTapCachedImage: () -> Void
|
||||
|
||||
@State private var cachedImage: UIImage?
|
||||
@State private var blurImage: UIImage?
|
||||
|
||||
private var imageHeight: CGFloat { min(width * 0.75, 200) }
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let image = cachedImage {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: width, height: imageHeight)
|
||||
.clipped()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { onTapCachedImage() }
|
||||
} else if let blur = blurImage {
|
||||
Image(uiImage: blur)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: width, height: imageHeight)
|
||||
.clipped()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
} else {
|
||||
// No image at all — show placeholder.
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(outgoing ? Color.white.opacity(0.08) : Color.white.opacity(0.06))
|
||||
.frame(width: width, height: imageHeight)
|
||||
.overlay {
|
||||
Image(systemName: "photo")
|
||||
.font(.system(size: 24))
|
||||
.foregroundStyle(
|
||||
outgoing
|
||||
? Color.white.opacity(0.3)
|
||||
: RosettaColors.Adaptive.textSecondary.opacity(0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
// Decode blurhash from preview field ("cdnTag::blurhash" or just "blurhash").
|
||||
if let hash = extractBlurHash(), !hash.isEmpty {
|
||||
blurImage = ChatDetailView.cachedBlurHash(hash, width: 64, height: 64)
|
||||
}
|
||||
|
||||
// Check cache immediately — image may already be there.
|
||||
if let img = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
|
||||
cachedImage = img
|
||||
return
|
||||
}
|
||||
|
||||
// Retry: the original MessageImageView may still be downloading.
|
||||
// Poll up to 5 times with 500ms intervals (2.5s total) — covers most download durations.
|
||||
for _ in 0..<5 {
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
if Task.isCancelled { return }
|
||||
if let img = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
|
||||
cachedImage = img
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func extractBlurHash() -> String? {
|
||||
guard !attachment.preview.isEmpty else { return nil }
|
||||
let parts = attachment.preview.components(separatedBy: "::")
|
||||
let hash = parts.count > 1 ? parts[1] : attachment.preview
|
||||
return hash.isEmpty ? nil : hash
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ReplyQuoteThumbnail
|
||||
|
||||
/// 32×32 thumbnail for reply quote views. Checks `AttachmentCache` first for the actual
|
||||
/// downloaded image, falling back to blurhash. Uses `@State` + `.task` with retry polling
|
||||
/// so the thumbnail updates when the original `MessageImageView` finishes downloading.
|
||||
private struct ReplyQuoteThumbnail: View {
|
||||
let attachment: ReplyAttachmentData
|
||||
let blurHash: String?
|
||||
|
||||
/// Actual cached image (from AttachmentCache). Overrides blurhash when available.
|
||||
@State private var cachedImage: UIImage?
|
||||
|
||||
var body: some View {
|
||||
// Blurhash is computed synchronously (static cache) so it shows on the first frame.
|
||||
// cachedImage overrides it when the real photo is available in AttachmentCache.
|
||||
let image = cachedImage ?? blurHash.flatMap {
|
||||
ChatDetailView.cachedBlurHash($0, width: 32, height: 32)
|
||||
}
|
||||
|
||||
Group {
|
||||
if let image {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 32, height: 32)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
}
|
||||
}
|
||||
.task {
|
||||
// Check AttachmentCache for the actual downloaded photo.
|
||||
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
|
||||
cachedImage = cached
|
||||
return
|
||||
}
|
||||
// Retry — image may be downloading in MessageImageView.
|
||||
for _ in 0..<5 {
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
if Task.isCancelled { return }
|
||||
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
|
||||
cachedImage = cached
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
|
||||
@@ -6,9 +6,11 @@ struct ForwardChatPickerView: View {
|
||||
let onSelect: (ChatRoute) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
/// Android parity: system accounts (Updates, Safe) excluded from forward picker.
|
||||
/// Saved Messages allowed (forward to self).
|
||||
private var dialogs: [Dialog] {
|
||||
DialogRepository.shared.sortedDialogs.filter {
|
||||
$0.iHaveSent || $0.isSavedMessages || SystemAccounts.isSystemAccount($0.opponentKey)
|
||||
($0.iHaveSent || $0.isSavedMessages) && !SystemAccounts.isSystemAccount($0.opponentKey)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
169
Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift
Normal file
169
Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift
Normal file
@@ -0,0 +1,169 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import Photos
|
||||
|
||||
// MARK: - Data Types
|
||||
|
||||
/// State for the image gallery viewer.
|
||||
struct ImageViewerState: Equatable {
|
||||
let attachmentIds: [String]
|
||||
let initialIndex: Int
|
||||
}
|
||||
|
||||
// MARK: - ImageGalleryViewer
|
||||
|
||||
/// Telegram-style multi-photo gallery viewer with horizontal paging.
|
||||
/// Android parity: `ImageViewerScreen.kt` — HorizontalPager, zoom-to-point,
|
||||
/// velocity dismiss, page counter, share/save.
|
||||
struct ImageGalleryViewer: View {
|
||||
|
||||
let state: ImageViewerState
|
||||
let onDismiss: () -> Void
|
||||
|
||||
@State private var currentPage: Int
|
||||
@State private var showControls = true
|
||||
@State private var currentZoomScale: CGFloat = 1.0
|
||||
|
||||
init(state: ImageViewerState, onDismiss: @escaping () -> Void) {
|
||||
self.state = state
|
||||
self.onDismiss = onDismiss
|
||||
self._currentPage = State(initialValue: state.initialIndex)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
|
||||
// Pager
|
||||
TabView(selection: $currentPage) {
|
||||
ForEach(Array(state.attachmentIds.enumerated()), id: \.element) { index, attachmentId in
|
||||
ZoomableImagePage(
|
||||
attachmentId: attachmentId,
|
||||
onDismiss: onDismiss,
|
||||
showControls: $showControls,
|
||||
currentScale: $currentZoomScale
|
||||
)
|
||||
.tag(index)
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
.disabled(currentZoomScale > 1.05)
|
||||
|
||||
// Controls overlay
|
||||
if showControls {
|
||||
controlsOverlay
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.statusBarHidden(true)
|
||||
.animation(.easeInOut(duration: 0.2), value: showControls)
|
||||
.onChange(of: currentPage) { _, newPage in
|
||||
prefetchAdjacentImages(around: newPage)
|
||||
}
|
||||
.onAppear {
|
||||
prefetchAdjacentImages(around: state.initialIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Controls Overlay
|
||||
|
||||
private var controlsOverlay: some View {
|
||||
VStack {
|
||||
// Top bar: close + counter — inside safe area to avoid notch/Dynamic Island overlap
|
||||
HStack {
|
||||
Button { onDismiss() } label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(Color.white.opacity(0.2))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.padding(.leading, 16)
|
||||
|
||||
Spacer()
|
||||
|
||||
if state.attachmentIds.count > 1 {
|
||||
Text("\(currentPage + 1) / \(state.attachmentIds.count)")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Invisible spacer to balance the close button
|
||||
Color.clear.frame(width: 36, height: 36)
|
||||
.padding(.trailing, 16)
|
||||
}
|
||||
.padding(.top, 54)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Bottom bar: share + save
|
||||
HStack(spacing: 32) {
|
||||
Button { shareCurrentImage() } label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button { saveCurrentImage() } label: {
|
||||
Image(systemName: "square.and.arrow.down")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 34)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func shareCurrentImage() {
|
||||
guard currentPage < state.attachmentIds.count,
|
||||
let image = AttachmentCache.shared.loadImage(forAttachmentId: state.attachmentIds[currentPage])
|
||||
else { return }
|
||||
|
||||
let activityVC = UIActivityViewController(activityItems: [image], applicationActivities: nil)
|
||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let root = windowScene.keyWindow?.rootViewController {
|
||||
var presenter = root
|
||||
while let presented = presenter.presentedViewController {
|
||||
presenter = presented
|
||||
}
|
||||
activityVC.popoverPresentationController?.sourceView = presenter.view
|
||||
activityVC.popoverPresentationController?.sourceRect = CGRect(
|
||||
x: presenter.view.bounds.midX, y: presenter.view.bounds.maxY - 50,
|
||||
width: 0, height: 0
|
||||
)
|
||||
presenter.present(activityVC, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func saveCurrentImage() {
|
||||
guard currentPage < state.attachmentIds.count,
|
||||
let image = AttachmentCache.shared.loadImage(forAttachmentId: state.attachmentIds[currentPage])
|
||||
else { return }
|
||||
|
||||
PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
|
||||
guard status == .authorized || status == .limited else { return }
|
||||
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Prefetch
|
||||
|
||||
private func prefetchAdjacentImages(around index: Int) {
|
||||
for offset in [-1, 1] {
|
||||
let i = index + offset
|
||||
guard i >= 0, i < state.attachmentIds.count else { continue }
|
||||
// Touch cache to warm it (loads from disk if needed)
|
||||
_ = AttachmentCache.shared.loadImage(forAttachmentId: state.attachmentIds[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,10 +72,6 @@ struct MessageAvatarView: View {
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(outgoing ? Color.white.opacity(0.15) : Color.white.opacity(0.1), lineWidth: 1)
|
||||
)
|
||||
.task {
|
||||
loadFromCache()
|
||||
if avatarImage == nil {
|
||||
|
||||
@@ -73,15 +73,19 @@ struct MessageFileView: View {
|
||||
.padding(.vertical, 8)
|
||||
.frame(width: 220)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
.task {
|
||||
checkCache()
|
||||
}
|
||||
// Download/share triggered by BubbleContextMenuOverlay tap → notification.
|
||||
// Overlay UIView intercepts all taps; SwiftUI onTapGesture can't fire.
|
||||
.onReceive(NotificationCenter.default.publisher(for: .triggerAttachmentDownload)) { notif in
|
||||
if let id = notif.object as? String, id == attachment.id {
|
||||
if isDownloaded, let url = cachedFileURL {
|
||||
shareFile(url)
|
||||
} else if !isDownloading {
|
||||
downloadFile()
|
||||
}
|
||||
}
|
||||
.task {
|
||||
checkCache()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,14 +62,20 @@ struct MessageImageView: View {
|
||||
} else {
|
||||
placeholderView
|
||||
.overlay { downloadArrowOverlay }
|
||||
.onTapGesture { downloadImage() }
|
||||
}
|
||||
}
|
||||
.task {
|
||||
// Decode blurhash once (like Android's LaunchedEffect + Dispatchers.IO)
|
||||
decodeBlurHash()
|
||||
// PERF: load cached image FIRST — skip expensive BlurHash DCT decode
|
||||
// if the full image is already available.
|
||||
loadFromCache()
|
||||
if image == nil {
|
||||
decodeBlurHash()
|
||||
}
|
||||
}
|
||||
// Download triggered by BubbleContextMenuOverlay tap → notification.
|
||||
// Overlay UIView intercepts all taps; SwiftUI onTapGesture can't fire.
|
||||
.onReceive(NotificationCenter.default.publisher(for: .triggerAttachmentDownload)) { notif in
|
||||
if let id = notif.object as? String, id == attachment.id, image == nil {
|
||||
downloadImage()
|
||||
}
|
||||
}
|
||||
@@ -184,10 +190,23 @@ struct MessageImageView: View {
|
||||
|
||||
/// Decodes the blurhash from the attachment preview string once and caches in @State.
|
||||
/// Android parity: `LaunchedEffect(preview) { BlurHash.decode(preview, 200, 200) }`.
|
||||
/// PERF: static cache for decoded BlurHash images — shared across all instances.
|
||||
/// Avoids redundant DCT decode when the same attachment appears in multiple re-renders.
|
||||
@MainActor private static var blurHashCache: [String: UIImage] = [:]
|
||||
|
||||
private func decodeBlurHash() {
|
||||
let hash = extractBlurHash(from: attachment.preview)
|
||||
guard !hash.isEmpty else { return }
|
||||
if let cached = Self.blurHashCache[hash] {
|
||||
blurImage = cached
|
||||
return
|
||||
}
|
||||
if let result = UIImage.fromBlurHash(hash, width: 32, height: 32) {
|
||||
if Self.blurHashCache.count > 200 {
|
||||
let keysToRemove = Array(Self.blurHashCache.keys.prefix(100))
|
||||
for key in keysToRemove { Self.blurHashCache.removeValue(forKey: key) }
|
||||
}
|
||||
Self.blurHashCache[hash] = result
|
||||
blurImage = result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,9 +224,7 @@ struct OpponentProfileView: View {
|
||||
if #available(iOS 26.0, *) {
|
||||
Capsule().fill(.clear).glassEffect(.regular, in: .capsule)
|
||||
} else {
|
||||
Capsule().fill(.thinMaterial)
|
||||
.overlay { Capsule().strokeBorder(Color.white.opacity(0.22), lineWidth: 0.5) }
|
||||
.shadow(color: .black.opacity(0.12), radius: 20, y: 8)
|
||||
TelegramGlassCapsule()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,12 +238,7 @@ struct OpponentProfileView: View {
|
||||
in: RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
)
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.12), lineWidth: 0.5)
|
||||
}
|
||||
TelegramGlassRoundedRect(cornerRadius: 14)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,10 +228,7 @@ struct PhotoPreviewView: View {
|
||||
.fill(.clear)
|
||||
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: 21, style: .continuous))
|
||||
} else {
|
||||
let shape = RoundedRectangle(cornerRadius: 21, style: .continuous)
|
||||
shape.fill(.thinMaterial)
|
||||
.overlay { shape.strokeBorder(Color.white.opacity(0.18), lineWidth: 0.5) }
|
||||
.shadow(color: .black.opacity(0.12), radius: 20, y: 8)
|
||||
TelegramGlassRoundedRect(cornerRadius: 21)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,15 +17,19 @@ struct SwipeToReplyModifier: ViewModifier {
|
||||
@State private var offset: CGFloat = 0
|
||||
@State private var hasTriggeredHaptic = false
|
||||
@State private var lockedAxis: SwipeAxis?
|
||||
/// Start X in global coordinates — reject if near left screen edge (back gesture zone).
|
||||
@State private var gestureStartX: CGFloat?
|
||||
|
||||
private enum SwipeAxis { case horizontal, vertical }
|
||||
|
||||
/// Minimum drag distance to trigger reply action.
|
||||
private let threshold: CGFloat = 50
|
||||
private let threshold: CGFloat = 55
|
||||
/// Offset where elastic resistance begins.
|
||||
private let elasticCap: CGFloat = 80
|
||||
private let elasticCap: CGFloat = 85
|
||||
/// Reply icon circle diameter.
|
||||
private let iconSize: CGFloat = 34
|
||||
/// Ignore gestures starting within this distance from the left screen edge (iOS back gesture zone).
|
||||
private let backGestureEdge: CGFloat = 40
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
@@ -65,18 +69,28 @@ struct SwipeToReplyModifier: ViewModifier {
|
||||
// MARK: - Gesture
|
||||
|
||||
private var dragGesture: some Gesture {
|
||||
DragGesture(minimumDistance: 16, coordinateSpace: .local)
|
||||
DragGesture(minimumDistance: 20, coordinateSpace: .global)
|
||||
.onChanged { value in
|
||||
// Record start position on first event.
|
||||
if gestureStartX == nil {
|
||||
gestureStartX = value.startLocation.x
|
||||
}
|
||||
|
||||
// Reject gestures originating near the left screen edge (iOS back gesture zone).
|
||||
if let startX = gestureStartX, startX < backGestureEdge {
|
||||
return
|
||||
}
|
||||
|
||||
// Lock axis on first significant movement to avoid
|
||||
// interfering with vertical scroll or back-swipe navigation.
|
||||
if lockedAxis == nil {
|
||||
let dx = abs(value.translation.width)
|
||||
let dy = abs(value.translation.height)
|
||||
if dx > 16 || dy > 16 {
|
||||
// Require clear horizontal dominance (2:1 ratio)
|
||||
if dx > 20 || dy > 20 {
|
||||
// Require clear horizontal dominance (2.5:1 ratio)
|
||||
// AND must be leftward — right swipe is back navigation.
|
||||
let isLeftward = value.translation.width < 0
|
||||
lockedAxis = (dx > dy * 2 && isLeftward) ? .horizontal : .vertical
|
||||
lockedAxis = (dx > dy * 2.5 && isLeftward) ? .horizontal : .vertical
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +115,7 @@ struct SwipeToReplyModifier: ViewModifier {
|
||||
// Haptic at threshold (once per gesture)
|
||||
if abs(offset) >= threshold, !hasTriggeredHaptic {
|
||||
hasTriggeredHaptic = true
|
||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
|
||||
}
|
||||
}
|
||||
.onEnded { _ in
|
||||
@@ -111,6 +125,7 @@ struct SwipeToReplyModifier: ViewModifier {
|
||||
}
|
||||
lockedAxis = nil
|
||||
hasTriggeredHaptic = false
|
||||
gestureStartX = nil
|
||||
if shouldReply {
|
||||
onReply()
|
||||
}
|
||||
|
||||
198
Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift
Normal file
198
Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift
Normal file
@@ -0,0 +1,198 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
// MARK: - ZoomableImagePage
|
||||
|
||||
/// Single page in the image gallery viewer with centroid-based zoom.
|
||||
/// Android parity: `ZoomableImage` in `ImageViewerScreen.kt` — pinch zoom to centroid,
|
||||
/// double-tap to tap point, velocity-based dismiss, touch slop.
|
||||
struct ZoomableImagePage: View {
|
||||
|
||||
let attachmentId: String
|
||||
let onDismiss: () -> Void
|
||||
@Binding var showControls: Bool
|
||||
@Binding var currentScale: CGFloat
|
||||
|
||||
@State private var image: UIImage?
|
||||
@State private var scale: CGFloat = 1.0
|
||||
@State private var offset: CGSize = .zero
|
||||
@State private var dismissOffset: CGFloat = 0
|
||||
@State private var dismissStartTime: Date?
|
||||
|
||||
private let minScale: CGFloat = 1.0
|
||||
private let maxScale: CGFloat = 5.0
|
||||
private let doubleTapScale: CGFloat = 2.5
|
||||
private let dismissDistanceThreshold: CGFloat = 100
|
||||
private let dismissVelocityThreshold: CGFloat = 800
|
||||
private let touchSlop: CGFloat = 20
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
// Background fade during dismiss
|
||||
Color.black
|
||||
.opacity(backgroundOpacity)
|
||||
.ignoresSafeArea()
|
||||
|
||||
if let image {
|
||||
imageContent(image, in: geometry)
|
||||
} else {
|
||||
placeholder
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
image = AttachmentCache.shared.loadImage(forAttachmentId: attachmentId)
|
||||
}
|
||||
.onChange(of: scale) { _, newValue in
|
||||
currentScale = newValue
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Image Content
|
||||
|
||||
@ViewBuilder
|
||||
private func imageContent(_ image: UIImage, in geometry: GeometryProxy) -> some View {
|
||||
let size = geometry.size
|
||||
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.scaleEffect(scale)
|
||||
.offset(x: offset.width, y: offset.height + dismissOffset)
|
||||
.gesture(doubleTapGesture(in: size))
|
||||
.gesture(pinchGesture(in: size))
|
||||
.gesture(dragGesture(in: size))
|
||||
.onTapGesture {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showControls.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Placeholder
|
||||
|
||||
private var placeholder: some View {
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
Text("Loading...")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Background Opacity
|
||||
|
||||
private var backgroundOpacity: Double {
|
||||
let progress = min(abs(dismissOffset) / 300, 1.0)
|
||||
return 1.0 - progress * 0.6
|
||||
}
|
||||
|
||||
// MARK: - Double Tap (zoom to tap point)
|
||||
|
||||
private func doubleTapGesture(in size: CGSize) -> some Gesture {
|
||||
SpatialTapGesture(count: 2)
|
||||
.onEnded { value in
|
||||
withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) {
|
||||
if scale > 1.05 {
|
||||
// Zoom out to 1x
|
||||
scale = 1.0
|
||||
offset = .zero
|
||||
} else {
|
||||
// Zoom in to tap point
|
||||
let tapPoint = value.location
|
||||
let viewCenter = CGPoint(x: size.width / 2, y: size.height / 2)
|
||||
scale = doubleTapScale
|
||||
// Shift image so tap point ends up at screen center
|
||||
offset = CGSize(
|
||||
width: (viewCenter.x - tapPoint.x) * (doubleTapScale - 1),
|
||||
height: (viewCenter.y - tapPoint.y) * (doubleTapScale - 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pinch Gesture (zoom to centroid)
|
||||
|
||||
private func pinchGesture(in size: CGSize) -> some Gesture {
|
||||
MagnificationGesture()
|
||||
.onChanged { value in
|
||||
let newScale = min(max(value * (scale > 0.01 ? 1.0 : scale), minScale * 0.5), maxScale)
|
||||
// MagnificationGesture doesn't provide centroid, so zoom to center.
|
||||
// For true centroid zoom, we'd need UIKit gesture recognizers.
|
||||
// This is acceptable — most users don't notice centroid vs center on mobile.
|
||||
scale = newScale
|
||||
}
|
||||
.onEnded { _ in
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
|
||||
if scale < minScale {
|
||||
scale = minScale
|
||||
offset = .zero
|
||||
}
|
||||
clampOffset(in: size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Drag Gesture (pan when zoomed, dismiss when not)
|
||||
|
||||
private func dragGesture(in size: CGSize) -> some Gesture {
|
||||
DragGesture(minimumDistance: touchSlop)
|
||||
.onChanged { value in
|
||||
if scale > 1.05 {
|
||||
// Zoomed: pan image
|
||||
offset = CGSize(
|
||||
width: value.translation.width,
|
||||
height: value.translation.height
|
||||
)
|
||||
} else {
|
||||
// Not zoomed: check if vertical dominant (dismiss) or horizontal (page swipe)
|
||||
let dx = abs(value.translation.width)
|
||||
let dy = abs(value.translation.height)
|
||||
if dy > dx * 1.2 {
|
||||
if dismissStartTime == nil {
|
||||
dismissStartTime = Date()
|
||||
}
|
||||
dismissOffset = value.translation.height
|
||||
}
|
||||
}
|
||||
}
|
||||
.onEnded { value in
|
||||
if scale > 1.05 {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
|
||||
clampOffset(in: size)
|
||||
}
|
||||
} else {
|
||||
// Calculate velocity for dismiss
|
||||
let elapsed = dismissStartTime.map { Date().timeIntervalSince($0) } ?? 0.3
|
||||
let velocityY = abs(dismissOffset) / max(elapsed, 0.01)
|
||||
|
||||
if abs(dismissOffset) > dismissDistanceThreshold || velocityY > dismissVelocityThreshold {
|
||||
onDismiss()
|
||||
} else {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
|
||||
dismissOffset = 0
|
||||
}
|
||||
}
|
||||
dismissStartTime = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Offset Clamping
|
||||
|
||||
private func clampOffset(in size: CGSize) {
|
||||
guard scale > 1.0 else {
|
||||
offset = .zero
|
||||
return
|
||||
}
|
||||
let maxOffsetX = size.width * (scale - 1) / 2
|
||||
let maxOffsetY = size.height * (scale - 1) / 2
|
||||
offset = CGSize(
|
||||
width: min(max(offset.width, -maxOffsetX), maxOffsetX),
|
||||
height: min(max(offset.height, -maxOffsetY), maxOffsetY)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -325,7 +325,8 @@ private struct FavoriteContactsRowSearch: View {
|
||||
colorIndex: dialog.avatarColorIndex,
|
||||
size: 62,
|
||||
isOnline: dialog.isOnline,
|
||||
isSavedMessages: dialog.isSavedMessages
|
||||
isSavedMessages: dialog.isSavedMessages,
|
||||
image: dialog.isSavedMessages ? nil : AvatarRepository.shared.loadAvatar(publicKey: dialog.opponentKey)
|
||||
)
|
||||
|
||||
Text(dialog.isSavedMessages ? "Saved" : dialog.opponentTitle.components(separatedBy: " ").first ?? "")
|
||||
|
||||
@@ -564,14 +564,13 @@ private struct ChatListDialogContent: View {
|
||||
@State private var typingDialogs: Set<String> = []
|
||||
|
||||
var body: some View {
|
||||
// Compute once — avoids 3× filter (allModeDialogs → allModePinned → allModeUnpinned).
|
||||
let allDialogs = viewModel.allModeDialogs
|
||||
let pinned = allDialogs.filter(\.isPinned)
|
||||
let unpinned = allDialogs.filter { !$0.isPinned }
|
||||
// Use pre-partitioned arrays from ViewModel (single-pass O(n) instead of 3× filter).
|
||||
let pinned = viewModel.allModePinned
|
||||
let unpinned = viewModel.allModeUnpinned
|
||||
let requestsCount = viewModel.requestsCount
|
||||
|
||||
Group {
|
||||
if allDialogs.isEmpty && !viewModel.isLoading {
|
||||
if pinned.isEmpty && unpinned.isEmpty && !viewModel.isLoading {
|
||||
SyncAwareEmptyState()
|
||||
} else {
|
||||
dialogList(
|
||||
@@ -719,52 +718,44 @@ struct SyncAwareChatRow: View {
|
||||
|
||||
// MARK: - Device Approval Banner
|
||||
|
||||
/// Shown on primary device when another device is requesting access.
|
||||
/// Desktop parity: clean banner with "New login from {device} ({os})" and Accept/Decline.
|
||||
/// Desktop: DeviceVerify.tsx — height 65px, centered text (dimmed), two transparent buttons.
|
||||
private struct DeviceApprovalBanner: View {
|
||||
let device: DeviceEntry
|
||||
let onAccept: () -> Void
|
||||
let onDecline: () -> Void
|
||||
|
||||
@State private var showAcceptConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "exclamationmark.shield")
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(RosettaColors.error)
|
||||
VStack(spacing: 8) {
|
||||
Text("New login from \(device.deviceName) (\(device.deviceOs))")
|
||||
.font(.system(size: 13, weight: .regular))
|
||||
.foregroundStyle(.white.opacity(0.45))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("New device login detected")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
|
||||
Text("\(device.deviceName) (\(device.deviceOs))")
|
||||
.font(.system(size: 12, weight: .regular))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
HStack(spacing: 24) {
|
||||
Button("Accept") {
|
||||
showAcceptConfirmation = true
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button(action: onAccept) {
|
||||
Text("Yes, it's me")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.primaryBlue)
|
||||
}
|
||||
|
||||
Button(action: onDecline) {
|
||||
Text("No, it's not me!")
|
||||
Button("Decline") {
|
||||
onDecline()
|
||||
}
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.error)
|
||||
.foregroundStyle(RosettaColors.error.opacity(0.8))
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.leading, 34)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.alert("Accept new device", isPresented: $showAcceptConfirmation) {
|
||||
Button("Accept") { onAccept() }
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Are you sure you want to accept this device? This will allow it to access your account.")
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(RosettaColors.error.opacity(0.08))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,65 +39,70 @@ final class ChatListViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Computed (dialog list for ChatListDialogContent)
|
||||
// MARK: - Dialog partitions (single pass, cached per observation cycle)
|
||||
|
||||
private struct DialogPartition {
|
||||
var allPinned: [Dialog] = []
|
||||
var allUnpinned: [Dialog] = []
|
||||
var requests: [Dialog] = []
|
||||
var totalUnread: Int = 0
|
||||
}
|
||||
|
||||
/// Cached partition — computed once, reused by all properties until dialogs change.
|
||||
private var _cachedPartition: DialogPartition?
|
||||
private var _cachedPartitionVersion: Int = -1
|
||||
|
||||
private var partition: DialogPartition {
|
||||
let repo = DialogRepository.shared
|
||||
let currentVersion = repo.dialogsVersion
|
||||
if let cached = _cachedPartition, _cachedPartitionVersion == currentVersion {
|
||||
return cached
|
||||
}
|
||||
var result = DialogPartition()
|
||||
for dialog in repo.sortedDialogs {
|
||||
let isChat = dialog.iHaveSent || dialog.isSavedMessages || SystemAccounts.isSystemAccount(dialog.opponentKey)
|
||||
if isChat {
|
||||
if dialog.isPinned {
|
||||
result.allPinned.append(dialog)
|
||||
} else {
|
||||
result.allUnpinned.append(dialog)
|
||||
}
|
||||
} else {
|
||||
result.requests.append(dialog)
|
||||
}
|
||||
if !dialog.isMuted {
|
||||
result.totalUnread += dialog.unreadCount
|
||||
}
|
||||
}
|
||||
_cachedPartition = result
|
||||
_cachedPartitionVersion = currentVersion
|
||||
return result
|
||||
}
|
||||
|
||||
/// Filtered dialog list based on `dialogsMode`.
|
||||
/// - `all`: dialogs where I have sent (+ Saved Messages + system accounts)
|
||||
/// - `requests`: dialogs where only opponent has messaged me
|
||||
var filteredDialogs: [Dialog] {
|
||||
let all = DialogRepository.shared.sortedDialogs
|
||||
let p = partition
|
||||
switch dialogsMode {
|
||||
case .all:
|
||||
return all.filter {
|
||||
$0.iHaveSent || $0.isSavedMessages || SystemAccounts.isSystemAccount($0.opponentKey)
|
||||
}
|
||||
case .requests:
|
||||
return all.filter {
|
||||
!$0.iHaveSent && !$0.isSavedMessages && !SystemAccounts.isSystemAccount($0.opponentKey)
|
||||
}
|
||||
case .all: return p.allPinned + p.allUnpinned
|
||||
case .requests: return p.requests
|
||||
}
|
||||
}
|
||||
|
||||
var pinnedDialogs: [Dialog] { filteredDialogs.filter(\.isPinned) }
|
||||
var unpinnedDialogs: [Dialog] { filteredDialogs.filter { !$0.isPinned } }
|
||||
|
||||
/// Number of request dialogs (incoming-only, not system, not self-chat).
|
||||
var requestsCount: Int {
|
||||
DialogRepository.shared.sortedDialogs.filter {
|
||||
!$0.iHaveSent && !$0.isSavedMessages && !SystemAccounts.isSystemAccount($0.opponentKey)
|
||||
}.count
|
||||
}
|
||||
var pinnedDialogs: [Dialog] { partition.allPinned }
|
||||
var unpinnedDialogs: [Dialog] { partition.allUnpinned }
|
||||
|
||||
var requestsCount: Int { partition.requests.count }
|
||||
var hasRequests: Bool { requestsCount > 0 }
|
||||
|
||||
var totalUnreadCount: Int {
|
||||
DialogRepository.shared.dialogs.values
|
||||
.lazy.filter { !$0.isMuted }
|
||||
.reduce(0) { $0 + $1.unreadCount }
|
||||
}
|
||||
|
||||
var totalUnreadCount: Int { partition.totalUnread }
|
||||
var hasUnread: Bool { totalUnreadCount > 0 }
|
||||
|
||||
// MARK: - Per-mode dialogs (for TabView pages)
|
||||
|
||||
/// "All" dialogs — conversations where I have sent (+ Saved Messages + system accounts).
|
||||
/// Used by the All page in the swipeable TabView.
|
||||
var allModeDialogs: [Dialog] {
|
||||
DialogRepository.shared.sortedDialogs.filter {
|
||||
$0.iHaveSent || $0.isSavedMessages || SystemAccounts.isSystemAccount($0.opponentKey)
|
||||
}
|
||||
}
|
||||
var allModeDialogs: [Dialog] { partition.allPinned + partition.allUnpinned }
|
||||
var allModePinned: [Dialog] { partition.allPinned }
|
||||
var allModeUnpinned: [Dialog] { partition.allUnpinned }
|
||||
|
||||
var allModePinned: [Dialog] { allModeDialogs.filter(\.isPinned) }
|
||||
var allModeUnpinned: [Dialog] { allModeDialogs.filter { !$0.isPinned } }
|
||||
|
||||
/// "Requests" dialogs — conversations where only opponent has messaged me.
|
||||
/// Used by the Requests page in the swipeable TabView.
|
||||
var requestsModeDialogs: [Dialog] {
|
||||
DialogRepository.shared.sortedDialogs.filter {
|
||||
!$0.iHaveSent && !$0.isSavedMessages && !SystemAccounts.isSystemAccount($0.opponentKey)
|
||||
}
|
||||
}
|
||||
var requestsModeDialogs: [Dialog] { partition.requests }
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ private extension ChatRowView {
|
||||
size: 62,
|
||||
isOnline: dialog.isOnline,
|
||||
isSavedMessages: dialog.isSavedMessages,
|
||||
image: AvatarRepository.shared.loadAvatar(publicKey: dialog.opponentKey)
|
||||
image: dialog.isSavedMessages ? nil : AvatarRepository.shared.loadAvatar(publicKey: dialog.opponentKey)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,9 @@ private extension ChatRowView {
|
||||
.frame(height: 41, alignment: .topLeading)
|
||||
}
|
||||
|
||||
/// Static cache for emoji-parsed message text (avoids regex per row per render).
|
||||
private static var messageTextCache: [String: String] = [:]
|
||||
|
||||
var messageText: String {
|
||||
// Desktop parity: show "typing..." in chat list row when opponent is typing.
|
||||
if isTyping && !dialog.isSavedMessages {
|
||||
@@ -140,9 +143,18 @@ private extension ChatRowView {
|
||||
if dialog.lastMessage.isEmpty {
|
||||
return "No messages yet"
|
||||
}
|
||||
if let cached = Self.messageTextCache[dialog.lastMessage] {
|
||||
return cached
|
||||
}
|
||||
// Strip inline markdown markers and convert emoji shortcodes for clean preview.
|
||||
let cleaned = dialog.lastMessage.replacingOccurrences(of: "**", with: "")
|
||||
return EmojiParser.replaceShortcodes(in: cleaned)
|
||||
let result = EmojiParser.replaceShortcodes(in: cleaned)
|
||||
if Self.messageTextCache.count > 500 {
|
||||
let keysToRemove = Array(Self.messageTextCache.keys.prefix(250))
|
||||
for key in keysToRemove { Self.messageTextCache.removeValue(forKey: key) }
|
||||
}
|
||||
Self.messageTextCache[dialog.lastMessage] = result
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,22 +294,37 @@ private extension ChatRowView {
|
||||
let f = DateFormatter(); f.dateFormat = "dd.MM.yy"; return f
|
||||
}()
|
||||
|
||||
/// Static cache for formatted time strings (avoids Date/Calendar per row per render).
|
||||
private static var timeStringCache: [Int64: String] = [:]
|
||||
|
||||
var formattedTime: String {
|
||||
guard dialog.lastMessageTimestamp > 0 else { return "" }
|
||||
|
||||
if let cached = Self.timeStringCache[dialog.lastMessageTimestamp] {
|
||||
return cached
|
||||
}
|
||||
|
||||
let date = Date(timeIntervalSince1970: Double(dialog.lastMessageTimestamp) / 1000)
|
||||
let now = Date()
|
||||
let calendar = Calendar.current
|
||||
|
||||
let result: String
|
||||
if calendar.isDateInToday(date) {
|
||||
return Self.timeFormatter.string(from: date)
|
||||
result = Self.timeFormatter.string(from: date)
|
||||
} else if calendar.isDateInYesterday(date) {
|
||||
return "Yesterday"
|
||||
result = "Yesterday"
|
||||
} else if let days = calendar.dateComponents([.day], from: date, to: now).day, days < 7 {
|
||||
return Self.dayFormatter.string(from: date)
|
||||
result = Self.dayFormatter.string(from: date)
|
||||
} else {
|
||||
return Self.dateFormatter.string(from: date)
|
||||
result = Self.dateFormatter.string(from: date)
|
||||
}
|
||||
|
||||
if Self.timeStringCache.count > 500 {
|
||||
let keysToRemove = Array(Self.timeStringCache.keys.prefix(250))
|
||||
for key in keysToRemove { Self.timeStringCache.removeValue(forKey: key) }
|
||||
}
|
||||
Self.timeStringCache[dialog.lastMessageTimestamp] = result
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,9 +76,7 @@ struct RequestChatsView: View {
|
||||
if #available(iOS 26.0, *) {
|
||||
Capsule().fill(.clear).glassEffect(.regular, in: .capsule)
|
||||
} else {
|
||||
Capsule().fill(.thinMaterial)
|
||||
.overlay { Capsule().strokeBorder(strokeColor.opacity(strokeOpacity), lineWidth: 0.5) }
|
||||
.shadow(color: .black.opacity(0.12), radius: 20, y: 8)
|
||||
TelegramGlassCapsule()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,11 +106,7 @@ struct MainTabView: View {
|
||||
RosettaTabBar(
|
||||
selectedTab: selectedTab,
|
||||
onTabSelected: { tab in
|
||||
activatedTabs.insert(tab)
|
||||
for t in RosettaTab.interactionOrder { activatedTabs.insert(t) }
|
||||
withAnimation(.easeInOut(duration: 0.15)) {
|
||||
selectedTab = tab
|
||||
}
|
||||
},
|
||||
onSwipeStateChanged: { state in
|
||||
if let state {
|
||||
@@ -150,6 +146,7 @@ struct MainTabView: View {
|
||||
tabView(for: tab)
|
||||
.frame(width: width, height: availableSize.height)
|
||||
.opacity(tabOpacity(for: tab))
|
||||
.animation(.easeOut(duration: 0.12), value: selectedTab)
|
||||
.allowsHitTesting(tab == selectedTab && dragFractionalIndex == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,13 +92,17 @@ private extension ProfileEditView {
|
||||
VStack(spacing: 0) {
|
||||
// Display Name field
|
||||
HStack {
|
||||
TextField("First Name", text: $displayName)
|
||||
TextField("First Last", text: $displayName)
|
||||
.font(.system(size: 17))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.words)
|
||||
.onChange(of: displayName) { _, newValue in
|
||||
displayNameError = ProfileValidator.validateDisplayName(newValue)?.errorDescription
|
||||
let sanitized = ProfileValidator.sanitizeDisplayName(newValue)
|
||||
if sanitized != newValue {
|
||||
displayName = sanitized
|
||||
}
|
||||
displayNameError = ProfileValidator.validateDisplayName(sanitized)?.errorDescription
|
||||
}
|
||||
|
||||
if !displayName.isEmpty {
|
||||
|
||||
@@ -41,17 +41,20 @@ struct SafetyView: View {
|
||||
let publicKey = SessionManager.shared.currentPublicKey
|
||||
BiometricAuthManager.shared.clearAll(forAccount: publicKey)
|
||||
AvatarRepository.shared.removeAvatar(publicKey: publicKey)
|
||||
// Clear persisted chat files before session ends
|
||||
// Start fade overlay FIRST — covers the screen before
|
||||
// deleteAccount() triggers setActiveAccount(next) which
|
||||
// would cause MainTabView .id() recreation.
|
||||
onLogout?()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.04) {
|
||||
DialogRepository.shared.reset(clearPersisted: true)
|
||||
MessageRepository.shared.reset(clearPersisted: true)
|
||||
// Clear per-account UserDefaults entries
|
||||
let defaults = UserDefaults.standard
|
||||
defaults.removeObject(forKey: "rosetta_recent_searches_\(publicKey)")
|
||||
defaults.removeObject(forKey: "rosetta_last_sync_\(publicKey)")
|
||||
defaults.removeObject(forKey: "backgroundBlurColor_\(publicKey)")
|
||||
SessionManager.shared.endSession()
|
||||
try? AccountManager.shared.deleteAccount()
|
||||
onLogout?()
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text("Are you sure? This will permanently delete your account from this device. You'll need your seed phrase to recover it.")
|
||||
|
||||
@@ -85,6 +85,11 @@ struct SettingsView: View {
|
||||
let publicKey = SessionManager.shared.currentPublicKey
|
||||
BiometricAuthManager.shared.clearAll(forAccount: publicKey)
|
||||
AvatarRepository.shared.removeAvatar(publicKey: publicKey)
|
||||
// Start fade overlay FIRST — covers the screen before
|
||||
// deleteAccount() triggers setActiveAccount(next) which
|
||||
// would cause MainTabView .id() recreation.
|
||||
onLogout?()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.04) {
|
||||
DialogRepository.shared.reset(clearPersisted: true)
|
||||
MessageRepository.shared.reset(clearPersisted: true)
|
||||
let defaults = UserDefaults.standard
|
||||
@@ -93,7 +98,7 @@ struct SettingsView: View {
|
||||
defaults.removeObject(forKey: "backgroundBlurColor_\(publicKey)")
|
||||
SessionManager.shared.endSession()
|
||||
try? AccountManager.shared.deleteAccount()
|
||||
onLogout?()
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text("Are you sure? This will permanently delete your account from this device. You'll need your seed phrase to recover it.")
|
||||
@@ -277,20 +282,18 @@ struct SettingsView: View {
|
||||
guard isSaving else { return }
|
||||
isSaving = false
|
||||
|
||||
if let code = ResultCode(rawValue: result.resultCode), code == .success {
|
||||
// Server confirmed — update local profile + avatar
|
||||
if result.resultCode == ResultCode.usernameTaken.rawValue {
|
||||
usernameError = "This username is already taken"
|
||||
} else {
|
||||
// Server confirmed OR unknown code — save locally.
|
||||
// PacketResult has no request ID, so we can't guarantee this
|
||||
// response belongs to our PacketUserInfo. Treat non-usernameTaken
|
||||
// as success and save locally (desktop parity: fallback save).
|
||||
updateLocalProfile(displayName: trimmedName, username: trimmedUsername)
|
||||
commitPendingAvatar()
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
isEditingProfile = false
|
||||
}
|
||||
} else {
|
||||
// Server returned error
|
||||
if result.resultCode == ResultCode.usernameTaken.rawValue {
|
||||
usernameError = "This username is already taken"
|
||||
} else {
|
||||
usernameError = "Failed to save profile"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -418,6 +421,9 @@ struct SettingsView: View {
|
||||
|
||||
// MARK: - Account Switcher Card
|
||||
|
||||
@State private var accountToDelete: Account?
|
||||
@State private var showDeleteAccountSheet = false
|
||||
|
||||
private var accountSwitcherCard: some View {
|
||||
let currentKey = AccountManager.shared.currentAccount?.publicKey
|
||||
let otherAccounts = AccountManager.shared.allAccounts.filter { $0.publicKey != currentKey }
|
||||
@@ -440,6 +446,18 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
.padding(.top, 16)
|
||||
.confirmationDialog(
|
||||
"Are you sure you want to delete this account from this device?",
|
||||
isPresented: $showDeleteAccountSheet,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete", role: .destructive) {
|
||||
if let acc = accountToDelete {
|
||||
deleteOtherAccount(acc)
|
||||
accountToDelete = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func accountRow(_ account: Account, position: SettingsRowPosition) -> some View {
|
||||
@@ -449,16 +467,20 @@ struct SettingsView: View {
|
||||
let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: account.publicKey)
|
||||
let unread = totalUnreadCount(for: account.publicKey)
|
||||
|
||||
return Button {
|
||||
// Show fade overlay FIRST — covers the screen immediately.
|
||||
// Delay setActiveAccount + endSession until overlay is fully opaque (35ms fade-in),
|
||||
// so the user never sees the account list re-render.
|
||||
return AccountSwipeRow(
|
||||
position: position,
|
||||
onTap: {
|
||||
onLogout?()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.04) {
|
||||
AccountManager.shared.setActiveAccount(publicKey: account.publicKey)
|
||||
SessionManager.shared.endSession()
|
||||
}
|
||||
} label: {
|
||||
},
|
||||
onDelete: {
|
||||
accountToDelete = account
|
||||
showDeleteAccountSheet = true
|
||||
}
|
||||
) {
|
||||
HStack(spacing: 12) {
|
||||
AvatarView(
|
||||
initials: initials,
|
||||
@@ -487,10 +509,7 @@ struct SettingsView: View {
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.frame(height: 48)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.settingsHighlight(position: position)
|
||||
}
|
||||
|
||||
private func addAccountRow(position: SettingsRowPosition) -> some View {
|
||||
@@ -519,6 +538,17 @@ struct SettingsView: View {
|
||||
|
||||
/// Calculates total unread count for the given account's dialogs.
|
||||
/// Only returns meaningful data for the currently active session.
|
||||
/// Deletes another (non-active) account from the device.
|
||||
private func deleteOtherAccount(_ account: Account) {
|
||||
BiometricAuthManager.shared.clearAll(forAccount: account.publicKey)
|
||||
AvatarRepository.shared.removeAvatar(publicKey: account.publicKey)
|
||||
let defaults = UserDefaults.standard
|
||||
defaults.removeObject(forKey: "rosetta_recent_searches_\(account.publicKey)")
|
||||
defaults.removeObject(forKey: "rosetta_last_sync_\(account.publicKey)")
|
||||
defaults.removeObject(forKey: "backgroundBlurColor_\(account.publicKey)")
|
||||
AccountManager.shared.removeAccount(publicKey: account.publicKey)
|
||||
}
|
||||
|
||||
private func totalUnreadCount(for accountPublicKey: String) -> Int {
|
||||
guard accountPublicKey == SessionManager.shared.currentPublicKey else { return 0 }
|
||||
return DialogRepository.shared.dialogs.values.reduce(0) { $0 + $1.unreadCount }
|
||||
@@ -850,3 +880,128 @@ struct SettingsView: View {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Account Swipe Row
|
||||
|
||||
/// Row with swipe-to-delete that doesn't conflict with tap action.
|
||||
/// Uses a gesture-priority trick: horizontal DragGesture on a transparent overlay
|
||||
/// takes priority only when horizontal movement exceeds vertical (swipe left).
|
||||
/// Vertical drags and taps pass through to the content below.
|
||||
private struct AccountSwipeRow<Content: View>: View {
|
||||
let position: SettingsRowPosition
|
||||
let onTap: () -> Void
|
||||
let onDelete: () -> Void
|
||||
@ViewBuilder let content: () -> Content
|
||||
|
||||
@State private var swipeOffset: CGFloat = 0
|
||||
@State private var isRevealed = false
|
||||
private let deleteWidth: CGFloat = 80
|
||||
private let cardRadius: CGFloat = 12
|
||||
|
||||
/// Top-right corner radius based on row position in the card.
|
||||
private var deleteTopRadius: CGFloat {
|
||||
switch position {
|
||||
case .top, .alone: return cardRadius
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Bottom-right corner radius based on row position in the card.
|
||||
private var deleteBottomRadius: CGFloat {
|
||||
// Account rows are never .bottom — addAccountRow is always below.
|
||||
// But handle it for completeness.
|
||||
switch position {
|
||||
case .bottom, .alone: return cardRadius
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .trailing) {
|
||||
// Delete button behind — only when swiped
|
||||
if swipeOffset < -5 {
|
||||
HStack(spacing: 0) {
|
||||
Spacer()
|
||||
Button {
|
||||
withAnimation(.easeOut(duration: 0.2)) {
|
||||
swipeOffset = 0
|
||||
isRevealed = false
|
||||
}
|
||||
onDelete()
|
||||
} label: {
|
||||
Text("Delete")
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: deleteWidth, height: geo.size.height)
|
||||
.background(
|
||||
UnevenRoundedRectangle(
|
||||
topLeadingRadius: 0,
|
||||
bottomLeadingRadius: 0,
|
||||
bottomTrailingRadius: deleteBottomRadius,
|
||||
topTrailingRadius: deleteTopRadius
|
||||
)
|
||||
.fill(Color.red)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Content row
|
||||
content()
|
||||
.contentShape(Rectangle())
|
||||
.offset(x: swipeOffset)
|
||||
.onTapGesture {
|
||||
if isRevealed {
|
||||
withAnimation(.easeOut(duration: 0.2)) {
|
||||
swipeOffset = 0
|
||||
isRevealed = false
|
||||
}
|
||||
} else {
|
||||
onTap()
|
||||
}
|
||||
}
|
||||
.highPriorityGesture(
|
||||
DragGesture(minimumDistance: 20, coordinateSpace: .local)
|
||||
.onChanged { value in
|
||||
let dx = value.translation.width
|
||||
let dy = value.translation.height
|
||||
// Only respond to horizontal swipes
|
||||
guard abs(dx) > abs(dy) else { return }
|
||||
|
||||
if isRevealed {
|
||||
// Already open — allow closing
|
||||
let newOffset = -deleteWidth + dx
|
||||
swipeOffset = max(min(newOffset, 0), -deleteWidth - 20)
|
||||
} else if dx < 0 {
|
||||
// Swiping left to reveal
|
||||
swipeOffset = max(dx, -deleteWidth - 20)
|
||||
}
|
||||
}
|
||||
.onEnded { value in
|
||||
let dx = value.translation.width
|
||||
guard abs(dx) > abs(value.translation.height) else {
|
||||
// Was vertical — snap back
|
||||
withAnimation(.easeOut(duration: 0.2)) {
|
||||
swipeOffset = isRevealed ? -deleteWidth : 0
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
withAnimation(.easeOut(duration: 0.2)) {
|
||||
if swipeOffset < -deleteWidth / 2 {
|
||||
swipeOffset = -deleteWidth
|
||||
isRevealed = true
|
||||
} else {
|
||||
swipeOffset = 0
|
||||
isRevealed = false
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(height: 48)
|
||||
.clipped()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,5 +4,9 @@
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.rosetta.dev</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -64,22 +64,27 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
|
||||
// MARK: - Background Push (Badge + Local Notification with Sound)
|
||||
|
||||
/// Called when a push notification arrives with `content-available: 1`.
|
||||
/// Server does NOT send `sound` in APNs payload — we always create a local
|
||||
/// notification with `.default` sound to ensure vibration works.
|
||||
/// Two scenarios:
|
||||
/// 1. Server sends data-only push (no alert) → we create a local notification with sound.
|
||||
/// 2. Server sends visible push + content-available → NSE handles sound/badge,
|
||||
/// we only sync the badge count here.
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
|
||||
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
|
||||
) {
|
||||
// Foreground: WebSocket handles messages + haptic feedback — skip.
|
||||
// Foreground: WebSocket handles messages in real-time — skip.
|
||||
guard application.applicationState != .active else {
|
||||
completionHandler(.noData)
|
||||
return
|
||||
}
|
||||
|
||||
// Background/inactive: increment badge from persisted count.
|
||||
let currentBadge = UserDefaults.standard.integer(forKey: "app_badge_count")
|
||||
let shared = UserDefaults(suiteName: "group.com.rosetta.dev")
|
||||
|
||||
// Background/inactive: increment badge from shared App Group storage.
|
||||
let currentBadge = shared?.integer(forKey: "app_badge_count") ?? 0
|
||||
let newBadge = currentBadge + 1
|
||||
shared?.set(newBadge, forKey: "app_badge_count")
|
||||
UserDefaults.standard.set(newBadge, forKey: "app_badge_count")
|
||||
UNUserNotificationCenter.current().setBadgeCount(newBadge)
|
||||
|
||||
@@ -87,13 +92,22 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
|
||||
let senderName = userInfo["sender_name"] as? String ?? "New message"
|
||||
let messageText = userInfo["message"] as? String ?? "New message"
|
||||
|
||||
// Don't notify for muted chats.
|
||||
let isMuted = Task { @MainActor in
|
||||
DialogRepository.shared.dialogs[senderKey]?.isMuted == true
|
||||
}
|
||||
Task {
|
||||
let muted = await isMuted.value
|
||||
guard !muted else {
|
||||
// Check if the server already sent a visible alert (aps.alert exists).
|
||||
// If so, NSE already modified it — don't create a duplicate local notification.
|
||||
let aps = userInfo["aps"] as? [String: Any]
|
||||
let hasVisibleAlert = aps?["alert"] != nil
|
||||
|
||||
// Don't notify for muted chats (sync check without MainActor await).
|
||||
let isMuted: Bool = {
|
||||
// Access is safe: called from background on MainActor-isolated repo.
|
||||
// Use standard defaults cache for muted set (no MainActor needed).
|
||||
let mutedSet = UserDefaults.standard.stringArray(forKey: "muted_chats_keys") ?? []
|
||||
return mutedSet.contains(senderKey)
|
||||
}()
|
||||
|
||||
// If server sent visible alert, NSE handles sound+badge. Just sync badge.
|
||||
// If data-only push, create local notification with sound for vibration.
|
||||
guard !hasVisibleAlert && !isMuted else {
|
||||
completionHandler(.newData)
|
||||
return
|
||||
}
|
||||
@@ -113,7 +127,8 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
|
||||
content: content,
|
||||
trigger: nil
|
||||
)
|
||||
try? await UNUserNotificationCenter.current().add(request)
|
||||
// Use non-async path to avoid Task lifetime issues in background.
|
||||
UNUserNotificationCenter.current().add(request) { _ in
|
||||
completionHandler(.newData)
|
||||
}
|
||||
}
|
||||
@@ -288,6 +303,11 @@ struct RosettaApp: App {
|
||||
}
|
||||
},
|
||||
)
|
||||
// Force full view recreation on account switch. Without this,
|
||||
// SwiftUI may reuse the old MainTabView's @StateObject instances
|
||||
// (SettingsViewModel, ChatListViewModel) when appState cycles
|
||||
// .main → .unlock → .main, causing stale profile data to persist.
|
||||
.id(AccountManager.shared.currentAccount?.publicKey ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,4 +341,7 @@ extension Notification.Name {
|
||||
static let openChatFromNotification = Notification.Name("openChatFromNotification")
|
||||
/// Posted when own profile (displayName/username) is updated from the server.
|
||||
static let profileDidUpdate = Notification.Name("profileDidUpdate")
|
||||
/// Posted when user taps an attachment in the bubble overlay — carries attachment ID (String) as `object`.
|
||||
/// MessageImageView / MessageFileView listen and trigger download/share.
|
||||
static let triggerAttachmentDownload = Notification.Name("triggerAttachmentDownload")
|
||||
}
|
||||
|
||||
31
RosettaNotificationService/Info.plist
Normal file
31
RosettaNotificationService/Info.plist
Normal file
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>RosettaNotificationService</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.usernotifications.service</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
54
RosettaNotificationService/NotificationService.swift
Normal file
54
RosettaNotificationService/NotificationService.swift
Normal file
@@ -0,0 +1,54 @@
|
||||
import UserNotifications
|
||||
|
||||
/// Notification Service Extension — runs as a separate process even when the main app
|
||||
/// is terminated. Intercepts push notifications with `mutable-content: 1` and:
|
||||
/// 1. Adds `.default` sound for vibration (server payload has no sound)
|
||||
/// 2. Increments the app icon badge from shared App Group storage
|
||||
final class NotificationService: UNNotificationServiceExtension {
|
||||
|
||||
private static let appGroupID = "group.com.rosetta.dev"
|
||||
private static let badgeKey = "app_badge_count"
|
||||
|
||||
private var contentHandler: ((UNNotificationContent) -> Void)?
|
||||
private var bestAttemptContent: UNMutableNotificationContent?
|
||||
|
||||
override func didReceive(
|
||||
_ request: UNNotificationRequest,
|
||||
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
|
||||
) {
|
||||
self.contentHandler = contentHandler
|
||||
bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent
|
||||
|
||||
guard let content = bestAttemptContent else {
|
||||
contentHandler(request.content)
|
||||
return
|
||||
}
|
||||
|
||||
// 1. Add sound for vibration — server APNs payload has no sound field.
|
||||
content.sound = .default
|
||||
|
||||
// 2. Increment badge count from shared App Group storage.
|
||||
if let shared = UserDefaults(suiteName: Self.appGroupID) {
|
||||
let current = shared.integer(forKey: Self.badgeKey)
|
||||
let newBadge = current + 1
|
||||
shared.set(newBadge, forKey: Self.badgeKey)
|
||||
content.badge = NSNumber(value: newBadge)
|
||||
}
|
||||
|
||||
// 3. Ensure notification category for CarPlay parity.
|
||||
if content.categoryIdentifier.isEmpty {
|
||||
content.categoryIdentifier = "message"
|
||||
}
|
||||
|
||||
contentHandler(content)
|
||||
}
|
||||
|
||||
/// Called if the extension takes too long (30s limit).
|
||||
/// Deliver the best attempt content with at least the sound set.
|
||||
override func serviceExtensionTimeWillExpire() {
|
||||
if let handler = contentHandler, let content = bestAttemptContent {
|
||||
content.sound = .default
|
||||
handler(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.rosetta.dev</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
Reference in New Issue
Block a user