From 469f1821554cdcf636d6f4b75f0e47dfc4558898 Mon Sep 17 00:00:00 2001 From: senseiGai Date: Sun, 29 Mar 2026 18:02:01 +0500 Subject: [PATCH] =?UTF-8?q?Reply-=D0=BE=D0=B3=D1=80=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=87=D0=B5=D0=BD=D0=B8=D1=8F:=20Desktop-parity=20=D0=B0=D1=83?= =?UTF-8?q?=D0=B4=D0=B8=D1=82=20=D0=B8=20=D1=84=D0=B8=D0=BA=D1=81=20.call?= =?UTF-8?q?=20/=20system=20accounts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/CallActivityAttributes.swift | 1 - Rosetta/Core/Services/CallManager.swift | 14 +- .../Chats/ChatDetail/MessageCellView.swift | 11 +- .../Chats/ChatDetail/NativeMessageCell.swift | 14 +- .../Chats/ChatDetail/NativeMessageList.swift | 2 + .../CallActivityAttributes.swift | 1 - .../CallLiveActivity.swift | 75 ++++--- RosettaTests/CallLiveActivityTests.swift | 192 ++++++++++++++++++ 8 files changed, 254 insertions(+), 56 deletions(-) create mode 100644 RosettaTests/CallLiveActivityTests.swift diff --git a/Rosetta/Core/Services/CallActivityAttributes.swift b/Rosetta/Core/Services/CallActivityAttributes.swift index 0db9fa9..33c331f 100644 --- a/Rosetta/Core/Services/CallActivityAttributes.swift +++ b/Rosetta/Core/Services/CallActivityAttributes.swift @@ -4,7 +4,6 @@ import Foundation struct CallActivityAttributes: ActivityAttributes { let peerName: String let peerPublicKey: String - let avatarData: Data? let colorIndex: Int struct ContentState: Codable, Hashable { diff --git a/Rosetta/Core/Services/CallManager.swift b/Rosetta/Core/Services/CallManager.swift index 773d17f..2ef17fb 100644 --- a/Rosetta/Core/Services/CallManager.swift +++ b/Rosetta/Core/Services/CallManager.swift @@ -336,15 +336,19 @@ final class CallManager: NSObject, ObservableObject { print("[Call] LiveActivity DISABLED by user settings") return } - // Load peer avatar thumbnail for Live Activity - var avatarJpeg: Data? - if let avatar = AvatarRepository.shared.loadAvatar(publicKey: uiState.peerPublicKey) { - avatarJpeg = avatar.jpegData(compressionQuality: 0.5) + // Save peer avatar to App Group so widget extension can read it + if let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.rosetta.dev") { + let avatarURL = containerURL.appendingPathComponent("call_avatar.jpg") + if let avatar = AvatarRepository.shared.loadAvatar(publicKey: uiState.peerPublicKey), + let thumb = avatar.jpegData(compressionQuality: 0.4) { + try? thumb.write(to: avatarURL) + } else { + try? FileManager.default.removeItem(at: avatarURL) + } } let attributes = CallActivityAttributes( peerName: uiState.displayName, peerPublicKey: uiState.peerPublicKey, - avatarData: avatarJpeg, colorIndex: RosettaColors.avatarColorIndex(for: uiState.peerTitle, publicKey: uiState.peerPublicKey) ) let state = CallActivityAttributes.ContentState( diff --git a/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift b/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift index 75f5094..8472f05 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift @@ -59,7 +59,7 @@ struct MessageCellView: View, Equatable { } .modifier(ConditionalSwipeToReply( enabled: !isSavedMessages && !isSystemAccount - && !message.attachments.contains(where: { $0.type == .avatar || $0.type == .call || $0.type == .messages }), + && !message.attachments.contains(where: { $0.type == .avatar || $0.type == .messages }), onReply: { actions.onReply(message) } )) .overlay { @@ -545,10 +545,11 @@ struct MessageCellView: View, Equatable { private func bubbleActions(for message: ChatMessage) -> [BubbleContextAction] { var result: [BubbleContextAction] = [] - // Avatars, calls, and forwarded messages cannot be replied to or forwarded - let isAvatarOrForwarded = message.attachments.contains(where: { $0.type == .avatar || $0.type == .call || $0.type == .messages }) + // Desktop parity: system accounts + ATTACHMENTS_NOT_ALLOWED_TO_REPLY = [AVATAR, MESSAGES] + let isAvatarOrForwarded = message.attachments.contains(where: { $0.type == .avatar || $0.type == .messages }) + let canReplyForward = !isSavedMessages && !isSystemAccount && !isAvatarOrForwarded - if !isAvatarOrForwarded { + if canReplyForward { result.append(BubbleContextAction( title: "Reply", image: UIImage(systemName: "arrowshape.turn.up.left"), @@ -562,7 +563,7 @@ struct MessageCellView: View, Equatable { role: [] ) { actions.onCopy(message.text) }) - if !isAvatarOrForwarded { + if canReplyForward { result.append(BubbleContextAction( title: "Forward", image: UIImage(systemName: "arrowshape.turn.up.right"), diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift index 65069aa..bb7edad 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift @@ -147,6 +147,8 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel private var message: ChatMessage? private var actions: MessageCellActions? private var currentLayout: MessageCellLayout? + var isSavedMessages = false + var isSystemAccount = false private var isDeliveryFailedVisible = false private var wasSentCheckVisible = false private var wasReadCheckVisible = false @@ -985,9 +987,10 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel actions.onCopy(message.text) }) } - // Avatars, calls, and forwarded messages cannot be replied to or forwarded - let isAvatarOrForwarded = message.attachments.contains(where: { $0.type == .avatar || $0.type == .call || $0.type == .messages }) - if !isAvatarOrForwarded { + // Desktop parity: system accounts + ATTACHMENTS_NOT_ALLOWED_TO_REPLY = [AVATAR, MESSAGES] + let isAvatarOrForwarded = message.attachments.contains(where: { $0.type == .avatar || $0.type == .messages }) + let canReplyForward = !self.isSavedMessages && !self.isSystemAccount && !isAvatarOrForwarded + if canReplyForward { items.append(UIAction(title: "Reply", image: UIImage(systemName: "arrowshape.turn.up.left")) { _ in actions.onReply(message) }) @@ -1005,8 +1008,9 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel // MARK: - Swipe to Reply @objc private func handleSwipe(_ gesture: UIPanGestureRecognizer) { - // Block swipe on avatar, call, and forwarded-message attachments - let isReplyBlocked = message?.attachments.contains(where: { $0.type == .avatar || $0.type == .call || $0.type == .messages }) ?? false + // Desktop parity: system accounts + ATTACHMENTS_NOT_ALLOWED_TO_REPLY = [AVATAR, MESSAGES] + if isSavedMessages || isSystemAccount { return } + let isReplyBlocked = message?.attachments.contains(where: { $0.type == .avatar || $0.type == .messages }) ?? false if isReplyBlocked { return } let translation = gesture.translation(in: contentView) diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift index ef5b8da..662ce29 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift @@ -254,6 +254,8 @@ final class NativeMessageListController: UIViewController { } } + cell.isSavedMessages = self.config.isSavedMessages + cell.isSystemAccount = self.config.isSystemAccount cell.configure( message: message, timestamp: self.formatTimestamp(message.timestamp), diff --git a/RosettaLiveActivityWidget/CallActivityAttributes.swift b/RosettaLiveActivityWidget/CallActivityAttributes.swift index 0db9fa9..33c331f 100644 --- a/RosettaLiveActivityWidget/CallActivityAttributes.swift +++ b/RosettaLiveActivityWidget/CallActivityAttributes.swift @@ -4,7 +4,6 @@ import Foundation struct CallActivityAttributes: ActivityAttributes { let peerName: String let peerPublicKey: String - let avatarData: Data? let colorIndex: Int struct ContentState: Codable, Hashable { diff --git a/RosettaLiveActivityWidget/CallLiveActivity.swift b/RosettaLiveActivityWidget/CallLiveActivity.swift index fef9b23..cef7c9f 100644 --- a/RosettaLiveActivityWidget/CallLiveActivity.swift +++ b/RosettaLiveActivityWidget/CallLiveActivity.swift @@ -2,26 +2,28 @@ import ActivityKit import SwiftUI import WidgetKit -// Mantine v8 avatar palette (11 colors) — desktop parity -private let mantineAvatarTints: [Color] = [ - Color(red: 0.133, green: 0.545, blue: 0.902), // blue #228be6 - Color(red: 0.082, green: 0.667, blue: 0.749), // cyan #15aabf - Color(red: 0.745, green: 0.294, blue: 0.859), // grape #be4bdb - Color(red: 0.251, green: 0.753, blue: 0.341), // green #40c057 - Color(red: 0.298, green: 0.431, blue: 0.961), // indigo #4c6ef5 - Color(red: 0.510, green: 0.788, blue: 0.118), // lime #82c91e - Color(red: 0.992, green: 0.494, blue: 0.078), // orange #fd7e14 - Color(red: 0.902, green: 0.286, blue: 0.502), // pink #e64980 - Color(red: 0.980, green: 0.322, blue: 0.322), // red #fa5252 - Color(red: 0.071, green: 0.722, blue: 0.525), // teal #12b886 - Color(red: 0.475, green: 0.314, blue: 0.949), // violet #7950f2 +// Mantine v8 avatar tint colors (shade-6) — exact hex values, desktop parity +private let mantineTints: [Color] = [ + Color(red: 34/255, green: 139/255, blue: 230/255), // #228be6 blue + Color(red: 21/255, green: 170/255, blue: 191/255), // #15aabf cyan + Color(red: 190/255, green: 75/255, blue: 219/255), // #be4bdb grape + Color(red: 64/255, green: 192/255, blue: 87/255), // #40c057 green + Color(red: 76/255, green: 110/255, blue: 245/255), // #4c6ef5 indigo + Color(red: 130/255, green: 201/255, blue: 30/255), // #82c91e lime + Color(red: 253/255, green: 126/255, blue: 20/255), // #fd7e14 orange + Color(red: 230/255, green: 73/255, blue: 128/255), // #e64980 pink + Color(red: 250/255, green: 82/255, blue: 82/255), // #fa5252 red + Color(red: 18/255, green: 184/255, blue: 134/255), // #12b886 teal + Color(red: 121/255, green: 80/255, blue: 242/255), // #7950f2 violet ] +private let appGroupID = "group.com.rosetta.dev" + @main struct CallLiveActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: CallActivityAttributes.self) { context in - // MARK: - Lock Screen Banner + // MARK: - Lock Screen HStack(spacing: 14) { avatarView(context: context, size: 48, fontSize: 18) @@ -37,7 +39,7 @@ struct CallLiveActivity: Widget { .foregroundColor(Color(white: 0.45)) if context.state.isActive { - Text(duration(context.state.durationSec)) + Text(fmt(context.state.durationSec)) .font(.system(size: 14, weight: .medium).monospacedDigit()) .foregroundColor(Color(white: 0.55)) } else { @@ -53,9 +55,7 @@ struct CallLiveActivity: Widget { } } } - Spacer() - ZStack { Circle().fill(Color.red) Image(systemName: "phone.down.fill") @@ -76,11 +76,10 @@ struct CallLiveActivity: Widget { .padding(.leading, 6) .padding(.top, 4) } - DynamicIslandExpandedRegion(.trailing) { VStack(spacing: 2) { if context.state.isActive { - Text(duration(context.state.durationSec)) + Text(fmt(context.state.durationSec)) .font(.system(size: 16, weight: .bold).monospacedDigit()) .foregroundColor(.green) } else { @@ -88,7 +87,6 @@ struct CallLiveActivity: Widget { .font(.system(size: 16, weight: .bold)) .foregroundColor(.orange) } - if context.state.isMuted { Image(systemName: "mic.slash.fill") .font(.system(size: 11)) @@ -98,7 +96,6 @@ struct CallLiveActivity: Widget { .padding(.trailing, 6) .padding(.top, 4) } - DynamicIslandExpandedRegion(.center) { Text(context.attributes.peerName) .font(.system(size: 16, weight: .semibold)) @@ -106,7 +103,6 @@ struct CallLiveActivity: Widget { .lineLimit(1) .padding(.top, 4) } - DynamicIslandExpandedRegion(.bottom) { HStack(spacing: 5) { Circle() @@ -121,13 +117,11 @@ struct CallLiveActivity: Widget { } .padding(.top, 4) } - } compactLeading: { avatarView(context: context, size: 26, fontSize: 10) - } compactTrailing: { if context.state.isActive { - Text(duration(context.state.durationSec)) + Text(fmt(context.state.durationSec)) .font(.system(size: 13, weight: .semibold).monospacedDigit()) .foregroundColor(.green) } else { @@ -135,7 +129,6 @@ struct CallLiveActivity: Widget { .font(.system(size: 12)) .foregroundColor(.green) } - } minimal: { Image(systemName: "phone.fill") .font(.system(size: 11)) @@ -148,19 +141,17 @@ struct CallLiveActivity: Widget { @ViewBuilder private func avatarView(context: ActivityViewContext, size: CGFloat, fontSize: CGFloat) -> some View { - if let data = context.attributes.avatarData, - let uiImage = UIImage(data: data) { - Image(uiImage: uiImage) + if let img = loadSharedAvatar() { + Image(uiImage: img) .resizable() .scaledToFill() .frame(width: size, height: size) .clipShape(Circle()) } else { - let idx = context.attributes.colorIndex - let color = mantineAvatarTints[idx < mantineAvatarTints.count ? idx : 0] + let idx = min(context.attributes.colorIndex, mantineTints.count - 1) ZStack { - Circle().fill(color) - Text(initials(from: context.attributes.peerName, publicKey: context.attributes.peerPublicKey)) + Circle().fill(mantineTints[max(0, idx)]) + Text(makeInitials(context.attributes.peerName, context.attributes.peerPublicKey)) .font(.system(size: fontSize, weight: .bold)) .foregroundColor(.white) } @@ -168,25 +159,31 @@ struct CallLiveActivity: Widget { } } + private func loadSharedAvatar() -> UIImage? { + guard let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupID)? + .appendingPathComponent("call_avatar.jpg"), + let data = try? Data(contentsOf: url), + let img = UIImage(data: data) else { return nil } + return img + } + // MARK: - Helpers - private func duration(_ sec: Int) -> String { + private func fmt(_ sec: Int) -> String { String(format: "%d:%02d", sec / 60, sec % 60) } - private func initials(from name: String, publicKey: String) -> String { + private func makeInitials(_ name: String, _ pubKey: String) -> String { let words = name.trimmingCharacters(in: .whitespaces) .split(whereSeparator: { $0.isWhitespace }) .filter { !$0.isEmpty } switch words.count { case 0: - return publicKey.isEmpty ? "?" : String(publicKey.prefix(2)).uppercased() + return pubKey.isEmpty ? "?" : String(pubKey.prefix(2)).uppercased() case 1: return String(words[0].prefix(2)).uppercased() default: - let first = words[0].first.map(String.init) ?? "" - let second = words[1].first.map(String.init) ?? "" - return (first + second).uppercased() + return "\(words[0].prefix(1))\(words[1].prefix(1))".uppercased() } } } diff --git a/RosettaTests/CallLiveActivityTests.swift b/RosettaTests/CallLiveActivityTests.swift new file mode 100644 index 0000000..1a4177f --- /dev/null +++ b/RosettaTests/CallLiveActivityTests.swift @@ -0,0 +1,192 @@ +import ActivityKit +import XCTest +@testable import Rosetta + +@MainActor +final class CallLiveActivityTests: XCTestCase { + private let ownKey = "02-own-la-test" + private let peerKey = "02-peer-la-test" + + override func setUp() { + super.setUp() + CallManager.shared.resetForSessionEnd() + CallManager.shared.bindAccount(publicKey: ownKey) + } + + override func tearDown() { + CallManager.shared.resetForSessionEnd() + super.tearDown() + } + + // MARK: - CallActivityAttributes + + func testCallActivityAttributesCodableRoundTrip() throws { + let attrs = CallActivityAttributes( + peerName: "Test User", + peerPublicKey: "02abcdef1234567890", + colorIndex: 5 + ) + + let encoder = JSONEncoder() + let data = try encoder.encode(attrs) + let decoder = JSONDecoder() + let decoded = try decoder.decode(CallActivityAttributes.self, from: data) + + XCTAssertEqual(decoded.peerName, "Test User") + XCTAssertEqual(decoded.peerPublicKey, "02abcdef1234567890") + XCTAssertEqual(decoded.colorIndex, 5) + } + + func testCallActivityContentStateCodableRoundTrip() throws { + let state = CallActivityAttributes.ContentState( + durationSec: 125, + isActive: true, + isMuted: false + ) + + let data = try JSONEncoder().encode(state) + let decoded = try JSONDecoder().decode(CallActivityAttributes.ContentState.self, from: data) + + XCTAssertEqual(decoded.durationSec, 125) + XCTAssertTrue(decoded.isActive) + XCTAssertFalse(decoded.isMuted) + } + + func testAttributesSizeUnderActivityKitLimit() throws { + // ActivityKit limit is ~4KB for attributes + let attrs = CallActivityAttributes( + peerName: "Very Long Name That Could Be A Display Name In Any Language", + peerPublicKey: "02abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + colorIndex: 10 + ) + + let data = try JSONEncoder().encode(attrs) + XCTAssertLessThan(data.count, 4096, "Attributes must be under 4KB ActivityKit limit") + } + + // MARK: - Color Index Parity + + func testColorIndexMatchesRosettaColors() { + let names = ["Alice", "Bob", "Enfants Riches", "Baragoz", "0333331", ""] + let keys = ["02abc", "02def", "02123", "02456", "02789", "02000"] + + for (name, key) in zip(names, keys) { + let expected = RosettaColors.avatarColorIndex(for: name, publicKey: key) + XCTAssertGreaterThanOrEqual(expected, 0) + XCTAssertLessThan(expected, RosettaColors.avatarColors.count) + } + } + + func testColorIndexDeterministic() { + let idx1 = RosettaColors.avatarColorIndex(for: "Test", publicKey: "02abc") + let idx2 = RosettaColors.avatarColorIndex(for: "Test", publicKey: "02abc") + XCTAssertEqual(idx1, idx2, "Color index must be deterministic") + } + + func testColorIndexDiffersForDifferentNames() { + let idx1 = RosettaColors.avatarColorIndex(for: "Alice", publicKey: "02abc") + let idx2 = RosettaColors.avatarColorIndex(for: "Bob", publicKey: "02abc") + // Not guaranteed to differ for all inputs, but these specific ones should + // Just verify they're valid + XCTAssertGreaterThanOrEqual(idx1, 0) + XCTAssertGreaterThanOrEqual(idx2, 0) + } + + func testColorIndexFallsBackToPublicKey() { + let idxEmpty = RosettaColors.avatarColorIndex(for: "", publicKey: "02abcdef") + let idxSpaces = RosettaColors.avatarColorIndex(for: " ", publicKey: "02abcdef") + XCTAssertEqual(idxEmpty, idxSpaces, "Empty name and spaces-only should both fall back to publicKey") + } + + // MARK: - Initials Parity + + func testInitialsTwoWords() { + let result = RosettaColors.initials(name: "Enfants Riches", publicKey: "02abc") + XCTAssertEqual(result, "ER") + } + + func testInitialsSingleWord() { + let result = RosettaColors.initials(name: "Baragoz", publicKey: "02abc") + XCTAssertEqual(result, "BA") + } + + func testInitialsEmptyName() { + let result = RosettaColors.initials(name: "", publicKey: "02abcdef") + XCTAssertEqual(result, "02") + } + + func testInitialsThreeWords() { + let result = RosettaColors.initials(name: "John Paul Smith", publicKey: "02abc") + XCTAssertEqual(result, "JP") + } + + // MARK: - Live Activity Lifecycle + + func testLiveActivityCreatedOnOutgoingCall() { + let result = CallManager.shared.startOutgoingCall( + toPublicKey: peerKey, + title: "Peer", + username: "peer" + ) + XCTAssertEqual(result, .started) + // Live Activity may or may not start depending on simulator capabilities + // but the code path should not crash + } + + func testLiveActivityCreatedOnIncomingCall() { + let packet = PacketSignalPeer( + src: peerKey, + dst: ownKey, + sharedPublic: "", + signalType: .call, + roomId: "" + ) + CallManager.shared.testHandleSignalPacket(packet) + XCTAssertEqual(CallManager.shared.uiState.phase, .incoming) + // Live Activity start called without crash + } + + func testLiveActivityEndedOnFinishCall() { + _ = CallManager.shared.startOutgoingCall( + toPublicKey: peerKey, + title: "Peer", + username: "peer" + ) + CallManager.shared.endCall() + XCTAssertNil(CallManager.shared.liveActivity, "Live Activity should be nil after endCall") + } + + // MARK: - App Group Avatar + + func testAppGroupAvatarFileCreatedAndCleaned() { + guard let containerURL = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: "group.com.rosetta.dev" + ) else { + // App Group not available in test environment — skip + return + } + + let avatarURL = containerURL.appendingPathComponent("call_avatar.jpg") + + // Clean up + try? FileManager.default.removeItem(at: avatarURL) + XCTAssertFalse(FileManager.default.fileExists(atPath: avatarURL.path)) + + // Write test data + let testData = Data([0xFF, 0xD8, 0xFF, 0xE0]) // JPEG header + try? testData.write(to: avatarURL) + XCTAssertTrue(FileManager.default.fileExists(atPath: avatarURL.path)) + + // Clean up + try? FileManager.default.removeItem(at: avatarURL) + XCTAssertFalse(FileManager.default.fileExists(atPath: avatarURL.path)) + } + + // MARK: - Sound Manager + + func testCallSoundManagerSingleton() { + let a = CallSoundManager.shared + let b = CallSoundManager.shared + XCTAssertTrue(a === b, "CallSoundManager must be singleton") + } +}