encryptWithPassword возвращён к SHA256+rawDeflate (iOS-only данные)

Добавлен encryptWithPasswordDesktopCompat (SHA1+zlibDeflate) для кросс-платформенных данных (aesChachaKey, аватар)
3 вызова в SessionManager переведены на desktop-compatible путь
Добавлен Notification.Name.profileDidUpdate для мгновенного обновления имени в Settings
Удалены debug-логи из CryptoManager и SessionManager
This commit is contained in:
2026-03-15 03:50:56 +05:00
parent acc3fb8e2f
commit dd4642f251
48 changed files with 3865 additions and 517 deletions

View File

@@ -10,6 +10,34 @@ private struct ComposerHeightKey: PreferenceKey {
}
}
/// Reads keyboardPadding in its own observation scope
/// parent body is NOT re-evaluated on padding changes.
private struct KeyboardSpacer: View {
@ObservedObject private var keyboard = KeyboardTracker.shared
let composerHeight: CGFloat
var body: some View {
Color.clear.frame(height: composerHeight + keyboard.keyboardPadding + 4)
}
}
/// Applies keyboard bottom padding in an isolated observation scope.
/// Parent view is NOT marked dirty when keyboardPadding changes.
private struct KeyboardPaddedView<Content: View>: View {
@ObservedObject private var keyboard = KeyboardTracker.shared
let extraPadding: CGFloat
let content: Content
init(extraPadding: CGFloat = 0, @ViewBuilder content: () -> Content) {
self.extraPadding = extraPadding
self.content = content()
}
var body: some View {
content.offset(y: -(keyboard.keyboardPadding + extraPadding))
}
}
struct ChatDetailView: View {
let route: ChatRoute
var onPresentedChange: ((Bool) -> Void)? = nil
@@ -30,8 +58,10 @@ struct ChatDetailView: View {
@State private var isInputFocused = false
@State private var isAtBottom = true
@State private var composerHeight: CGFloat = 56
@StateObject private var keyboard = KeyboardTracker()
@State private var shouldScrollOnNextMessage = false
/// Captured on chat open ID of the first unread incoming message (for separator).
@State private var firstUnreadMessageId: String?
@State private var isSendingAvatar = false
private var currentPublicKey: String {
SessionManager.shared.currentPublicKey
@@ -66,6 +96,8 @@ struct ChatDetailView: View {
private var subtitleText: String {
if route.isSavedMessages { return "" }
// Desktop parity: system accounts show "official account" instead of online/offline
if route.isSystemAccount { return "official account" }
if isTyping { return "typing..." }
if let dialog, dialog.isOnline { return "online" }
return "offline"
@@ -115,20 +147,38 @@ struct ChatDetailView: View {
}
.overlay { chatEdgeGradients }
.overlay(alignment: .bottom) {
composer
.background(
GeometryReader { geo in
Color.clear.preference(key: ComposerHeightKey.self, value: geo.size.height)
}
)
.padding(.bottom, keyboard.keyboardPadding)
if !route.isSystemAccount {
KeyboardPaddedView {
composer
.background(
GeometryReader { geo in
Color.clear.preference(key: ComposerHeightKey.self, value: geo.size.height)
}
)
}
}
}
.onPreferenceChange(ComposerHeightKey.self) { composerHeight = $0 }
.ignoresSafeArea(.keyboard)
.onPreferenceChange(ComposerHeightKey.self) { newHeight in
composerHeight = newHeight
}
.modifier(IgnoreKeyboardSafeAreaLegacy())
.background {
ZStack {
ZStack(alignment: .bottom) {
RosettaColors.Adaptive.background
tiledChatBackground
// Telegram-style: dark gradient at screen bottom (home indicator area).
// In background (not overlay) so it never moves with keyboard.
if #unavailable(iOS 26) {
LinearGradient(
colors: [
Color.black.opacity(0.0),
Color.black.opacity(0.55)
],
startPoint: .top,
endPoint: .bottom
)
.frame(height: 34)
}
}
.ignoresSafeArea()
}
@@ -140,6 +190,12 @@ struct ChatDetailView: View {
.toolbar(.hidden, for: .tabBar)
.task {
isViewActive = true
// Capture first unread incoming message BEFORE marking as read.
if firstUnreadMessageId == nil {
firstUnreadMessageId = messages.first(where: {
!$0.isRead && $0.fromPublicKey != currentPublicKey
})?.id
}
// Desktop parity: restore draft text from DraftManager.
let draft = DraftManager.shared.getDraft(for: route.publicKey)
if !draft.isEmpty {
@@ -161,23 +217,29 @@ struct ChatDetailView: View {
guard isViewActive else { return }
activateDialog()
markDialogAsRead()
// Subscribe to opponent's online status (Android parity) only after settled
SessionManager.shared.subscribeToOnlineStatus(publicKey: route.publicKey)
// Desktop parity: force-refresh user info (incl. online status) on chat open.
// PacketSearch (0x03) returns current online state, supplementing 0x05 subscription.
if !route.isSavedMessages {
SessionManager.shared.forceRefreshUserInfo(publicKey: route.publicKey)
// Desktop parity: skip online subscription and user info fetch for system accounts
if !route.isSystemAccount {
// Subscribe to opponent's online status (Android parity) only after settled
SessionManager.shared.subscribeToOnlineStatus(publicKey: route.publicKey)
// Desktop parity: force-refresh user info (incl. online status) on chat open.
// PacketSearch (0x03) returns current online state, supplementing 0x05 subscription.
if !route.isSavedMessages {
SessionManager.shared.forceRefreshUserInfo(publicKey: route.publicKey)
}
}
}
.onDisappear {
isViewActive = false
firstUnreadMessageId = nil
MessageRepository.shared.setDialogActive(route.publicKey, isActive: false)
// Desktop parity: save draft text on chat close.
DraftManager.shared.saveDraft(for: route.publicKey, text: messageText)
}
}
var body: some View { content }
var body: some View {
content
}
}
private extension ChatDetailView {
@@ -244,7 +306,8 @@ private extension ChatDetailView {
colorIndex: avatarColorIndex,
size: 35,
isOnline: false,
isSavedMessages: route.isSavedMessages
isSavedMessages: route.isSavedMessages,
image: opponentAvatar
)
.frame(width: 36, height: 36)
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
@@ -300,7 +363,8 @@ private extension ChatDetailView {
colorIndex: avatarColorIndex,
size: 38,
isOnline: false,
isSavedMessages: route.isSavedMessages
isSavedMessages: route.isSavedMessages,
image: opponentAvatar
)
.frame(width: 44, height: 44)
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
@@ -335,6 +399,11 @@ private extension ChatDetailView {
RosettaColors.avatarColorIndex(for: titleText, publicKey: route.publicKey)
}
/// Avatar image for the opponent. System accounts return a bundled static image.
var opponentAvatar: UIImage? {
AvatarRepository.shared.loadAvatar(publicKey: route.publicKey)
}
var incomingBubbleFill: Color {
RosettaColors.adaptive(light: Color(hex: 0x2C2C2E), dark: Color(hex: 0x2C2C2E))
}
@@ -415,7 +484,8 @@ private extension ChatDetailView {
colorIndex: avatarColorIndex,
size: 80,
isOnline: dialog?.isOnline ?? false,
isSavedMessages: route.isSavedMessages
isSavedMessages: route.isSavedMessages,
image: opponentAvatar
)
VStack(spacing: 4) {
@@ -456,8 +526,8 @@ private extension ChatDetailView {
// Spacer for composer + keyboard OUTSIDE LazyVStack so padding
// changes only shift the LazyVStack as a whole block (cheap),
// instead of re-laying out every cell inside it (expensive).
Color.clear
.frame(height: composerHeight + keyboard.keyboardPadding + 4)
// Isolated in KeyboardSpacer to avoid marking parent dirty.
KeyboardSpacer(composerHeight: composerHeight)
// LazyVStack: only visible cells are loaded. Internal layout
// is unaffected by the spacer above changing height.
@@ -479,6 +549,13 @@ private extension ChatDetailView {
)
.scaleEffect(x: 1, y: -1) // flip each row back to normal
.id(message.id)
// Unread Messages separator (Telegram style).
// In inverted scroll, "above" visually = after in code.
if message.id == firstUnreadMessageId {
unreadSeparator
.scaleEffect(x: 1, y: -1)
}
}
}
}
@@ -512,7 +589,9 @@ private extension ChatDetailView {
scroll
.scrollIndicators(.hidden)
.overlay(alignment: .bottom) {
scrollToBottomButton(proxy: proxy)
KeyboardPaddedView(extraPadding: composerHeight + 4) {
scrollToBottomButton(proxy: proxy)
}
}
}
}
@@ -542,7 +621,6 @@ private extension ChatDetailView {
.transition(.scale(scale: 0.01, anchor: .center).combined(with: .opacity))
}
}
.padding(.bottom, composerHeight + keyboard.keyboardPadding + 4)
.padding(.trailing, composerTrailingPadding)
.allowsHitTesting(!isAtBottom)
}
@@ -555,7 +633,7 @@ private extension ChatDetailView {
// Telegram-style compact bubble: inline time+status at bottom-trailing.
// Right padding reserves space for the timestamp overlay (64pt outgoing, 48pt incoming).
Text(messageText)
Text(parsedMarkdown(messageText))
.font(.system(size: 17, weight: .regular))
.tracking(-0.43)
.foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text)
@@ -600,6 +678,34 @@ private extension ChatDetailView {
.padding(.bottom, 0)
}
// MARK: - Markdown Parsing
/// Parses inline markdown (`**bold**`) from runtime strings.
/// Falls back to plain `AttributedString` if parsing fails.
private func parsedMarkdown(_ text: String) -> AttributedString {
if let parsed = try? AttributedString(
markdown: text,
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
) {
return parsed
}
return AttributedString(text)
}
// MARK: - Unread Separator
private var unreadSeparator: some View {
Text("Unread Messages")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.white.opacity(0.7))
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(Color.white.opacity(0.08))
.padding(.horizontal, -10) // compensate scroll content padding
.padding(.top, 6)
.padding(.bottom, 2)
}
// MARK: - Composer
var composer: some View {
@@ -613,8 +719,15 @@ private extension ChatDetailView {
}
HStack(alignment: .bottom, spacing: 0) {
Button {
// Placeholder for attachment picker
// Desktop parity: paperclip opens attachment menu with camera option.
// Camera sends current user's avatar to this chat.
Menu {
Button {
sendAvatarToChat()
} label: {
Label("Send Avatar", systemImage: "camera.fill")
}
.disabled(isSendingAvatar)
} label: {
TelegramVectorIcon(
pathData: TelegramIconPath.paperclip,
@@ -633,7 +746,7 @@ private extension ChatDetailView {
text: $messageText,
isFocused: $isInputFocused,
onKeyboardHeightChange: { height in
keyboard.updateFromKVO(keyboardHeight: height)
KeyboardTracker.shared.updateFromKVO(keyboardHeight: height)
},
onUserTextInsertion: handleComposerUserTyping,
textColor: UIColor(RosettaColors.Adaptive.text),
@@ -724,26 +837,6 @@ private extension ChatDetailView {
.animation(composerAnimation, value: canSend)
.animation(composerAnimation, value: shouldShowSendButton)
}
.background {
if #available(iOS 26, *) {
Color.clear
} else {
// Telegram-style: dark gradient below composer home indicator
VStack(spacing: 0) {
Spacer()
LinearGradient(
colors: [
Color.black.opacity(0.0),
Color.black.opacity(0.55)
],
startPoint: .top,
endPoint: .bottom
)
.frame(height: 34)
}
.ignoresSafeArea(edges: .bottom)
}
}
}
// MARK: - Bubble Position (Figma: Single / Top / Mid / Bottom)
@@ -957,7 +1050,10 @@ private extension ChatDetailView {
func markDialogAsRead() {
DialogRepository.shared.markAsRead(opponentKey: route.publicKey)
MessageRepository.shared.markIncomingAsRead(opponentKey: route.publicKey, myPublicKey: currentPublicKey)
SessionManager.shared.sendReadReceipt(toPublicKey: route.publicKey)
// Desktop parity: don't send read receipts for system accounts
if !route.isSystemAccount {
SessionManager.shared.sendReadReceipt(toPublicKey: route.publicKey)
}
}
/// Remove all delivered push notifications from this specific sender.
@@ -1001,6 +1097,26 @@ private extension ChatDetailView {
}
}
/// Desktop parity: onClickCamera() sends current user's avatar to this chat.
func sendAvatarToChat() {
guard !isSendingAvatar else { return }
isSendingAvatar = true
sendError = nil
Task { @MainActor in
do {
try await SessionManager.shared.sendAvatar(
toPublicKey: route.publicKey,
opponentTitle: route.title,
opponentUsername: route.username
)
} catch {
sendError = "Failed to send avatar"
}
isSendingAvatar = false
}
}
func handleComposerUserTyping() {
SessionManager.shared.sendTypingIndicator(toPublicKey: route.publicKey)
}
@@ -1286,6 +1402,18 @@ private enum TelegramIconPath {
static let microphone = #"M3.69141 5.09766C3.69141 4.16016 3.91602 3.30078 4.36523 2.51953C4.79492 1.75781 5.38086 1.14258 6.12305 0.673828C6.88477 0.224609 7.70508 0 8.58398 0C9.44336 0 10.2441 0.214844 10.9863 0.644531C11.7285 1.07422 12.3145 1.66016 12.7441 2.40234C13.1934 3.16406 13.4375 3.98438 13.4766 4.86328V5.09766V10.8105C13.4766 11.748 13.252 12.6074 12.8027 13.3887C12.373 14.1504 11.7871 14.7559 11.0449 15.2051C10.2832 15.6738 9.46289 15.9082 8.58398 15.9082C7.72461 15.9082 6.92383 15.6934 6.18164 15.2637C5.43945 14.834 4.85352 14.248 4.42383 13.5059C3.97461 12.7441 3.73047 11.9238 3.69141 11.0449V10.8105V5.09766ZM8.58398 1.58203C7.99805 1.58203 7.45117 1.72852 6.94336 2.02148C6.43555 2.31445 6.03516 2.71484 5.74219 3.22266C5.42969 3.73047 5.25391 4.28711 5.21484 4.89258V5.09766V10.8105C5.21484 11.4551 5.37109 12.0508 5.68359 12.5977C5.97656 13.125 6.37695 13.5449 6.88477 13.8574C7.41211 14.1699 7.97852 14.3262 8.58398 14.3262C9.16992 14.3262 9.7168 14.1797 10.2246 13.8867C10.7324 13.5938 11.1328 13.1934 11.4258 12.6855C11.7383 12.1777 11.9141 11.6211 11.9531 11.0156V10.8105V5.09766C11.9531 4.45312 11.7969 3.85742 11.4844 3.31055C11.1914 2.7832 10.791 2.36328 10.2832 2.05078C9.75586 1.73828 9.18945 1.58203 8.58398 1.58203ZM9.3457 19.7168V22.7637C9.3457 22.9785 9.26758 23.1641 9.11133 23.3203C8.97461 23.4766 8.79883 23.5547 8.58398 23.5547C8.38867 23.5547 8.22266 23.4863 8.08594 23.3496C7.92969 23.2324 7.8418 23.0762 7.82227 22.8809V22.7637V19.7168C6.74805 19.5996 5.72266 19.2969 4.74609 18.8086C3.80859 18.3203 2.98828 17.666 2.28516 16.8457C1.5625 16.0449 1.00586 15.1367 0.615234 14.1211C0.205078 13.0664 0 11.9629 0 10.8105C0 10.5957 0.078125 10.4102 0.234375 10.2539C0.390625 10.0977 0.566406 10.0195 0.761719 10.0195C0.976562 10.0195 1.16211 10.0977 1.31836 10.2539C1.45508 10.4102 1.52344 10.5957 1.52344 10.8105C1.52344 11.8066 1.70898 12.7637 2.08008 13.6816C2.45117 14.5605 2.95898 15.332 3.60352 15.9961C4.24805 16.6797 4.99023 17.207 5.83008 17.5781C6.70898 17.9688 7.62695 18.1641 8.58398 18.1641C9.54102 18.1641 10.459 17.9688 11.3379 17.5781C12.1777 17.207 12.9199 16.6797 13.5645 15.9961C14.209 15.332 14.7168 14.5605 15.0879 13.6816C15.459 12.7637 15.6445 11.8066 15.6445 10.8105C15.6445 10.5957 15.7129 10.4102 15.8496 10.2539C16.0059 10.0977 16.1914 10.0195 16.4062 10.0195C16.6016 10.0195 16.7773 10.0977 16.9336 10.2539C17.0898 10.4102 17.168 10.5957 17.168 10.8105C17.168 11.9629 16.9629 13.0664 16.5527 14.1211C16.1621 15.1367 15.6055 16.0449 14.8828 16.8457C14.1797 17.666 13.3594 18.3203 12.4219 18.8086C11.4453 19.2969 10.4199 19.5996 9.3457 19.7168Z"#
}
/// iOS < 26: ignore keyboard safe area (manual KeyboardTracker handles offset).
/// iOS 26+: let SwiftUI handle keyboard natively no manual tracking.
private struct IgnoreKeyboardSafeAreaLegacy: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 26, *) {
content
} else {
content.ignoresSafeArea(.keyboard)
}
}
}
#Preview {
NavigationStack {