From adad5b8b830d176edfa1ffb8ca54b5e716b65e69 Mon Sep 17 00:00:00 2001 From: senseiGai Date: Thu, 9 Apr 2026 20:21:24 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=B8=D0=BA=D1=81:=20peer=20profile=20?= =?UTF-8?q?=E2=80=94=20offline=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81,=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BF=D1=80=D0=B5=D1=82=20=D1=80=D0=B0=D1=81=D1=88?= =?UTF-8?q?=D0=B8=D1=80=D0=B5=D0=BD=D0=B8=D1=8F=20letter-=D0=B0=D0=B2?= =?UTF-8?q?=D0=B0=D1=82=D0=B0=D1=80=D0=B0,=20=D1=86=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D1=80=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20sticky?= =?UTF-8?q?=20title,=20=D0=B0=D0=B4=D0=B0=D0=BF=D1=82=D0=B8=D0=B2=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20chevron?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- Rosetta.xcodeproj/project.pbxproj | 18 +- .../xcshareddata/xcschemes/Rosetta.xcscheme | 2 +- .../Data/Repositories/GroupRepository.swift | 50 ++ Rosetta/Core/Layout/MessageCellLayout.swift | 31 +- .../Core/Services/InAppBannerManager.swift | 6 + .../DesignSystem/Components/GlassCard.swift | 40 + .../Components/InAppBannerView.swift | 68 +- .../Components/VerifiedBadge.swift | 28 +- Rosetta/DesignSystem/Helpers/AnchorKey.swift | 8 + .../DesignSystem/Helpers/OffsetHelper.swift | 23 + .../Chats/ChatDetail/ChatDetailView.swift | 196 ++++- .../Chats/ChatDetail/CoreTextLabel.swift | 3 +- .../ChatDetail/FullScreenImageViewer.swift | 236 ------ .../Gallery/GalleryDismissController.swift | 73 ++ .../Gallery/GalleryHeroAnimator.swift | 238 ++++++ .../ChatDetail/Gallery/GalleryMenuPopup.swift | 169 ++++ .../Gallery/GalleryOverlayView.swift | 332 ++++++++ .../Gallery/GalleryPageScrollView.swift | 187 +++++ .../Gallery/GalleryViewController.swift | 432 ++++++++++ .../ChatDetail/Gallery/GalleryZoomPage.swift | 266 +++++++ .../Chats/ChatDetail/ImageGalleryViewer.swift | 387 +-------- .../Chats/ChatDetail/MessageCellActions.swift | 7 +- .../Chats/ChatDetail/MessageCellView.swift | 31 +- .../Chats/ChatDetail/NativeMessageCell.swift | 296 ++++++- .../Chats/ChatDetail/NativeMessageList.swift | 81 ++ .../ChatDetail/OpponentProfileView.swift | 541 +++++++++---- .../ChatDetail/PeerProfileHeaderView.swift | 213 +++++ .../ChatDetail/PeerProfileViewModel.swift | 148 ++++ .../TelegramContextMenuCardView.swift | 108 ++- .../TelegramContextMenuController.swift | 8 + .../Chats/ChatDetail/ZoomableImagePage.swift | 142 ---- .../Features/Groups/EncryptionKeyView.swift | 163 ++++ Rosetta/Features/Groups/GroupEditView.swift | 181 +++++ Rosetta/Features/Groups/GroupInfoView.swift | 751 +++++++++++++++--- .../Features/Groups/GroupInfoViewModel.swift | 148 +++- Rosetta/Features/Groups/GroupMemberRow.swift | 52 +- .../Features/Groups/SharedMediaSection.swift | 56 ++ .../Settings/DynamicIslandBlurView.swift | 148 ++++ .../Settings/SettingsProfileHeader.swift | 101 +++ Rosetta/Features/Settings/SettingsView.swift | 183 +++-- Rosetta/RosettaApp.swift | 40 +- 42 files changed, 4947 insertions(+), 1247 deletions(-) create mode 100644 Rosetta/DesignSystem/Helpers/AnchorKey.swift create mode 100644 Rosetta/DesignSystem/Helpers/OffsetHelper.swift delete mode 100644 Rosetta/Features/Chats/ChatDetail/FullScreenImageViewer.swift create mode 100644 Rosetta/Features/Chats/ChatDetail/Gallery/GalleryDismissController.swift create mode 100644 Rosetta/Features/Chats/ChatDetail/Gallery/GalleryHeroAnimator.swift create mode 100644 Rosetta/Features/Chats/ChatDetail/Gallery/GalleryMenuPopup.swift create mode 100644 Rosetta/Features/Chats/ChatDetail/Gallery/GalleryOverlayView.swift create mode 100644 Rosetta/Features/Chats/ChatDetail/Gallery/GalleryPageScrollView.swift create mode 100644 Rosetta/Features/Chats/ChatDetail/Gallery/GalleryViewController.swift create mode 100644 Rosetta/Features/Chats/ChatDetail/Gallery/GalleryZoomPage.swift create mode 100644 Rosetta/Features/Chats/ChatDetail/PeerProfileHeaderView.swift create mode 100644 Rosetta/Features/Chats/ChatDetail/PeerProfileViewModel.swift delete mode 100644 Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift create mode 100644 Rosetta/Features/Groups/EncryptionKeyView.swift create mode 100644 Rosetta/Features/Groups/GroupEditView.swift create mode 100644 Rosetta/Features/Groups/SharedMediaSection.swift create mode 100644 Rosetta/Features/Settings/DynamicIslandBlurView.swift create mode 100644 Rosetta/Features/Settings/SettingsProfileHeader.swift diff --git a/.gitignore b/.gitignore index b6f20c1..88ef4e3 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,8 @@ Telegram-iOS AGENTS.md voip.p12 CertificateSigningRequest.certSigningRequest -PhotosTransition +TelegramDynamicIslandHeader +TelegramHeader # Xcode build/ diff --git a/Rosetta.xcodeproj/project.pbxproj b/Rosetta.xcodeproj/project.pbxproj index c797e56..a3e61fc 100644 --- a/Rosetta.xcodeproj/project.pbxproj +++ b/Rosetta.xcodeproj/project.pbxproj @@ -641,7 +641,7 @@ CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 36; + CURRENT_PROJECT_VERSION = 37; DEVELOPMENT_TEAM = QN8Z263QGX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -657,7 +657,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.5; + MARKETING_VERSION = 1.3.6; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -681,7 +681,7 @@ CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 36; + CURRENT_PROJECT_VERSION = 37; DEVELOPMENT_TEAM = QN8Z263QGX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -697,7 +697,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.5; + MARKETING_VERSION = 1.3.6; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -841,7 +841,7 @@ C19929D9466573F31997B2C0 /* Release */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; + defaultConfigurationName = Debug; }; 853F295D2F4B50410092AD05 /* Build configuration list for PBXProject "Rosetta" */ = { isa = XCConfigurationList; @@ -850,7 +850,7 @@ 853F296C2F4B50420092AD05 /* Release */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; + defaultConfigurationName = Debug; }; 853F296D2F4B50420092AD05 /* Build configuration list for PBXNativeTarget "Rosetta" */ = { isa = XCConfigurationList; @@ -859,7 +859,7 @@ 853F296F2F4B50420092AD05 /* Release */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; + defaultConfigurationName = Debug; }; B5D2E60ADEB8AE2E8F7615C6 /* Build configuration list for PBXNativeTarget "RosettaNotificationService" */ = { isa = XCConfigurationList; @@ -868,7 +868,7 @@ 0140D6320A9CF4B5E933E0B1 /* Debug */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; + defaultConfigurationName = Debug; }; LA00000062F8D22220092AD05 /* Build configuration list for PBXNativeTarget "RosettaLiveActivityWidget" */ = { isa = XCConfigurationList; @@ -877,7 +877,7 @@ LA00000082F8D22220092AD05 /* Release */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; + defaultConfigurationName = Debug; }; /* End XCConfigurationList section */ diff --git a/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme b/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme index a4e863c..39a5209 100644 --- a/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme +++ b/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme @@ -66,7 +66,7 @@ String { @@ -39,6 +41,11 @@ final class GroupRepository { let description: String } + struct CachedGroupMembers: Equatable { + let memberKeys: [String] + let adminKey: String + } + struct ParsedGroupInvite: Sendable { let groupId: String let title: String @@ -164,6 +171,25 @@ final class GroupRepository { } } + // MARK: - Member Cache + + func cachedMembers(account: String, groupDialogKey: String) -> CachedGroupMembers? { + let groupId = normalizeGroupId(groupDialogKey) + guard !groupId.isEmpty else { return nil } + let ck = cacheKey(account: account, groupId: groupId) + return memberCache[ck] + } + + func updateMemberCache(account: String, groupDialogKey: String, memberKeys: [String]) { + let groupId = normalizeGroupId(groupDialogKey) + guard !groupId.isEmpty, !memberKeys.isEmpty else { return } + let ck = cacheKey(account: account, groupId: groupId) + let cached = CachedGroupMembers(memberKeys: memberKeys, adminKey: memberKeys[0]) + if memberCache[ck] != cached { + memberCache[ck] = cached + } + } + /// Returns true if the string looks like a group invite (starts with `#group:` and can be parsed). func isValidInviteString(_ value: String) -> Bool { parseInviteString(value) != nil @@ -323,6 +349,30 @@ final class GroupRepository { return toGroupDialogKey(groupId) } + /// Updates only title and description for an existing group (local-only, no server packet yet). + func updateGroupMetadata(account: String, groupDialogKey: String, title: String, description: String) { + let groupId = normalizeGroupId(groupDialogKey) + guard !groupId.isEmpty else { return } + + do { + try db.writeSync { db in + try db.execute( + sql: """ + UPDATE groups SET title = ?, description = ? + WHERE account = ? AND group_id = ? + """, + arguments: [title, description, account, groupId] + ) + } + } catch { + Self.logger.error("Failed to update group metadata \(groupId): \(error.localizedDescription)") + return + } + + let ck = cacheKey(account: account, groupId: groupId) + metadataCache[ck] = GroupMetadata(title: title, description: description) + } + /// Deletes a group and all its local messages/dialog. func deleteGroup(account: String, groupDialogKey: String) { let groupId = normalizeGroupId(groupDialogKey) diff --git a/Rosetta/Core/Layout/MessageCellLayout.swift b/Rosetta/Core/Layout/MessageCellLayout.swift index 1d8d659..3ceb1a5 100644 --- a/Rosetta/Core/Layout/MessageCellLayout.swift +++ b/Rosetta/Core/Layout/MessageCellLayout.swift @@ -85,6 +85,7 @@ struct MessageCellLayout: Sendable { var showsSenderAvatar: Bool // true for .bottom/.single incoming in group var senderName: String var senderKey: String + var isGroupAdmin: Bool // true if sender is group owner (members[0]) // MARK: - Types @@ -129,6 +130,8 @@ extension MessageCellLayout { let groupInviteCount: Int let groupInviteTitle: String let groupInviteGroupId: String + let senderName: String // group sender name (for min bubble width) + let isGroupAdmin: Bool // sender is group owner (admin badge takes extra space) } private struct MediaDimensions { @@ -250,7 +253,8 @@ extension MessageCellLayout { // CoreText (CTTypesetter) — returns per-line widths including lastLineWidth. // Also captures CoreTextTextLayout for cell rendering (avoids double computation). let bubbleTextColor: UIColor = (config.isOutgoing || config.isDarkMode) ? .white : .black - let (measurement, layout) = measureTextDetailedWithLayout(config.text, maxWidth: max(maxTextWidth, 50), font: font, textColor: bubbleTextColor) + let bubbleLinkColor: UIColor = config.isOutgoing ? .white : CoreTextTextLayout.linkColor + let (measurement, layout) = measureTextDetailedWithLayout(config.text, maxWidth: max(maxTextWidth, 50), font: font, textColor: bubbleTextColor, linkColor: bubbleLinkColor) textMeasurement = measurement cachedTextLayout = layout } else if !config.text.isEmpty { @@ -504,6 +508,15 @@ extension MessageCellLayout { if config.isForward { bubbleW = max(bubbleW, min(200, effectiveMaxBubbleWidth)) } + // Ensure bubble is wide enough for sender name (group chats). + // Name font: 13pt semibold. Admin badge: +20pt. Padding: 10 left + 14 right = 24. + if !config.senderName.isEmpty { + let nameFont = UIFont.systemFont(ofSize: 13, weight: .semibold) + let nameW = (config.senderName as NSString).size(withAttributes: [.font: nameFont]).width + let adminExtra: CGFloat = config.isGroupAdmin ? 20 : 0 + let neededW = ceil(nameW) + adminExtra + 24 + bubbleW = max(bubbleW, min(neededW, effectiveMaxBubbleWidth)) + } // Stretchable bubble image min height bubbleH = max(bubbleH, 37) @@ -708,7 +721,8 @@ extension MessageCellLayout { showsSenderName: false, showsSenderAvatar: false, senderName: "", - senderKey: "" + senderKey: "", + isGroupAdmin: false ) return (layout, cachedTextLayout) } @@ -775,10 +789,10 @@ extension MessageCellLayout { /// Returns BOTH measurement AND the full CoreTextTextLayout for cell rendering cache. /// This eliminates the double CoreText computation (measure + render). private static func measureTextDetailedWithLayout( - _ text: String, maxWidth: CGFloat, font: UIFont, textColor: UIColor = .white + _ text: String, maxWidth: CGFloat, font: UIFont, textColor: UIColor = .white, linkColor: UIColor = CoreTextTextLayout.linkColor ) -> (TextMeasurement, CoreTextTextLayout) { let layout = CoreTextTextLayout.calculate( - text: text, maxWidth: maxWidth, font: font, textColor: textColor + text: text, maxWidth: maxWidth, font: font, textColor: textColor, linkColor: linkColor ) let measurement = TextMeasurement( size: layout.size, @@ -921,6 +935,7 @@ extension MessageCellLayout { opponentPublicKey: String, opponentTitle: String, isGroupChat: Bool = false, + groupAdminKey: String = "", isDarkMode: Bool = true ) -> (layouts: [String: MessageCellLayout], textLayouts: [String: CoreTextTextLayout]) { var result: [String: MessageCellLayout] = [:] @@ -1062,7 +1077,9 @@ extension MessageCellLayout { dateHeaderText: dateHeaderText, groupInviteCount: groupInviteCount, groupInviteTitle: groupInviteTitle, - groupInviteGroupId: groupInviteGroupId + groupInviteGroupId: groupInviteGroupId, + senderName: (isGroupChat && !isOutgoing) ? (DialogRepository.shared.dialogs[message.fromPublicKey]?.opponentTitle ?? String(message.fromPublicKey.prefix(8))) : "", + isGroupAdmin: (isGroupChat && !isOutgoing && !groupAdminKey.isEmpty && message.fromPublicKey == groupAdminKey) ) var (layout, textLayout) = calculate(config: config) @@ -1077,6 +1094,10 @@ extension MessageCellLayout { layout.showsSenderName = (position == .top || position == .single) // .bottom or .single = last message in sender run → show avatar layout.showsSenderAvatar = (position == .bottom || position == .single) + // Desktop parity: members[0] is the group owner/admin. + if !groupAdminKey.isEmpty && message.fromPublicKey == groupAdminKey { + layout.isGroupAdmin = true + } // Add height for sender name so cells don't overlap. if layout.showsSenderName { layout.totalHeight += 20 diff --git a/Rosetta/Core/Services/InAppBannerManager.swift b/Rosetta/Core/Services/InAppBannerManager.swift index 7ecc622..583f22a 100644 --- a/Rosetta/Core/Services/InAppBannerManager.swift +++ b/Rosetta/Core/Services/InAppBannerManager.swift @@ -52,6 +52,12 @@ final class InAppBannerManager: ObservableObject { currentBanner = nil } + /// Cancel auto-dismiss timer (e.g., during active pan gesture). + /// Telegram: cancels timeout when abs(translation) > 4pt. + func cancelAutoDismiss() { + dismissTask?.cancel() + } + // MARK: - Data struct BannerData: Identifiable { diff --git a/Rosetta/DesignSystem/Components/GlassCard.swift b/Rosetta/DesignSystem/Components/GlassCard.swift index ba21b40..93dd21a 100644 --- a/Rosetta/DesignSystem/Components/GlassCard.swift +++ b/Rosetta/DesignSystem/Components/GlassCard.swift @@ -23,6 +23,46 @@ struct SettingsCard: View { } } +// MARK: - Telegram Section Card (solid fill, no border/blur) + +/// Telegram-parity section card: #1c1c1d dark fill, 11pt corner radius, no borders. +/// Source: DefaultDarkPresentationTheme.swift → itemBlocksBackgroundColor. +struct TelegramSectionCard: View { + let cornerRadius: CGFloat + let content: () -> Content + + init(cornerRadius: CGFloat = 11, @ViewBuilder content: @escaping () -> Content) { + self.cornerRadius = cornerRadius + self.content = content + } + + var body: some View { + content() + .background( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(telegramSectionFill) + ) + } +} + +/// Telegram itemBlocksBackgroundColor: dark #1c1c1d, light #F2F2F7. +let telegramSectionFill = RosettaColors.adaptive( + light: Color(hex: 0xF2F2F7), + dark: Color(red: 28/255, green: 28/255, blue: 29/255) +) + +/// Telegram itemBlocksSeparatorColor: dark #545458 at 55%, light #3C3C43 at 36%. +let telegramSeparatorColor = RosettaColors.adaptive( + light: Color(hex: 0x3C3C43).opacity(0.36), + dark: Color(hex: 0x545458).opacity(0.55) +) + +/// Telegram itemPlaceholderTextColor: dark #4d4d4d, light #c8c8ce. +let telegramPlaceholderColor = RosettaColors.adaptive( + light: Color(hex: 0xC8C8CE), + dark: Color(hex: 0x4d4d4d) +) + // MARK: - Glass Card (material blur + border) struct GlassCard: View { diff --git a/Rosetta/DesignSystem/Components/InAppBannerView.swift b/Rosetta/DesignSystem/Components/InAppBannerView.swift index 7d80f9b..d07d1e7 100644 --- a/Rosetta/DesignSystem/Components/InAppBannerView.swift +++ b/Rosetta/DesignSystem/Components/InAppBannerView.swift @@ -1,4 +1,5 @@ import SwiftUI +import UIKit // MARK: - In-App Notification Banner (Telegram parity) @@ -8,10 +9,14 @@ import SwiftUI /// Specs (from Telegram iOS `ChatMessageNotificationItem.swift` + `NotificationItemContainerNode.swift`): /// - Panel: 74pt min height, 24pt corner radius, 8pt horizontal margin /// - Avatar: 54pt circle, 12pt left inset, 23pt avatar-to-text spacing -/// - Title: semibold 16pt, white, 1 line -/// - Message: regular 16pt, white (full), max 2 lines +/// - Title: semibold 16pt, 1 line +/// - Message: regular 16pt, max 2 lines +/// - Text color: white (dark) / black (light) — adapts to colorScheme /// - Background: TelegramGlass (CABackdropLayer / UIGlassEffect) -/// - Slide from top: 0.4s, auto-dismiss: 5s, swipe dismiss with 0.55 damping +/// - Slide from top: 0.4s, auto-dismiss: 5s +/// - Swipe up to dismiss (5pt / 200pt/s threshold) +/// - Swipe down to expand (open chat, 20pt / 300pt/s threshold) +/// - Haptic feedback on drag struct InAppBannerView: View { let senderName: String let messagePreview: String @@ -19,25 +24,36 @@ struct InAppBannerView: View { let isGroup: Bool let onTap: () -> Void let onDismiss: () -> Void + let onExpand: () -> Void + let onDragBegan: () -> Void + + @Environment(\.colorScheme) private var colorScheme @State private var dragOffset: CGFloat = 0 + @State private var hapticPrepared = false + @State private var hasExpandedHaptic = false + @State private var dragCancelledTimeout = false private let panelHeight: CGFloat = 74 private let cornerRadius: CGFloat = 24 private let avatarSize: CGFloat = 54 private let horizontalMargin: CGFloat = 8 + private var textColor: Color { + colorScheme == .dark ? .white : .black + } + var body: some View { HStack(spacing: 23) { avatarView VStack(alignment: .leading, spacing: 1) { Text(senderName) .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(.white) + .foregroundStyle(textColor) .lineLimit(1) Text(messagePreview) .font(.system(size: 16)) - .foregroundStyle(.white) + .foregroundStyle(textColor) .lineLimit(2) } Spacer(minLength: 0) @@ -47,26 +63,60 @@ struct InAppBannerView: View { .frame(minHeight: panelHeight) .frame(maxWidth: .infinity) .glass(cornerRadius: cornerRadius) + .shadow(color: .black.opacity(0.04), radius: 40, x: 0, y: 1) .padding(.horizontal, horizontalMargin) .offset(y: dragOffset) .gesture( DragGesture(minimumDistance: 5) .onChanged { value in let translation = value.translation.height + + // Haptic: prepare when drag begins (Telegram: abs > 1pt). + if abs(translation) > 1 && !hapticPrepared { + UIImpactFeedbackGenerator(style: .medium).prepare() + hapticPrepared = true + } + + // Cancel auto-dismiss timer (Telegram: abs > 4pt). + if abs(translation) > 4 && !dragCancelledTimeout { + dragCancelledTimeout = true + onDragBegan() + } + + // Haptic on expand threshold crossing (Telegram: bounds.minY < -24pt). + if translation > 24 && !hasExpandedHaptic { + hasExpandedHaptic = true + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + } + if translation < 0 { // Dragging up — full 1:1 tracking. dragOffset = translation } else { // Dragging down — logarithmic rubber-band (Telegram: 0.55/50 cap). - // Formula: -((1 - 1/((delta*0.55/50)+1)) * 50) let delta = translation dragOffset = (1.0 - (1.0 / ((delta * 0.55 / 50.0) + 1.0))) * 50.0 } } .onEnded { value in let velocity = value.predictedEndTranslation.height - value.translation.height - // Dismiss: swiped up > 20pt or fast upward velocity (Telegram: -20pt / 300pt/s). - if value.translation.height < -20 || velocity < -300 { + + // Reset haptic state. + hapticPrepared = false + hasExpandedHaptic = false + dragCancelledTimeout = false + + // Expand: pulled down > 20pt or fast downward velocity (Telegram: -20pt / 300pt/s). + if value.translation.height > 20 || velocity > 300 { + withAnimation(.easeInOut(duration: 0.3)) { + dragOffset = 0 + } + onExpand() + return + } + + // Dismiss: swiped up > 5pt or fast upward velocity (Telegram: 5pt / 200pt/s). + if value.translation.height < -5 || velocity < -200 { withAnimation(.easeOut(duration: 0.4)) { dragOffset = -200 } @@ -112,7 +162,7 @@ struct InAppBannerView: View { .clipShape(Circle()) } else { ZStack { - Circle().fill(Color(hex: 0x1A1B1E)) + Circle().fill(colorScheme == .dark ? Color(hex: 0x1A1B1E) : Color(hex: 0xF1F3F5)) Circle().fill(Color(hex: UInt(pair.tint)).opacity(0.15)) Text(Self.initials(name: senderName, publicKey: senderKey)) .font(.system(size: avatarSize * 0.38, weight: .bold, design: .rounded)) diff --git a/Rosetta/DesignSystem/Components/VerifiedBadge.swift b/Rosetta/DesignSystem/Components/VerifiedBadge.swift index 08bac05..c08aec6 100644 --- a/Rosetta/DesignSystem/Components/VerifiedBadge.swift +++ b/Rosetta/DesignSystem/Components/VerifiedBadge.swift @@ -7,13 +7,15 @@ import SwiftUI /// Uses Tabler Icons SVG paths for desktop parity: /// - Level 1: rosette with checkmark (blue) — `IconRosetteDiscountCheckFilled` /// - Level 2: shield with checkmark (green) — `IconShieldCheckFilled` -/// - Level 3+: shield with checkmark (blue) — group admin +/// - Level 3+: arrow badge down (gold) — group admin /// /// Tapping the badge presents a dialog explaining the verification level. struct VerifiedBadge: View { let verified: Int var size: CGFloat = 16 var badgeTint: Color? + /// When false, tapping the badge does nothing (no alert). + var interactive: Bool = true @Environment(\.colorScheme) private var colorScheme @State private var showExplanation = false @@ -23,9 +25,9 @@ struct VerifiedBadge: View { SVGPathShape(pathData: iconPath, viewBox: CGSize(width: 24, height: 24)) .fill(resolvedColor) .frame(width: size, height: size) - .onTapGesture { showExplanation = true } - .accessibilityLabel("Verified account") - .alert("Verified Account", isPresented: $showExplanation) { + .onTapGesture { if interactive { showExplanation = true } } + .accessibilityLabel(verified >= 3 ? "Group admin" : "Verified account") + .alert(alertTitle, isPresented: $showExplanation) { Button("OK", role: .cancel) {} } message: { Text(annotationText) @@ -33,6 +35,10 @@ struct VerifiedBadge: View { } } + private var alertTitle: String { + verified >= 3 ? "Group Admin" : "Verified Account" + } + // MARK: - Private /// Desktop parity: different icon per verification level. @@ -43,18 +49,22 @@ struct VerifiedBadge: View { case 2: return TablerIconPath.shieldCheckFilled case 3...: - return TablerIconPath.shieldCheckFilled + return TablerIconPath.arrowBadgeDownFilled default: return TablerIconPath.rosetteDiscountCheckFilled } } - /// Desktop parity: level 2 (Rosetta admin) uses green, others use brand blue. + /// Desktop parity: level 2 (Rosetta admin) uses green, + /// level 3+ (group admin) uses gold, others use brand blue. private var resolvedColor: Color { if let badgeTint { return badgeTint } if verified == 2 { return RosettaColors.success // green } + if verified >= 3 { + return Color(hex: 0xFFD700) // gold — desktop parity + } return RosettaColors.primaryBlue // #248AE6 — same both themes (Telegram parity) } @@ -75,7 +85,7 @@ struct VerifiedBadge: View { /// SVG path data from Tabler Icons — exact match with desktop's verified badge icons. /// Desktop reference: `@tabler/icons-react` → `IconRosetteDiscountCheckFilled`, `IconShieldCheckFilled`. /// ViewBox: 24×24 for both icons. -private enum TablerIconPath { +enum TablerIconPath { /// Rosette with checkmark — verification level 1 (public figure/brand/organization). /// Desktop: `IconRosetteDiscountCheckFilled` from `@tabler/icons-react`. @@ -84,6 +94,10 @@ private enum TablerIconPath { /// Shield with checkmark — verification level 2 (Rosetta administration). /// Desktop: `IconShieldCheckFilled` from `@tabler/icons-react`. static let shieldCheckFilled = "M11.998 2l.118 .007l.059 .008l.061 .013l.111 .034a.993 .993 0 0 1 .217 .112l.104 .082l.255 .218a11 11 0 0 0 7.189 2.537l.342 -.01a1 1 0 0 1 1.005 .717a13 13 0 0 1 -9.208 16.25a1 1 0 0 1 -.502 0a13 13 0 0 1 -9.209 -16.25a1 1 0 0 1 1.005 -.717a11 11 0 0 0 7.531 -2.527l.263 -.225l.096 -.075a.993 .993 0 0 1 .217 -.112l.112 -.034a.97 .97 0 0 1 .119 -.021l.115 -.007zm3.71 7.293a1 1 0 0 0 -1.415 0l-3.293 3.292l-1.293 -1.292l-.094 -.083a1 1 0 0 0 -1.32 1.497l2 2l.094 .083a1 1 0 0 0 1.32 -.083l4 -4l.083 -.094a1 1 0 0 0 -.083 -1.32z" + + /// Arrow badge down — verification level 3+ (group admin/owner). + /// Desktop: `IconArrowBadgeDownFilled` from `@tabler/icons-react`. + static let arrowBadgeDownFilled = "M16.375 6.22l-4.375 3.498l-4.375 -3.5a1 1 0 0 0 -1.625 .782v6a1 1 0 0 0 .375 .78l5 4a1 1 0 0 0 1.25 0l5 -4a1 1 0 0 0 .375 -.78v-6a1 1 0 0 0 -1.625 -.78z" } // MARK: - Preview diff --git a/Rosetta/DesignSystem/Helpers/AnchorKey.swift b/Rosetta/DesignSystem/Helpers/AnchorKey.swift new file mode 100644 index 0000000..d7a7005 --- /dev/null +++ b/Rosetta/DesignSystem/Helpers/AnchorKey.swift @@ -0,0 +1,8 @@ +import SwiftUI + +struct AnchorKey: PreferenceKey { + static var defaultValue: [String: Anchor] = [:] + static func reduce(value: inout [String: Anchor], nextValue: () -> [String: Anchor]) { + value.merge(nextValue()) { $1 } + } +} diff --git a/Rosetta/DesignSystem/Helpers/OffsetHelper.swift b/Rosetta/DesignSystem/Helpers/OffsetHelper.swift new file mode 100644 index 0000000..ab5298c --- /dev/null +++ b/Rosetta/DesignSystem/Helpers/OffsetHelper.swift @@ -0,0 +1,23 @@ +import SwiftUI + +struct OffsetKey: PreferenceKey { + static var defaultValue: CGRect = .zero + static func reduce(value: inout CGRect, nextValue: () -> CGRect) { + value = nextValue() + } +} + +extension View { + @ViewBuilder + func offsetExtractor(coordinateSpace: String, completion: @escaping (CGRect) -> Void) -> some View { + self + .overlay { + GeometryReader { + let rect = $0.frame(in: .named(coordinateSpace)) + Color.clear + .preference(key: OffsetKey.self, value: rect) + .onPreferenceChange(OffsetKey.self, perform: completion) + } + } + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index 1ef264f..f58890a 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -58,6 +58,11 @@ struct ChatDetailView: View { /// Triggers NativeMessageList to scroll to bottom (button tap). @State private var scrollToBottomRequested = false + // Multi-select + @State private var isMultiSelectMode = false + @State private var selectedMessageIds: Set = [] + @State private var showClearChatConfirmation = false + /// Stable callback reference for message cell interactions. /// Class ref pointer is stable across parent re-renders → cells not marked dirty. @State private var cellActions = MessageCellActions() @@ -219,6 +224,12 @@ struct ChatDetailView: View { .ignoresSafeArea() } } + .overlay(alignment: .bottom) { + if isMultiSelectMode { + selectionActionBar + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } .navigationBarTitleDisplayMode(.inline) .navigationBarBackButtonHidden(true) .enableSwipeBack() @@ -232,7 +243,10 @@ struct ChatDetailView: View { cellActions.onForward = { [self] msg in forwardingMessage = msg; showForwardPicker = true } cellActions.onDelete = { [self] msg in messageToDelete = msg } cellActions.onCopy = { text in UIPasteboard.general.string = text } - cellActions.onImageTap = { [self] attId, frame in openImageViewer(attachmentId: attId, sourceFrame: frame) } + cellActions.onImageTap = { [self] attId, frame, sourceView in openImageViewer(attachmentId: attId, sourceFrame: frame, sourceView: sourceView) } + ImageViewerPresenter.shared.onShowInChat = { [self] messageId in + scrollToMessageId = messageId + } cellActions.onScrollToMessage = { [self] msgId in Task { @MainActor in guard await viewModel.ensureMessageLoaded(messageId: msgId) else { return } @@ -255,6 +269,17 @@ struct ChatDetailView: View { pendingGroupInviteTitle = parsed.title } } + cellActions.onEnterSelection = { [self] msg in + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { isMultiSelectMode = true } + selectedMessageIds = [msg.id] + } + cellActions.onToggleSelection = { [self] msgId in + if selectedMessageIds.contains(msgId) { + selectedMessageIds.remove(msgId) + } else { + selectedMessageIds.insert(msgId) + } + } cellActions.onGroupInviteOpen = { dialogKey in let title = DialogRepository.shared.dialogs[dialogKey]?.opponentTitle ?? "Group" let route = ChatRoute(groupDialogKey: dialogKey, title: title) @@ -362,6 +387,10 @@ struct ChatDetailView: View { if let message = messageToDelete { removeMessage(message) messageToDelete = nil + if isMultiSelectMode { + withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { isMultiSelectMode = false } + selectedMessageIds.removeAll() + } } } Button("Cancel", role: .cancel) { @@ -417,6 +446,16 @@ struct ChatDetailView: View { } message: { Text("Join \"\(pendingGroupInviteTitle ?? "group")\"?") } + .confirmationDialog("Clear Chat", isPresented: $showClearChatConfirmation) { + Button("Clear All Messages", role: .destructive) { + MessageRepository.shared.deleteDialog(route.publicKey) + withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { isMultiSelectMode = false } + selectedMessageIds.removeAll() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Are you sure you want to clear all messages? This action cannot be undone.") + } .sheet(isPresented: $showAttachmentPanel) { AttachmentPanelView( onSend: { attachments, caption in @@ -580,7 +619,48 @@ private extension ChatDetailView { @ToolbarContentBuilder var chatDetailToolbar: some ToolbarContent { - if #available(iOS 26, *) { + if isMultiSelectMode { + // Selection mode toolbar (Telegram parity: Clear Chat | N Selected | Cancel) + ToolbarItem(placement: .navigationBarLeading) { + Button { + showClearChatConfirmation = true + } label: { + Text("Clear Chat") + .font(.system(size: 17, weight: .regular)) + .foregroundStyle(RosettaColors.Adaptive.text) + .padding(.horizontal, 12) + .frame(height: 36) + .background { + glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: RosettaColors.Adaptive.text) + } + } + .buttonStyle(.plain) + } + + ToolbarItem(placement: .principal) { + Text("\(selectedMessageIds.count) Selected") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(RosettaColors.Adaptive.text) + .padding(.horizontal, 16) + .frame(height: 36) + .background { + glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: RosettaColors.Adaptive.text) + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button { + withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { isMultiSelectMode = false } + selectedMessageIds.removeAll() + } label: { + Text("Cancel") + .font(.system(size: 17, weight: .regular)) + .foregroundStyle(RosettaColors.Adaptive.text) + } + .buttonStyle(.plain) + } + + } else if #available(iOS 26, *) { // iOS 26+ — original compact sizes with .glassEffect() ToolbarItem(placement: .navigationBarLeading) { Button { dismiss() } label: { @@ -759,6 +839,107 @@ private extension ChatDetailView { colorScheme == .dark ? Color.black : Color.white } + // MARK: - Selection Action Bar + + @ViewBuilder + private var selectionActionBar: some View { + HStack(spacing: 0) { + // Delete + Button { + deleteSelectedMessages() + } label: { + Image(systemName: "trash") + .font(.system(size: 20)) + .foregroundStyle(selectedMessageIds.isEmpty ? RosettaColors.Adaptive.text.opacity(0.5) : .red) + .frame(width: 40, height: 40) + .background { glass(shape: .circle, strokeOpacity: 0.18, strokeColor: RosettaColors.Adaptive.text) } + } + .buttonStyle(.plain) + .disabled(selectedMessageIds.isEmpty) + + Spacer() + + // Share + Button { + shareSelectedMessages() + } label: { + Image(systemName: "square.and.arrow.up") + .font(.system(size: 20)) + .foregroundStyle(selectedMessageIds.isEmpty ? RosettaColors.Adaptive.text.opacity(0.5) : RosettaColors.Adaptive.text) + .frame(width: 40, height: 40) + .background { glass(shape: .circle, strokeOpacity: 0.18, strokeColor: RosettaColors.Adaptive.text) } + } + .buttonStyle(.plain) + .disabled(selectedMessageIds.isEmpty) + + Spacer() + + // Forward + Button { + forwardSelectedMessages() + } label: { + Image(systemName: "arrowshape.turn.up.right") + .font(.system(size: 20)) + .foregroundStyle(selectedMessageIds.isEmpty ? RosettaColors.Adaptive.text.opacity(0.5) : RosettaColors.Adaptive.text) + .frame(width: 40, height: 40) + .background { glass(shape: .circle, strokeOpacity: 0.18, strokeColor: RosettaColors.Adaptive.text) } + } + .buttonStyle(.plain) + .disabled(selectedMessageIds.isEmpty) + } + .padding(.horizontal, 26) // Telegram: 8 base + 18 safe area inset + .padding(.vertical, 12) + .padding(.bottom, 16) // safe area + .background { + glass(shape: .rounded(0), strokeOpacity: 0) + } + } + + private func deleteSelectedMessages() { + guard !selectedMessageIds.isEmpty else { return } + let selected = messages.filter { selectedMessageIds.contains($0.id) } + guard let first = selected.first else { return } + if selected.count == 1 { + messageToDelete = first + } else { + // Batch delete + for msg in selected { + MessageRepository.shared.deleteMessage(id: msg.id) + } + withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { isMultiSelectMode = false } + selectedMessageIds.removeAll() + } + } + + private func shareSelectedMessages() { + let selected = messages + .filter { selectedMessageIds.contains($0.id) } + .sorted { $0.timestamp < $1.timestamp } + let texts = selected.compactMap { msg -> String? in + let text = msg.text.trimmingCharacters(in: .whitespacesAndNewlines) + return text.isEmpty ? nil : text + } + guard !texts.isEmpty else { return } + let combined = texts.joined(separator: "\n\n") + let activityVC = UIActivityViewController(activityItems: [combined], applicationActivities: nil) + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootVC = windowScene.windows.first?.rootViewController { + rootVC.present(activityVC, animated: true) + } + } + + private func forwardSelectedMessages() { + let selected = messages + .filter { selectedMessageIds.contains($0.id) } + .sorted { $0.timestamp < $1.timestamp } + guard let first = selected.first else { return } + // For now: forward first selected message, exit selection + forwardingMessage = first + showForwardPicker = true + withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { isMultiSelectMode = false } + selectedMessageIds.removeAll() + } + /// Top: native SwiftUI Material blur with gradient mask — blurs content behind it. @ViewBuilder var chatEdgeGradients: some View { @@ -957,6 +1138,8 @@ private extension ChatDetailView { }, onComposerHeightChange: { composerHeight = $0 }, onKeyboardDidHide: { isInputFocused = false }, + isMultiSelectMode: isMultiSelectMode, + selectedMessageIds: selectedMessageIds, messageText: $messageText, isInputFocused: $isInputFocused, replySenderName: replySender, @@ -1235,7 +1418,10 @@ private extension ChatDetailView { /// sender name, timestamp, and caption for each image. /// Uses `ImageViewerPresenter` (UIKit overFullScreen) instead of SwiftUI fullScreenCover /// to avoid the default bottom-sheet slide-up animation. - func openImageViewer(attachmentId: String, sourceFrame: CGRect) { + func openImageViewer(attachmentId: String, sourceFrame: CGRect, sourceView: UIView? = nil) { + // Dismiss keyboard before opening gallery + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + var allImages: [ViewableImageInfo] = [] for message in messages { let senderName = senderDisplayName(for: message.fromPublicKey) @@ -1245,6 +1431,7 @@ private extension ChatDetailView { for attachment in message.attachments where attachment.type == .image { allImages.append(ViewableImageInfo( attachmentId: attachment.id, + messageId: message.id, senderName: senderName, timestamp: timestamp, caption: message.text @@ -1260,6 +1447,7 @@ private extension ChatDetailView { for att in reply.attachments where att.type == AttachmentType.image.rawValue { allImages.append(ViewableImageInfo( attachmentId: att.id, + messageId: message.id, senderName: fwdSenderName, timestamp: fwdTimestamp, caption: reply.message @@ -1271,7 +1459,7 @@ private extension ChatDetailView { } let index = allImages.firstIndex(where: { $0.attachmentId == attachmentId }) ?? 0 let state = ImageViewerState(images: allImages, initialIndex: index, sourceFrame: sourceFrame) - ImageViewerPresenter.shared.present(state: state) + ImageViewerPresenter.shared.present(state: state, sourceView: sourceView) } func retryMessage(_ message: ChatMessage) { diff --git a/Rosetta/Features/Chats/ChatDetail/CoreTextLabel.swift b/Rosetta/Features/Chats/ChatDetail/CoreTextLabel.swift index d1c5865..cb9b72f 100644 --- a/Rosetta/Features/Chats/ChatDetail/CoreTextLabel.swift +++ b/Rosetta/Features/Chats/ChatDetail/CoreTextLabel.swift @@ -122,6 +122,7 @@ final class CoreTextTextLayout { maxWidth: CGFloat, font: UIFont = .systemFont(ofSize: 17), textColor: UIColor = .white, + linkColor: UIColor = CoreTextTextLayout.linkColor, lineSpacingFactor: CGFloat = telegramLineSpacingFactor ) -> CoreTextTextLayout { // Guard: empty text, non-positive width, or NaN → return zero layout @@ -155,7 +156,7 @@ final class CoreTextTextLayout { let tld = host.split(separator: ".").last.map(String.init) ?? "" guard allowedTLDs.contains(tld) else { return } attrString.addAttributes([ - .foregroundColor: linkColor, + .foregroundColor: linkColor as UIColor, .underlineStyle: NSUnderlineStyle.single.rawValue ], range: result.range) detectedLinks.append((url: url, range: result.range)) diff --git a/Rosetta/Features/Chats/ChatDetail/FullScreenImageViewer.swift b/Rosetta/Features/Chats/ChatDetail/FullScreenImageViewer.swift deleted file mode 100644 index 4a41e27..0000000 --- a/Rosetta/Features/Chats/ChatDetail/FullScreenImageViewer.swift +++ /dev/null @@ -1,236 +0,0 @@ -import SwiftUI - -// MARK: - FullScreenImageViewer - -/// Full-screen image viewer with pinch zoom, double-tap zoom, and swipe-to-dismiss. -/// -/// Android parity: `ImageViewerScreen.kt` — zoom (1x–5x), double-tap (2.5x), -/// vertical swipe dismiss, background fade, tap to toggle controls. -struct FullScreenImageViewer: View { - - let image: UIImage - let onDismiss: () -> Void - - /// Current zoom scale (1.0 = fit, up to maxScale). - @State private var scale: CGFloat = 1.0 - @State private var lastScale: CGFloat = 1.0 - - /// Pan offset when zoomed. - @State private var offset: CGSize = .zero - @State private var lastOffset: CGSize = .zero - - /// Vertical drag offset for dismiss gesture (only when not zoomed). - @State private var dismissOffset: CGFloat = 0 - - /// Whether the UI controls (close button) are visible. - @State private var showControls = true - - private let minScale: CGFloat = 1.0 - private let maxScale: CGFloat = 5.0 - private let doubleTapScale: CGFloat = 2.5 - private let dismissThreshold: CGFloat = 150 - - var body: some View { - ZStack { - // Background: fades as user drags to dismiss - Color.black - .opacity(backgroundOpacity) - .ignoresSafeArea() - - // Zoomable image (visual only — no gestures here) - Image(uiImage: image) - .resizable() - .scaledToFit() - .scaleEffect(scale) - .offset(x: offset.width, y: offset.height + dismissOffset) - .allowsHitTesting(false) - - // Close button (above gesture layer so it stays tappable) - if showControls { - VStack { - HStack { - Spacer() - 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(.trailing, 16) - .padding(.top, 8) - } - Spacer() - } - .transition(.opacity) - } - } - // Gestures on the full-screen ZStack — not on the Image. - // scaleEffect is visual-only and doesn't expand the Image's hit-test area, - // so when zoomed to 2.5x, taps outside the original frame were lost. - .contentShape(Rectangle()) - .onTapGesture(count: 2) { - doubleTap() - } - .onTapGesture(count: 1) { - withAnimation(.easeInOut(duration: 0.2)) { - showControls.toggle() - } - } - .simultaneousGesture(pinchGesture) - .simultaneousGesture(dragGesture) - } - - // 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 - - private func doubleTap() { - withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { - if scale > 1.05 { - scale = 1.0 - lastScale = 1.0 - offset = .zero - lastOffset = .zero - } else { - scale = doubleTapScale - lastScale = doubleTapScale - offset = .zero - lastOffset = .zero - } - } - } - - // MARK: - Pinch Gesture - - private var pinchGesture: some Gesture { - MagnificationGesture() - .onChanged { value in - let newScale = lastScale * value - scale = min(max(newScale, minScale * 0.5), maxScale) - } - .onEnded { _ in - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - if scale < minScale { scale = minScale } - lastScale = scale - if scale <= 1.0 { - offset = .zero - lastOffset = .zero - } - } - } - } - - // MARK: - Drag Gesture - - private var dragGesture: some Gesture { - DragGesture() - .onChanged { value in - if scale > 1.05 { - offset = CGSize( - width: lastOffset.width + value.translation.width, - height: lastOffset.height + value.translation.height - ) - } else { - dismissOffset = value.translation.height - } - } - .onEnded { _ in - if scale > 1.05 { - lastOffset = offset - } else { - if abs(dismissOffset) > dismissThreshold { - onDismiss() - } else { - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - dismissOffset = 0 - } - } - } - } - } -} - -// MARK: - FullScreenImageFromCache - -/// Wrapper that loads an image from `AttachmentCache` by attachment ID and -/// presents it in `FullScreenImageViewer`. Handles cache-miss gracefully. -/// -/// Used as `fullScreenCover` content — the attachment ID is a stable value -/// passed as a parameter, avoiding @State capture issues with UIImage. -struct FullScreenImageFromCache: View { - let attachmentId: String - let onDismiss: () -> Void - @State private var image: UIImage? - @State private var isLoading = true - - var body: some View { - if let image { - FullScreenImageViewer(image: image, onDismiss: onDismiss) - } else { - // Cache miss/loading state — show placeholder with close button. - ZStack { - Color.black.ignoresSafeArea() - if isLoading { - VStack(spacing: 16) { - ProgressView() - .tint(.white) - Text("Loading...") - .font(.system(size: 15)) - .foregroundStyle(.white.opacity(0.5)) - } - } else { - VStack(spacing: 16) { - Image(systemName: "photo") - .font(.system(size: 48)) - .foregroundStyle(.white.opacity(0.3)) - Text("Image not available") - .font(.system(size: 15)) - .foregroundStyle(.white.opacity(0.5)) - } - } - VStack { - HStack { - Spacer() - 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(.trailing, 16) - .padding(.top, 8) - } - Spacer() - } - } - .task(id: attachmentId) { - if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachmentId) { - image = cached - isLoading = false - return - } - await ImageLoadLimiter.shared.acquire() - let loaded = await Task.detached(priority: .userInitiated) { - AttachmentCache.shared.loadImage(forAttachmentId: attachmentId) - }.value - await ImageLoadLimiter.shared.release() - guard !Task.isCancelled else { return } - image = loaded - isLoading = false - } - } - } -} diff --git a/Rosetta/Features/Chats/ChatDetail/Gallery/GalleryDismissController.swift b/Rosetta/Features/Chats/ChatDetail/Gallery/GalleryDismissController.swift new file mode 100644 index 0000000..1732635 --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/Gallery/GalleryDismissController.swift @@ -0,0 +1,73 @@ +import UIKit + +// MARK: - GalleryDismissController + +/// Manages interactive vertical drag-to-dismiss with velocity-based decision. +/// Uses UIPanGestureRecognizer attached to the gallery VC's view. +/// Only activates when current page is NOT zoomed (Telegram parity). +final class GalleryDismissController: NSObject, UIGestureRecognizerDelegate { + + var onDragChanged: ((CGFloat) -> Void)? + var onDismiss: ((CGFloat) -> Void)? + var onCancel: (() -> Void)? + + /// Set by GalleryViewController — returns true when current page is zoomed. + var isZoomedCheck: (() -> Bool)? + + private(set) var panGesture: UIPanGestureRecognizer! + + private let dismissThreshold: CGFloat = 100 + private let velocityThreshold: CGFloat = 1000 + + // MARK: - Init + + override init() { + super.init() + panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) + panGesture.minimumNumberOfTouches = 1 + panGesture.maximumNumberOfTouches = 1 + panGesture.delegate = self + } + + // MARK: - Pan Handler + + @objc private func handlePan(_ gesture: UIPanGestureRecognizer) { + let translation = gesture.translation(in: gesture.view) + + switch gesture.state { + case .changed: + onDragChanged?(translation.y) + + case .ended, .cancelled: + let velocity = gesture.velocity(in: gesture.view).y + let shouldDismiss = abs(translation.y) > dismissThreshold || abs(velocity) > velocityThreshold + if shouldDismiss { + onDismiss?(velocity) + } else { + onCancel?() + } + + default: + break + } + } + + // MARK: - UIGestureRecognizerDelegate + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return false } + // Only begin if vertical velocity dominates and page is not zoomed + let velocity = pan.velocity(in: pan.view) + let isVertical = abs(velocity.y) > abs(velocity.x) + let isNotZoomed = !(isZoomedCheck?() ?? false) + return isVertical && isNotZoomed + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + // Don't conflict with paging scroll view's pan + !(otherGestureRecognizer is UIPanGestureRecognizer) + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/Gallery/GalleryHeroAnimator.swift b/Rosetta/Features/Chats/ChatDetail/Gallery/GalleryHeroAnimator.swift new file mode 100644 index 0000000..4180a31 --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/Gallery/GalleryHeroAnimator.swift @@ -0,0 +1,238 @@ +import UIKit + +// MARK: - GalleryHeroAnimator + +/// Snapshot-based hero open/close animations (Telegram parity). +/// Uses CABasicAnimation on layer properties for GPU-accelerated 60fps transitions. +final class GalleryHeroAnimator { + + private weak var containerView: UIView? + private weak var sourceView: UIView? + private var snapshotView: UIImageView? + private let sourceCornerRadius: CGFloat = 16 + + // MARK: - Timing Constants (Telegram parity) + + private let heroPositionDuration: CFTimeInterval = 0.21 + private let heroBoundsDuration: CFTimeInterval = 0.25 + private let crossfadeInDuration: CFTimeInterval = 0.2 + private let crossfadeOutDuration: CFTimeInterval = 0.08 + private let backgroundFadeInDuration: CFTimeInterval = 0.2 + private let backgroundFadeOutDuration: CFTimeInterval = 0.25 + + // MARK: - Init + + init(containerView: UIView, sourceView: UIView?) { + self.containerView = containerView + self.sourceView = sourceView + } + + // MARK: - Open Animation (bubble → fullscreen) + + func animateOpen( + image: UIImage, + sourceFrame: CGRect, + targetFrame: CGRect, + backgroundView: UIView, + contentView: UIView, + completion: @escaping () -> Void + ) { + guard let containerView, sourceFrame != .zero else { + // Fallback: simple fade + backgroundView.alpha = 0 + contentView.alpha = 0 + UIView.animate(withDuration: 0.25) { + backgroundView.alpha = 1 + contentView.alpha = 1 + } completion: { _ in completion() } + return + } + + // Hide source cell image + sourceView?.alpha = 0 + + // Create snapshot + let snapshot = UIImageView(image: image) + snapshot.contentMode = .scaleAspectFill + snapshot.clipsToBounds = true + snapshot.layer.cornerRadius = sourceCornerRadius + snapshot.layer.cornerCurve = .continuous + snapshot.frame = sourceFrame + containerView.addSubview(snapshot) + snapshotView = snapshot + + // Hide real content during animation + contentView.alpha = 0 + backgroundView.alpha = 0 + + // Animate background fade + let bgFade = CABasicAnimation(keyPath: "opacity") + bgFade.fromValue = 0 + bgFade.toValue = 1 + bgFade.duration = backgroundFadeInDuration + bgFade.fillMode = .forwards + bgFade.isRemovedOnCompletion = false + backgroundView.layer.add(bgFade, forKey: "heroFadeIn") + backgroundView.alpha = 1 + + // Animate snapshot position + let positionAnim = CABasicAnimation(keyPath: "position") + positionAnim.fromValue = CGPoint(x: sourceFrame.midX, y: sourceFrame.midY) + positionAnim.toValue = CGPoint(x: targetFrame.midX, y: targetFrame.midY) + positionAnim.duration = heroPositionDuration + positionAnim.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + positionAnim.fillMode = .forwards + positionAnim.isRemovedOnCompletion = false + + // Animate snapshot bounds + let boundsAnim = CABasicAnimation(keyPath: "bounds.size") + boundsAnim.fromValue = sourceFrame.size + boundsAnim.toValue = targetFrame.size + boundsAnim.duration = heroBoundsDuration + boundsAnim.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + boundsAnim.fillMode = .forwards + boundsAnim.isRemovedOnCompletion = false + + // Animate corner radius + let cornerAnim = CABasicAnimation(keyPath: "cornerRadius") + cornerAnim.fromValue = sourceCornerRadius + cornerAnim.toValue = 0 + cornerAnim.duration = heroBoundsDuration + cornerAnim.fillMode = .forwards + cornerAnim.isRemovedOnCompletion = false + + CATransaction.begin() + CATransaction.setCompletionBlock { [weak self] in + // Show real content, remove snapshot + contentView.alpha = 1 + self?.snapshotView?.removeFromSuperview() + self?.snapshotView = nil + backgroundView.layer.removeAnimation(forKey: "heroFadeIn") + completion() + } + + snapshot.layer.add(positionAnim, forKey: "heroPosition") + snapshot.layer.add(boundsAnim, forKey: "heroBounds") + snapshot.layer.add(cornerAnim, forKey: "heroCorner") + + // Set final model values + snapshot.layer.position = CGPoint(x: targetFrame.midX, y: targetFrame.midY) + snapshot.layer.bounds = CGRect(origin: .zero, size: targetFrame.size) + snapshot.layer.cornerRadius = 0 + + CATransaction.commit() + } + + // MARK: - Close Animation (fullscreen → bubble) + + func animateClose( + image: UIImage, + currentFrame: CGRect, + backgroundView: UIView, + contentView: UIView, + completion: @escaping () -> Void + ) { + // Get fresh source frame (cell may have scrolled) + let targetFrame: CGRect + if let source = sourceView, source.window != nil { + targetFrame = source.convert(source.bounds, to: nil) + } else { + // Source view gone — fallback fade + animateFadeDismiss(backgroundView: backgroundView, contentView: contentView, completion: completion) + return + } + + guard let containerView, targetFrame != .zero else { + animateFadeDismiss(backgroundView: backgroundView, contentView: contentView, completion: completion) + return + } + + // Create snapshot at current gallery position + let snapshot = UIImageView(image: image) + snapshot.contentMode = .scaleAspectFill + snapshot.clipsToBounds = true + snapshot.layer.cornerRadius = 0 + snapshot.layer.cornerCurve = .continuous + snapshot.frame = currentFrame + containerView.addSubview(snapshot) + snapshotView = snapshot + + // Hide real content + contentView.alpha = 0 + + // Animate background fade out + let bgFade = CABasicAnimation(keyPath: "opacity") + bgFade.fromValue = backgroundView.alpha + bgFade.toValue = 0 + bgFade.duration = backgroundFadeOutDuration + bgFade.fillMode = .forwards + bgFade.isRemovedOnCompletion = false + backgroundView.layer.add(bgFade, forKey: "heroFadeOut") + backgroundView.alpha = 0 + + // Animate snapshot back to source + let positionAnim = CABasicAnimation(keyPath: "position") + positionAnim.fromValue = CGPoint(x: currentFrame.midX, y: currentFrame.midY) + positionAnim.toValue = CGPoint(x: targetFrame.midX, y: targetFrame.midY) + positionAnim.duration = heroBoundsDuration + positionAnim.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + positionAnim.fillMode = .forwards + positionAnim.isRemovedOnCompletion = false + + let boundsAnim = CABasicAnimation(keyPath: "bounds.size") + boundsAnim.fromValue = currentFrame.size + boundsAnim.toValue = targetFrame.size + boundsAnim.duration = heroBoundsDuration + boundsAnim.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + boundsAnim.fillMode = .forwards + boundsAnim.isRemovedOnCompletion = false + + let cornerAnim = CABasicAnimation(keyPath: "cornerRadius") + cornerAnim.fromValue = 0 + cornerAnim.toValue = sourceCornerRadius + cornerAnim.duration = heroBoundsDuration + cornerAnim.fillMode = .forwards + cornerAnim.isRemovedOnCompletion = false + + CATransaction.begin() + CATransaction.setCompletionBlock { [weak self] in + self?.sourceView?.alpha = 1 + self?.snapshotView?.removeFromSuperview() + self?.snapshotView = nil + backgroundView.layer.removeAnimation(forKey: "heroFadeOut") + completion() + } + + snapshot.layer.add(positionAnim, forKey: "heroPosition") + snapshot.layer.add(boundsAnim, forKey: "heroBounds") + snapshot.layer.add(cornerAnim, forKey: "heroCorner") + + snapshot.layer.position = CGPoint(x: targetFrame.midX, y: targetFrame.midY) + snapshot.layer.bounds = CGRect(origin: .zero, size: targetFrame.size) + snapshot.layer.cornerRadius = sourceCornerRadius + + CATransaction.commit() + } + + // MARK: - Fade Dismiss Fallback + + func animateFadeDismiss( + backgroundView: UIView, + contentView: UIView, + completion: @escaping () -> Void + ) { + sourceView?.alpha = 1 + UIView.animate(withDuration: 0.25, animations: { + backgroundView.alpha = 0 + contentView.alpha = 0 + }, completion: { _ in + completion() + }) + } + + /// Restores source view visibility without animation (called on dealloc safety). + func restoreSourceView() { + sourceView?.alpha = 1 + snapshotView?.removeFromSuperview() + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/Gallery/GalleryMenuPopup.swift b/Rosetta/Features/Chats/ChatDetail/Gallery/GalleryMenuPopup.swift new file mode 100644 index 0000000..db6bee5 --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/Gallery/GalleryMenuPopup.swift @@ -0,0 +1,169 @@ +import UIKit + +/// Lightweight Telegram-style popup menu for the gallery "..." button. +/// Reuses `TelegramContextMenuCardView` for the card visual and `TelegramContextMenuItem` for data. +/// No snapshot/blur overlay — the gallery is already dark. +final class GalleryMenuPopup: UIView { + + // MARK: - Constants + + private static let menuItemHeight: CGFloat = 44 + private static let menuGap: CGFloat = 8 + + // MARK: - Subviews + + private let menuCard: TelegramContextMenuCardView + + // MARK: - State + + private let anchorFrame: CGRect + private var isDismissing = false + private var onDismiss: (() -> Void)? + + // MARK: - Init + + private init(items: [TelegramContextMenuItem], anchorFrame: CGRect, onDismiss: (() -> Void)?) { + self.anchorFrame = anchorFrame + self.onDismiss = onDismiss + self.menuCard = TelegramContextMenuCardView(items: items, showSeparators: false, layoutStyle: .iconLeft) + super.init(frame: .zero) + buildHierarchy() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + // MARK: - Public API + + @MainActor + static func present( + in container: UIView, + anchorFrame: CGRect, + items: [TelegramContextMenuItem], + onDismiss: (() -> Void)? = nil + ) { + guard !items.isEmpty else { return } + + let popup = GalleryMenuPopup(items: items, anchorFrame: anchorFrame, onDismiss: onDismiss) + popup.frame = container.bounds + popup.autoresizingMask = [.flexibleWidth, .flexibleHeight] + container.addSubview(popup) + popup.performPresentation() + } + + // MARK: - Build + + private func buildHierarchy() { + // No dim overlay — gallery is already dark, and dim blocks blur effect. + // Tap-to-dismiss handled by gesture recognizer on self (transparent background). + backgroundColor = .clear + + menuCard.layer.shadowColor = UIColor.black.cgColor + menuCard.layer.shadowOpacity = 0.2 + menuCard.layer.shadowRadius = 10 + menuCard.layer.shadowOffset = CGSize(width: 0, height: 5) + menuCard.onItemSelected = { [weak self] in + self?.performDismissal() + } + addSubview(menuCard) + + let tap = UITapGestureRecognizer(target: self, action: #selector(tapDismiss(_:))) + tap.delegate = self + addGestureRecognizer(tap) + } + + // MARK: - Layout + + private func layoutMenu() { + let menuW = menuCard.preferredWidth + let menuH = CGFloat(menuCard.itemCount) * Self.menuItemHeight + let gap = Self.menuGap + + // Horizontal: right-align with anchor button + let menuX = max(anchorFrame.maxX - menuW, 8) + + // Vertical: prefer below, fallback above + let safeBottom = safeAreaInsets.bottom > 0 ? safeAreaInsets.bottom : 20 + let belowSpace = bounds.height - safeBottom - anchorFrame.maxY + let menuAbove: Bool + let menuY: CGFloat + + if belowSpace >= menuH + gap { + menuY = anchorFrame.maxY + gap + menuAbove = false + } else { + menuY = anchorFrame.minY - gap - menuH + menuAbove = true + } + + menuCard.frame = CGRect(x: menuX, y: menuY, width: menuW, height: menuH) + + // Anchor for scale: top-right (near "..." button) + let anchorX: CGFloat = 1.0 + let anchorY: CGFloat = menuAbove ? 1.0 : 0.0 + setAnchorPointPreservingPosition(CGPoint(x: anchorX, y: anchorY), for: menuCard) + } + + private func setAnchorPointPreservingPosition(_ anchor: CGPoint, for target: UIView) { + let oldAnchor = target.layer.anchorPoint + let delta = CGPoint( + x: (anchor.x - oldAnchor.x) * target.bounds.width, + y: (anchor.y - oldAnchor.y) * target.bounds.height + ) + target.layer.anchorPoint = anchor + target.layer.position = CGPoint( + x: target.layer.position.x + delta.x, + y: target.layer.position.y + delta.y + ) + } + + // MARK: - Presentation + + private func performPresentation() { + layoutMenu() + + menuCard.alpha = 0 + menuCard.transform = CGAffineTransform(scaleX: 0.01, y: 0.01) + + let spring = UISpringTimingParameters( + mass: 5.0, stiffness: 900.0, damping: 88.0, initialVelocity: .zero + ) + let animator = UIViewPropertyAnimator(duration: 0, timingParameters: spring) + animator.addAnimations { + self.menuCard.alpha = 1 + self.menuCard.transform = .identity + } + animator.startAnimation() + } + + // MARK: - Dismissal + + private func performDismissal() { + guard !isDismissing else { return } + isDismissing = true + + UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseIn) { + self.menuCard.alpha = 0 + self.menuCard.transform = CGAffineTransform(scaleX: 0.01, y: 0.01) + } completion: { [weak self] _ in + self?.onDismiss?() + self?.removeFromSuperview() + } + } + + // MARK: - Gestures + + @objc private func tapDismiss(_ g: UITapGestureRecognizer) { + performDismissal() + } +} + +// MARK: - UIGestureRecognizerDelegate + +extension GalleryMenuPopup: UIGestureRecognizerDelegate { + func gestureRecognizer(_ g: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + guard g is UITapGestureRecognizer else { return true } + let loc = touch.location(in: self) + return !menuCard.frame.contains(loc) + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/Gallery/GalleryOverlayView.swift b/Rosetta/Features/Chats/ChatDetail/Gallery/GalleryOverlayView.swift new file mode 100644 index 0000000..fdcaea3 --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/Gallery/GalleryOverlayView.swift @@ -0,0 +1,332 @@ +import UIKit + +// MARK: - GalleryOverlayView + +/// UIKit overlay for gallery controls — top bar, bottom bar, counter badge, caption. +/// Bottom bar: Telegram parity — Forward (left) + Draw+Captions (center capsule) + Delete (right). +/// "..." menu: custom popup (Telegram parity) — not system UIMenu. +final class GalleryOverlayView: UIView { + + var onBack: (() -> Void)? + var onMenu: (() -> Void)? + var onForward: (() -> Void)? + var onDraw: (() -> Void)? + var onCaptions: (() -> Void)? + var onDelete: (() -> Void)? + + private(set) var isControlsVisible = true + + // Top bar + private let topContainer = UIView() + private let backButton = UIButton(type: .system) + private let menuButton = UIButton(type: .system) + private let titleLabel = UILabel() + private let subtitleLabel = UILabel() + private let titleGlass = TelegramGlassUIView(frame: .zero) + private let backGlass = TelegramGlassUIView(frame: .zero) + private let menuGlass = TelegramGlassUIView(frame: .zero) + + // Counter badge + private let counterLabel = UILabel() + private let counterGlass = TelegramGlassUIView(frame: .zero) + private let counterContainer = UIView() + + // Bottom bar — Telegram parity: Forward (left) + Draw+Captions (center) + Delete (right) + private let bottomContainer = UIView() + private let leftGlass = TelegramGlassUIView(frame: .zero) + private let centerGlass = TelegramGlassUIView(frame: .zero) + private let rightGlass = TelegramGlassUIView(frame: .zero) + private let forwardButton = UIButton(type: .system) + private let drawButton = UIButton(type: .system) + private let captionsButton = UIButton(type: .system) + private let deleteButton = UIButton(type: .system) + private let centerSeparator = UIView() + + // Caption (above bottom bar) + private let captionContainer = UIView() + private let captionLabel = UILabel() + + private let topButtonSize: CGFloat = 44 + private let bottomBarH: CGFloat = 44 + + // MARK: - Adaptive Colors + + private static let primaryColor = UIColor { traits in + traits.userInterfaceStyle == .dark ? .white : .black + } + + private static let secondaryColor = UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor.white.withAlphaComponent(0.6) + : UIColor.black.withAlphaComponent(0.5) + } + + private static let separatorColor = UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor.white.withAlphaComponent(0.2) + : UIColor.black.withAlphaComponent(0.15) + } + + // MARK: - Init + + override init(frame: CGRect) { + super.init(frame: frame) + isUserInteractionEnabled = true + setupTopBar() + setupCounter() + setupBottomBar() + setupCaption() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup Top Bar + + private func setupTopBar() { + topContainer.isUserInteractionEnabled = true + addSubview(topContainer) + + backGlass.isCircle = true + backGlass.isUserInteractionEnabled = false + topContainer.addSubview(backGlass) + + configureButton(backButton, icon: "chevron.left", size: 18, weight: .semibold, action: #selector(backTapped)) + topContainer.addSubview(backButton) + + menuGlass.isCircle = true + menuGlass.isUserInteractionEnabled = false + topContainer.addSubview(menuGlass) + + let menuConfig = UIImage.SymbolConfiguration(pointSize: 18, weight: .medium) + menuButton.setImage(UIImage(systemName: "ellipsis", withConfiguration: menuConfig), for: .normal) + menuButton.tintColor = Self.primaryColor + menuButton.addTarget(self, action: #selector(menuTapped), for: .touchUpInside) + topContainer.addSubview(menuButton) + + titleGlass.isUserInteractionEnabled = false + topContainer.addSubview(titleGlass) + + titleLabel.font = .systemFont(ofSize: 17, weight: .semibold) + titleLabel.textColor = Self.primaryColor + titleLabel.textAlignment = .center + titleLabel.lineBreakMode = .byTruncatingTail + topContainer.addSubview(titleLabel) + + subtitleLabel.font = .systemFont(ofSize: 12) + subtitleLabel.textColor = Self.secondaryColor + subtitleLabel.textAlignment = .center + topContainer.addSubview(subtitleLabel) + } + + // MARK: - Setup Counter + + private func setupCounter() { + counterContainer.isUserInteractionEnabled = false + addSubview(counterContainer) + + counterGlass.isUserInteractionEnabled = false + counterContainer.addSubview(counterGlass) + + counterLabel.font = .systemFont(ofSize: 12, weight: .semibold) + counterLabel.textColor = Self.primaryColor + counterLabel.textAlignment = .center + counterContainer.addSubview(counterLabel) + } + + // MARK: - Setup Bottom Bar (Telegram parity) + + private func setupBottomBar() { + bottomContainer.isUserInteractionEnabled = true + addSubview(bottomContainer) + + // Left: Forward (single circle) + leftGlass.isCircle = true + leftGlass.isUserInteractionEnabled = false + bottomContainer.addSubview(leftGlass) + configureButton(forwardButton, icon: "arrowshape.turn.up.forward", size: 17, weight: .regular, action: #selector(forwardTapped)) + bottomContainer.addSubview(forwardButton) + + // Center: Draw + Captions (grouped capsule) + centerGlass.isUserInteractionEnabled = false + bottomContainer.addSubview(centerGlass) + + configureButton(drawButton, icon: "pencil.tip.crop.circle", size: 17, weight: .regular, action: #selector(drawTapped)) + bottomContainer.addSubview(drawButton) + + centerSeparator.backgroundColor = Self.separatorColor + centerSeparator.isUserInteractionEnabled = false + bottomContainer.addSubview(centerSeparator) + + configureButton(captionsButton, icon: "captions.bubble", size: 17, weight: .regular, action: #selector(captionsTapped)) + bottomContainer.addSubview(captionsButton) + + // Right: Delete (single circle) + rightGlass.isCircle = true + rightGlass.isUserInteractionEnabled = false + bottomContainer.addSubview(rightGlass) + configureButton(deleteButton, icon: "trash", size: 17, weight: .regular, action: #selector(deleteTapped)) + bottomContainer.addSubview(deleteButton) + } + + // MARK: - Setup Caption + + private func setupCaption() { + captionContainer.isUserInteractionEnabled = false + captionContainer.isHidden = true + addSubview(captionContainer) + + captionLabel.font = .systemFont(ofSize: 16) + captionLabel.textColor = Self.primaryColor + captionLabel.numberOfLines = 3 + captionLabel.lineBreakMode = .byTruncatingTail + captionContainer.addSubview(captionLabel) + } + + private func configureButton(_ button: UIButton, icon: String, size: CGFloat, weight: UIImage.SymbolWeight, action: Selector) { + let config = UIImage.SymbolConfiguration(pointSize: size, weight: weight) + button.setImage(UIImage(systemName: icon, withConfiguration: config), for: .normal) + button.tintColor = Self.primaryColor + button.addTarget(self, action: action, for: .touchUpInside) + } + + // MARK: - Layout + + override func layoutSubviews() { + super.layoutSubviews() + let sa = safeAreaInsets + let topY = sa.top > 0 ? sa.top : 20 + + layoutTopBar(topY: topY) + layoutCounter(topY: topY) + layoutBottomBar(sa: sa) + layoutCaption() + } + + private func layoutTopBar(topY: CGFloat) { + let hPad: CGFloat = 16 + topContainer.frame = CGRect(x: 0, y: topY, width: bounds.width, height: topButtonSize) + + backGlass.frame = CGRect(x: hPad, y: 0, width: topButtonSize, height: topButtonSize) + backButton.frame = backGlass.frame + + menuGlass.frame = CGRect(x: bounds.width - hPad - topButtonSize, y: 0, width: topButtonSize, height: topButtonSize) + menuButton.frame = menuGlass.frame + + let titleMaxW = bounds.width - (hPad + topButtonSize + 12) * 2 + let titleTextSize = titleLabel.sizeThatFits(CGSize(width: titleMaxW, height: 20)) + let subtitleTextSize = subtitleLabel.sizeThatFits(CGSize(width: titleMaxW, height: 16)) + let capsuleW = max(titleTextSize.width, subtitleTextSize.width) + 28 + let capsuleX = (bounds.width - capsuleW) / 2 + titleGlass.frame = CGRect(x: capsuleX, y: 0, width: capsuleW, height: topButtonSize) + titleLabel.frame = CGRect(x: capsuleX + 14, y: 5, width: capsuleW - 28, height: 20) + subtitleLabel.frame = CGRect(x: capsuleX + 14, y: 25, width: capsuleW - 28, height: 14) + } + + private func layoutCounter(topY: CGFloat) { + let counterY = topY + topButtonSize + 6 + let counterText = counterLabel.text ?? "" + let counterW = counterText.isEmpty ? 0 : counterLabel.sizeThatFits(CGSize(width: 200, height: 20)).width + 24 + let counterH: CGFloat = 24 + counterContainer.frame = CGRect(x: (bounds.width - counterW) / 2, y: counterY, width: counterW, height: counterH) + counterGlass.frame = counterContainer.bounds + counterLabel.frame = counterContainer.bounds + } + + private func layoutBottomBar(sa: UIEdgeInsets) { + let hPad: CGFloat = 8 + let bottomInset = sa.bottom > 0 ? sa.bottom : 16 + let bottomY = bounds.height - bottomInset - bottomBarH + bottomContainer.frame = CGRect(x: 0, y: bottomY, width: bounds.width, height: bottomBarH) + + // Left: Forward (single circle, 44×44) + leftGlass.frame = CGRect(x: hPad, y: 0, width: bottomBarH, height: bottomBarH) + forwardButton.frame = leftGlass.frame + + // Center: Draw + Captions (grouped capsule, 88×44) + let centerW: CGFloat = bottomBarH * 2 // 88pt + let centerX = (bounds.width - centerW) / 2 + centerGlass.frame = CGRect(x: centerX, y: 0, width: centerW, height: bottomBarH) + drawButton.frame = CGRect(x: centerX, y: 0, width: bottomBarH, height: bottomBarH) + captionsButton.frame = CGRect(x: centerX + bottomBarH, y: 0, width: bottomBarH, height: bottomBarH) + + // Thin separator between Draw and Captions + let sepH: CGFloat = 24 + centerSeparator.frame = CGRect( + x: centerX + bottomBarH - 0.25, + y: (bottomBarH - sepH) / 2, + width: 0.5, + height: sepH + ) + + // Right: Delete (single circle, 44×44) + rightGlass.frame = CGRect(x: bounds.width - hPad - bottomBarH, y: 0, width: bottomBarH, height: bottomBarH) + deleteButton.frame = rightGlass.frame + } + + private func layoutCaption() { + guard !captionContainer.isHidden else { return } + let hPad: CGFloat = 16 + let bottomGap: CGFloat = 14 + let maxH: CGFloat = 80 + + let availW = bounds.width - hPad * 2 + let textSize = captionLabel.sizeThatFits(CGSize(width: availW, height: maxH)) + let captionH = min(textSize.height, maxH) + let captionY = bottomContainer.frame.minY - bottomGap - captionH + + captionContainer.frame = CGRect(x: hPad, y: captionY, width: availW, height: captionH) + captionLabel.frame = captionContainer.bounds + } + + // MARK: - Public API + + func update(title: String, subtitle: String, counter: String, showCounter: Bool, caption: String) { + titleLabel.text = title + subtitleLabel.text = subtitle + counterLabel.text = counter + counterContainer.isHidden = !showCounter + captionLabel.text = caption + captionContainer.isHidden = caption.isEmpty + setNeedsLayout() + } + + func menuButtonFrame(in coordinateSpace: UICoordinateSpace) -> CGRect { + menuButton.convert(menuButton.bounds, to: coordinateSpace) + } + + func setControlsVisible(_ visible: Bool, animated: Bool) { + guard visible != isControlsVisible else { return } + isControlsVisible = visible + + let block = { [self] in + topContainer.alpha = visible ? 1 : 0 + counterContainer.alpha = visible ? 1 : 0 + bottomContainer.alpha = visible ? 1 : 0 + captionContainer.alpha = visible ? 1 : 0 + } + + if animated { + UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseInOut, animations: block) + } else { + block() + } + } + + // MARK: - Actions + + @objc private func backTapped() { onBack?() } + @objc private func menuTapped() { onMenu?() } + @objc private func forwardTapped() { onForward?() } + @objc private func drawTapped() { onDraw?() } + @objc private func captionsTapped() { onCaptions?() } + @objc private func deleteTapped() { onDelete?() } + + // MARK: - Hit Test + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let hit = super.hitTest(point, with: event) + return hit is UIButton ? hit : nil + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/Gallery/GalleryPageScrollView.swift b/Rosetta/Features/Chats/ChatDetail/Gallery/GalleryPageScrollView.swift new file mode 100644 index 0000000..f703574 --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/Gallery/GalleryPageScrollView.swift @@ -0,0 +1,187 @@ +import UIKit + +// MARK: - GalleryPageScrollView + +/// Horizontal paging scroll view with 3-page recycling (Telegram parity). +/// Uses `isPagingEnabled` for hardware-level page snapping — zero gesture +/// conflicts with per-page UIScrollView zoom. +final class GalleryPageScrollView: UIView, UIScrollViewDelegate { + + var onPageChanged: ((Int) -> Void)? + + private(set) var currentPage: Int = 0 + private var totalPages: Int = 0 + + private let scrollView = UIScrollView() + private var pages: [Int: GalleryZoomPage] = [:] + private var recycledPages: [GalleryZoomPage] = [] + private let pageGap: CGFloat = 20 + + /// Monotonic counter to invalidate stale image-load callbacks after page recycling. + private var pageGeneration: [ObjectIdentifier: UInt64] = [:] + private var nextGeneration: UInt64 = 0 + + /// Callback to load image for a given page index. + var imageLoader: ((Int, @escaping (UIImage?) -> Void) -> Void)? + + /// Callbacks forwarded from individual pages. + var onSingleTap: (() -> Void)? + var onEdgeTap: ((Int) -> Void)? + var onZoomChanged: ((Bool) -> Void)? + + /// Returns the zoom page for the current index (used by dismiss controller). + var currentZoomPage: GalleryZoomPage? { pages[currentPage] } + + // MARK: - Init + + override init(frame: CGRect) { + super.init(frame: frame) + setupScrollView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setupScrollView() { + scrollView.isPagingEnabled = true + scrollView.showsHorizontalScrollIndicator = false + scrollView.showsVerticalScrollIndicator = false + scrollView.contentInsetAdjustmentBehavior = .never + scrollView.delaysContentTouches = false + scrollView.delegate = self + scrollView.bounces = true + addSubview(scrollView) + } + + // MARK: - Public API + + func configure(totalPages: Int, initialPage: Int) { + self.totalPages = totalPages + self.currentPage = initialPage + layoutScrollView() + loadVisiblePages() + } + + func setPage(_ index: Int, animated: Bool) { + guard index >= 0, index < totalPages else { return } + let pageWidth = bounds.width + pageGap + scrollView.setContentOffset(CGPoint(x: CGFloat(index) * pageWidth, y: 0), animated: animated) + if !animated { + currentPage = index + loadVisiblePages() + } + } + + /// Disables paging scroll when a page is zoomed. + func setPagingEnabled(_ enabled: Bool) { + scrollView.isScrollEnabled = enabled + } + + // MARK: - Layout + + override func layoutSubviews() { + super.layoutSubviews() + layoutScrollView() + } + + private func layoutScrollView() { + let pageWidth = bounds.width + pageGap + scrollView.frame = CGRect(x: 0, y: 0, width: pageWidth, height: bounds.height) + scrollView.contentSize = CGSize(width: pageWidth * CGFloat(totalPages), height: bounds.height) + scrollView.contentOffset = CGPoint(x: CGFloat(currentPage) * pageWidth, y: 0) + + for (index, page) in pages { + page.frame = frameForPage(at: index) + } + } + + private func frameForPage(at index: Int) -> CGRect { + let pageWidth = bounds.width + pageGap + return CGRect(x: CGFloat(index) * pageWidth, y: 0, width: bounds.width, height: bounds.height) + } + + // MARK: - Page Management (3-page window) + + private func loadVisiblePages() { + let visibleIndices = Set((currentPage - 1)...(currentPage + 1)).filter { $0 >= 0 && $0 < totalPages } + + // Recycle pages outside visible window + for (index, page) in pages where !visibleIndices.contains(index) { + page.removeFromSuperview() + page.resetZoom() + page.image = nil + pageGeneration.removeValue(forKey: ObjectIdentifier(page)) + recycledPages.append(page) + pages.removeValue(forKey: index) + } + + // Create/load visible pages + for index in visibleIndices where pages[index] == nil { + let page = dequeuePage() + page.frame = frameForPage(at: index) + page.onSingleTap = { [weak self] in self?.onSingleTap?() } + page.onEdgeTap = { [weak self] dir in self?.handleEdgeTap(dir) } + page.onZoomChanged = { [weak self] isZoomed in + self?.scrollView.isScrollEnabled = !isZoomed + self?.onZoomChanged?(isZoomed) + } + scrollView.addSubview(page) + pages[index] = page + nextGeneration += 1 + pageGeneration[ObjectIdentifier(page)] = nextGeneration + loadImage(for: index, into: page) + } + } + + private func dequeuePage() -> GalleryZoomPage { + if let page = recycledPages.popLast() { + return page + } + return GalleryZoomPage(frame: .zero) + } + + private func loadImage(for index: Int, into page: GalleryZoomPage) { + let gen = pageGeneration[ObjectIdentifier(page)] ?? 0 + page.setLoadState(.loading) + imageLoader?(index) { [weak self, weak page] image in + guard let self, let page else { return } + guard self.pageGeneration[ObjectIdentifier(page)] == gen else { return } + if let image { + page.image = image + } else { + page.setLoadState(.failed) + } + } + } + + private func handleEdgeTap(_ direction: Int) { + let target = currentPage + direction + guard target >= 0, target < totalPages else { return } + setPage(target, animated: true) + } + + // MARK: - UIScrollViewDelegate + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + let pageWidth = bounds.width + pageGap + guard pageWidth > 0 else { return } + let newPage = Int(round(scrollView.contentOffset.x / pageWidth)) + let clamped = max(0, min(newPage, totalPages - 1)) + + if clamped != currentPage { + currentPage = clamped + loadVisiblePages() + onPageChanged?(clamped) + } + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + // Reset zoom on non-current pages after scroll settles (keep image intact) + for (index, page) in pages where index != currentPage { + page.resetZoomKeepImage() + } + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/Gallery/GalleryViewController.swift b/Rosetta/Features/Chats/ChatDetail/Gallery/GalleryViewController.swift new file mode 100644 index 0000000..ee5929e --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/Gallery/GalleryViewController.swift @@ -0,0 +1,432 @@ +import UIKit +import Photos + +// MARK: - GalleryViewController + +/// Pure UIKit gallery viewer with hero animations, UIScrollView zoom, +/// and hardware-accelerated paging (Telegram parity). +final class GalleryViewController: UIViewController { + + private let state: ImageViewerState + private weak var sourceView: UIView? + private let sourceFrame: CGRect + + private let backgroundView = UIView() + private let pagerView = GalleryPageScrollView() + private let overlayView = GalleryOverlayView() + private var dismissController: GalleryDismissController! + private var heroAnimator: GalleryHeroAnimator! + + private var isDismissing = false + var onShowInChat: ((String) -> Void)? + + // MARK: - Date Formatters + + private static let timeFormatter: DateFormatter = { + let f = DateFormatter() + f.dateStyle = .none + f.timeStyle = .short + return f + }() + + private static let dateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateStyle = .medium + f.timeStyle = .none + f.doesRelativeDateFormatting = true + return f + }() + + // MARK: - Init + + init(state: ImageViewerState, sourceView: UIView?) { + self.state = state + self.sourceView = sourceView + self.sourceFrame = state.sourceFrame + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .overFullScreen + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Status Bar + + override var prefersStatusBarHidden: Bool { true } + override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation { .fade } + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .clear + + setupBackground() + setupPager() + setupOverlay() + setupDismiss() + + // Overlay starts hidden — fades in during open animation (Telegram parity: 0.15s linear) + overlayView.alpha = 0 + + heroAnimator = GalleryHeroAnimator(containerView: view, sourceView: sourceView) + updateOverlayInfo() + prefetchAdjacentImages(around: state.initialIndex) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + performOpenAnimation() + } + + // MARK: - Setup + + private func setupBackground() { + backgroundView.backgroundColor = .black + backgroundView.alpha = 0 + backgroundView.frame = view.bounds + backgroundView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(backgroundView) + } + + private func setupPager() { + pagerView.frame = view.bounds + pagerView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(pagerView) + + pagerView.imageLoader = { [weak self] index, completion in + self?.loadImage(at: index, completion: completion) + } + + pagerView.onSingleTap = { [weak self] in + self?.toggleControls() + } + + pagerView.onEdgeTap = { [weak self] dir in + guard let self else { return } + let target = self.pagerView.currentPage + dir + guard target >= 0, target < self.state.images.count else { return } + self.pagerView.setPage(target, animated: true) + } + + pagerView.onPageChanged = { [weak self] page in + self?.updateOverlayInfo() + self?.prefetchAdjacentImages(around: page) + } + + pagerView.onZoomChanged = { [weak self] _ in + _ = self // Paging already disabled inside GalleryPageScrollView.onZoomChanged + } + + pagerView.configure(totalPages: state.images.count, initialPage: state.initialIndex) + } + + private func setupOverlay() { + overlayView.frame = view.bounds + overlayView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(overlayView) + + overlayView.onBack = { [weak self] in self?.dismissGallery() } + overlayView.onMenu = { [weak self] in self?.presentMenuPopup() } + overlayView.onForward = { [weak self] in _ = self } + overlayView.onDraw = { [weak self] in _ = self } + overlayView.onCaptions = { [weak self] in _ = self } + overlayView.onDelete = { [weak self] in _ = self } + } + + private func setupDismiss() { + dismissController = GalleryDismissController() + view.addGestureRecognizer(dismissController.panGesture) + + dismissController.isZoomedCheck = { [weak self] in + self?.pagerView.currentZoomPage?.isZoomed ?? false + } + + dismissController.onDragChanged = { [weak self] translationY in + self?.handleDragChanged(translationY) + } + + dismissController.onDismiss = { [weak self] velocity in + self?.handleDragDismiss(velocity: velocity) + } + + dismissController.onCancel = { [weak self] in + self?.handleDragCancel() + } + } + + // MARK: - Open Animation + + private func performOpenAnimation() { + let page = pagerView.currentPage + guard page < state.images.count else { + backgroundView.alpha = 1 + overlayView.alpha = 1 + return + } + + let attachmentId = state.images[page].attachmentId + if let cachedImage = AttachmentCache.shared.cachedImage(forAttachmentId: attachmentId) { + let targetFrame = fittedFrame(for: cachedImage.size) + pagerView.alpha = 0 + heroAnimator.animateOpen( + image: cachedImage, + sourceFrame: sourceFrame, + targetFrame: targetFrame, + backgroundView: backgroundView, + contentView: pagerView + ) { [weak self] in + self?.pagerView.alpha = 1 + } + // Overlay controls fade in after hero animation settles (smooth appearance) + UIView.animate(withDuration: 0.3, delay: 0.2, options: .curveEaseIn) { + self.overlayView.alpha = 1 + } + } else { + // No cached image — simple fade (overlay fades in with background) + UIView.animate(withDuration: 0.25) { + self.backgroundView.alpha = 1 + self.overlayView.alpha = 1 + } + } + } + + // MARK: - Dismiss + + private func dismissGallery() { + guard !isDismissing else { return } + isDismissing = true + + let page = pagerView.currentPage + guard page < state.images.count else { + finishDismiss() + return + } + + let attachmentId = state.images[page].attachmentId + if let image = AttachmentCache.shared.cachedImage(forAttachmentId: attachmentId) { + let currentFrame = fittedFrame(for: image.size) + overlayView.setControlsVisible(false, animated: true) + heroAnimator.animateClose( + image: image, + currentFrame: currentFrame, + backgroundView: backgroundView, + contentView: pagerView + ) { [weak self] in + self?.finishDismiss() + } + } else { + heroAnimator.animateFadeDismiss( + backgroundView: backgroundView, + contentView: pagerView + ) { [weak self] in + self?.finishDismiss() + } + } + } + + private func finishDismiss() { + heroAnimator.restoreSourceView() + dismiss(animated: false) + } + + // MARK: - Drag-to-Dismiss + + private func handleDragChanged(_ translationY: CGFloat) { + let progress = min(abs(translationY) / 300, 1.0) + backgroundView.alpha = 1 - progress + overlayView.alpha = 1 - min(abs(translationY) / 150, 1.0) + + // Move current page + pagerView.transform = CGAffineTransform(translationX: 0, y: translationY) + } + + private func handleDragDismiss(velocity: CGFloat) { + guard !isDismissing else { return } + isDismissing = true + + let page = pagerView.currentPage + guard page < state.images.count else { + finishDismiss() + return + } + + let attachmentId = state.images[page].attachmentId + if let image = AttachmentCache.shared.cachedImage(forAttachmentId: attachmentId), + sourceView?.window != nil { + let currentFrame = fittedFrame(for: image.size) + .offsetBy(dx: 0, dy: pagerView.transform.ty) + pagerView.transform = .identity + overlayView.setControlsVisible(false, animated: false) + heroAnimator.animateClose( + image: image, + currentFrame: currentFrame, + backgroundView: backgroundView, + contentView: pagerView + ) { [weak self] in + self?.finishDismiss() + } + } else { + let direction: CGFloat = velocity > 0 ? 1 : -1 + UIView.animate(withDuration: 0.2) { + self.pagerView.transform = CGAffineTransform(translationX: 0, y: direction * self.view.bounds.height) + self.backgroundView.alpha = 0 + self.overlayView.alpha = 0 + } completion: { _ in + self.pagerView.transform = .identity + self.finishDismiss() + } + } + } + + private func handleDragCancel() { + UIView.animate( + withDuration: 0.35, + delay: 0, + usingSpringWithDamping: 0.8, + initialSpringVelocity: 0, + options: .curveEaseInOut + ) { + self.pagerView.transform = .identity + self.backgroundView.alpha = 1 + self.overlayView.alpha = 1 + } + } + + // MARK: - Controls + + private func toggleControls() { + let visible = !overlayView.isControlsVisible + overlayView.setControlsVisible(visible, animated: true) + } + + // MARK: - Overlay Info + + private func updateOverlayInfo() { + let page = pagerView.currentPage + guard page < state.images.count else { return } + let info = state.images[page] + let day = Self.dateFormatter.string(from: info.timestamp) + let time = Self.timeFormatter.string(from: info.timestamp) + let counter = "\(page + 1) of \(state.images.count)" + + let rawCaption = info.caption + let caption = rawCaption.isEmpty || MessageCellLayout.isGarbageOrEncrypted(rawCaption) ? "" : rawCaption + + overlayView.update( + title: info.senderName, + subtitle: "\(day) at \(time)", + counter: counter, + showCounter: state.images.count > 1, + caption: caption + ) + } + + // MARK: - Image Loading + + private func loadImage(at index: Int, completion: @escaping (UIImage?) -> Void) { + guard index >= 0, index < state.images.count else { + completion(nil) + return + } + let attachmentId = state.images[index].attachmentId + + if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachmentId) { + completion(cached) + return + } + + Task.detached(priority: .userInitiated) { + await ImageLoadLimiter.shared.acquire() + let loaded = AttachmentCache.shared.loadImage(forAttachmentId: attachmentId) + await ImageLoadLimiter.shared.release() + + if let loaded { + await MainActor.run { completion(loaded) } + } else { + // Retry once after 500ms — covers transient I/O and key-race errors + try? await Task.sleep(nanoseconds: 500_000_000) + await ImageLoadLimiter.shared.acquire() + let retried = AttachmentCache.shared.loadImage(forAttachmentId: attachmentId) + await ImageLoadLimiter.shared.release() + await MainActor.run { completion(retried) } + } + } + } + + private func prefetchAdjacentImages(around index: Int) { + for offset in [-2, -1, 1, 2] { + let i = index + offset + guard i >= 0, i < state.images.count else { continue } + let aid = state.images[i].attachmentId + guard AttachmentCache.shared.cachedImage(forAttachmentId: aid) == nil else { continue } + Task.detached(priority: .utility) { + await ImageLoadLimiter.shared.acquire() + _ = AttachmentCache.shared.loadImage(forAttachmentId: aid) + await ImageLoadLimiter.shared.release() + } + } + } + + // MARK: - Actions + + private func saveCurrentImage() { + let page = pagerView.currentPage + guard page < state.images.count, + let image = AttachmentCache.shared.loadImage(forAttachmentId: state.images[page].attachmentId) + else { return } + PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in + guard status == .authorized || status == .limited else { return } + UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) + } + } + + // MARK: - Menu Popup + + private func presentMenuPopup() { + let items: [TelegramContextMenuItem] = [ + TelegramContextMenuItem( + title: "Show in Chat", + iconName: "bubble.left.and.text.bubble.right", + isDestructive: false, + handler: { [weak self] in + guard let self else { return } + let page = self.pagerView.currentPage + if page < self.state.images.count { + self.onShowInChat?(self.state.images[page].messageId) + } + self.dismissGallery() + } + ), + TelegramContextMenuItem( + title: "Save Image", + iconName: "square.and.arrow.down", + isDestructive: false, + handler: { [weak self] in self?.saveCurrentImage() } + ), + TelegramContextMenuItem( + title: "Delete", + iconName: "trash", + isDestructive: true, + handler: { [weak self] in _ = self } + ), + ] + let anchorFrame = overlayView.menuButtonFrame(in: view) + GalleryMenuPopup.present(in: view, anchorFrame: anchorFrame, items: items) + } + + // MARK: - Geometry + + private func fittedFrame(for imageSize: CGSize) -> CGRect { + let viewSize = view.bounds.size + guard imageSize.width > 0, imageSize.height > 0, + viewSize.width > 0, viewSize.height > 0 else { + return view.bounds + } + let scale = min(viewSize.width / imageSize.width, viewSize.height / imageSize.height) + let w = imageSize.width * scale + let h = imageSize.height * scale + return CGRect(x: (viewSize.width - w) / 2, y: (viewSize.height - h) / 2, width: w, height: h) + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/Gallery/GalleryZoomPage.swift b/Rosetta/Features/Chats/ChatDetail/Gallery/GalleryZoomPage.swift new file mode 100644 index 0000000..ea7b330 --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/Gallery/GalleryZoomPage.swift @@ -0,0 +1,266 @@ +import UIKit + +// MARK: - GalleryZoomPage + +/// Single zoomable image page using native UIScrollView zoom delegate. +/// Hardware-accelerated pinch zoom with zero gesture conflicts. +/// Centering: frame-based positioning (Telegram ZoomableContentGalleryItemNode parity). +final class GalleryZoomPage: UIView, UIScrollViewDelegate { + + enum LoadState { + case idle + case loading + case loaded + case failed + } + + var image: UIImage? { + didSet { configureImage() } + } + + var onSingleTap: (() -> Void)? + var onEdgeTap: ((Int) -> Void)? + var onZoomChanged: ((Bool) -> Void)? + + /// True when zoom scale > minimum (used by dismiss controller to disable pan). + var isZoomed: Bool { scrollView.zoomScale > scrollView.minimumZoomScale + 0.01 } + + private(set) var loadState: LoadState = .idle + + private let scrollView = UIScrollView() + private let imageView = UIImageView() + private var ignoreZoom = false + + private let loadingIndicator: UIActivityIndicatorView = { + let indicator = UIActivityIndicatorView(style: .large) + indicator.color = UIColor { $0.userInterfaceStyle == .dark ? .white : .gray } + indicator.hidesWhenStopped = true + return indicator + }() + + private let placeholderView: UIImageView = { + let config = UIImage.SymbolConfiguration(pointSize: 40, weight: .thin) + let img = UIImage(systemName: "photo.fill", withConfiguration: config) + let iv = UIImageView(image: img) + iv.tintColor = UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor.white.withAlphaComponent(0.3) + : UIColor.black.withAlphaComponent(0.2) + } + iv.contentMode = .center + iv.isHidden = true + return iv + }() + + // MARK: - Init + + override init(frame: CGRect) { + super.init(frame: frame) + setupScrollView() + setupGestures() + setupLoadingViews() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setupScrollView() { + scrollView.delegate = self + scrollView.showsVerticalScrollIndicator = false + scrollView.showsHorizontalScrollIndicator = false + scrollView.contentInsetAdjustmentBehavior = .never + scrollView.decelerationRate = .fast + scrollView.bouncesZoom = true + addSubview(scrollView) + + imageView.contentMode = .scaleToFill + imageView.clipsToBounds = true + scrollView.addSubview(imageView) + } + + private func setupLoadingViews() { + addSubview(loadingIndicator) + addSubview(placeholderView) + } + + private func setupGestures() { + let doubleTap = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap(_:))) + doubleTap.numberOfTapsRequired = 2 + scrollView.addGestureRecognizer(doubleTap) + + let singleTap = UITapGestureRecognizer(target: self, action: #selector(handleSingleTap(_:))) + singleTap.numberOfTapsRequired = 1 + singleTap.require(toFail: doubleTap) + scrollView.addGestureRecognizer(singleTap) + } + + // MARK: - Layout + + override func layoutSubviews() { + super.layoutSubviews() + let newFrame = bounds + loadingIndicator.center = CGPoint(x: bounds.midX, y: bounds.midY) + placeholderView.center = CGPoint(x: bounds.midX, y: bounds.midY) + guard scrollView.frame.size != newFrame.size else { return } + scrollView.frame = newFrame + if image != nil { + updateZoomScale() + } + } + + // MARK: - Image Configuration + + func setLoadState(_ state: LoadState) { + loadState = state + switch state { + case .loading: + loadingIndicator.startAnimating() + placeholderView.isHidden = true + case .failed: + loadingIndicator.stopAnimating() + placeholderView.isHidden = false + case .loaded, .idle: + loadingIndicator.stopAnimating() + placeholderView.isHidden = true + } + } + + private func configureImage() { + // Full state reset — prevents zoom accumulation from recycled pages + ignoreZoom = true + scrollView.minimumZoomScale = 1.0 + scrollView.maximumZoomScale = 1.0 + scrollView.zoomScale = 1.0 + scrollView.contentOffset = .zero + scrollView.contentInset = .zero + scrollView.isScrollEnabled = false + + guard let image else { + imageView.image = nil + imageView.frame = .zero + scrollView.contentSize = .zero + ignoreZoom = false + return + } + + setLoadState(.loaded) + imageView.image = image + imageView.frame = CGRect(origin: .zero, size: image.size) + scrollView.contentSize = image.size + ignoreZoom = false + updateZoomScale() + } + + private func updateZoomScale() { + guard let image, bounds.width > 0, bounds.height > 0 else { return } + let scaleW = bounds.width / image.size.width + let scaleH = bounds.height / image.size.height + let minScale = min(scaleW, scaleH) + let maxScale = max(minScale * 3.0, 1.0) + + // Collapse check (Telegram parity) + let effectiveMax = abs(maxScale - minScale) < 0.01 ? minScale : maxScale + + ignoreZoom = true + scrollView.minimumZoomScale = minScale + scrollView.maximumZoomScale = effectiveMax + scrollView.zoomScale = minScale + scrollView.contentInset = .zero + scrollView.isScrollEnabled = false + ignoreZoom = false + centerContent() + } + + /// Light reset: returns to min zoom without clearing the image. + /// Used by `scrollViewDidEndDecelerating` on adjacent pages. + func resetZoomKeepImage() { + guard isZoomed else { return } + ignoreZoom = true + scrollView.setZoomScale(scrollView.minimumZoomScale, animated: false) + scrollView.contentOffset = .zero + scrollView.isScrollEnabled = false + ignoreZoom = false + centerContent() + } + + /// Full state reset (called when page is recycled in pager). + func resetZoom() { + ignoreZoom = true + scrollView.minimumZoomScale = 1.0 + scrollView.maximumZoomScale = 1.0 + scrollView.zoomScale = 1.0 + scrollView.contentOffset = .zero + scrollView.contentInset = .zero + scrollView.contentSize = .zero + scrollView.isScrollEnabled = false + imageView.image = nil + imageView.frame = .zero + ignoreZoom = false + setLoadState(.idle) + } + + // MARK: - Content Centering (Telegram frame-based, NOT contentInset) + + private func centerContent() { + let boundsSize = scrollView.bounds.size + var frame = imageView.frame + + if boundsSize.width > frame.size.width { + frame.origin.x = (boundsSize.width - frame.size.width) / 2.0 + } else { + frame.origin.x = 0.0 + } + + if boundsSize.height >= frame.size.height { + frame.origin.y = (boundsSize.height - frame.size.height) / 2.0 + } else { + frame.origin.y = 0.0 + } + + imageView.frame = frame + } + + // MARK: - UIScrollViewDelegate + + func viewForZooming(in scrollView: UIScrollView) -> UIView? { + imageView + } + + func scrollViewDidZoom(_ scrollView: UIScrollView) { + guard !ignoreZoom else { return } + centerContent() + let isAtMin = scrollView.zoomScale <= scrollView.minimumZoomScale + 0.01 + scrollView.isScrollEnabled = !isAtMin + onZoomChanged?(!isAtMin) + } + + // MARK: - Gestures + + @objc private func handleDoubleTap(_ gesture: UITapGestureRecognizer) { + if scrollView.zoomScale > scrollView.minimumZoomScale + 0.01 { + scrollView.setZoomScale(scrollView.minimumZoomScale, animated: true) + } else { + let point = gesture.location(in: imageView) + let targetScale = min(scrollView.maximumZoomScale, scrollView.minimumZoomScale * 2.5) + let w = scrollView.bounds.width / targetScale + let h = scrollView.bounds.height / targetScale + let rect = CGRect(x: point.x - w / 2, y: point.y - h / 2, width: w, height: h) + scrollView.zoom(to: rect, animated: true) + } + } + + @objc private func handleSingleTap(_ gesture: UITapGestureRecognizer) { + let location = gesture.location(in: self) + let edgeZone = bounds.width * 0.20 + if location.x < edgeZone { + onEdgeTap?(-1) + } else if location.x > bounds.width - edgeZone { + onEdgeTap?(1) + } else { + onSingleTap?() + } + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift b/Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift index 55a5e43..fd3079f 100644 --- a/Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift +++ b/Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift @@ -1,12 +1,10 @@ -import Combine -import SwiftUI import UIKit -import Photos // MARK: - Data Types struct ViewableImageInfo: Equatable, Identifiable { let attachmentId: String + let messageId: String let senderName: String let timestamp: Date let caption: String @@ -19,398 +17,33 @@ struct ImageViewerState: Equatable { let sourceFrame: CGRect } -// MARK: - GalleryDismissPanCoordinator - -/// Manages the vertical pan gesture for gallery dismiss. -/// Attached to the hosting controller's view (NOT as a SwiftUI overlay) so it -/// doesn't block SwiftUI gestures (pinch zoom, taps) on the content below. -final class GalleryDismissPanCoordinator: NSObject, ObservableObject, UIGestureRecognizerDelegate { - @Published var dragOffset: CGSize = .zero - @Published private(set) var panEndSignal: Bool = false - private(set) var endVelocityY: CGFloat = 0 - var isEnabled: Bool = true - - @objc func handlePan(_ gesture: UIPanGestureRecognizer) { - guard isEnabled else { - if gesture.state == .began { gesture.state = .cancelled } - return - } - let t = gesture.translation(in: gesture.view) - switch gesture.state { - case .began, .changed: - dragOffset = CGSize(width: 0, height: t.y) - case .ended, .cancelled: - endVelocityY = gesture.velocity(in: gesture.view).y - panEndSignal.toggle() - default: break - } - } - - func gestureRecognizerShouldBegin(_ g: UIGestureRecognizer) -> Bool { - guard isEnabled, let pan = g as? UIPanGestureRecognizer else { return false } - let v = pan.velocity(in: pan.view) - return v.y > abs(v.x) - } - - func gestureRecognizer(_ g: UIGestureRecognizer, - shouldRecognizeSimultaneouslyWith o: UIGestureRecognizer) -> Bool { - !(o is UIPanGestureRecognizer) - } -} - // MARK: - ImageViewerPresenter -private final class StatusBarHiddenHostingController: UIHostingController { - override var prefersStatusBarHidden: Bool { true } - override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation { .fade } -} - @MainActor final class ImageViewerPresenter { static let shared = ImageViewerPresenter() - private weak var presentedController: UIViewController? - private var panCoordinator: GalleryDismissPanCoordinator? + private weak var presentedController: GalleryViewController? + var onShowInChat: ((String) -> Void)? - func present(state: ImageViewerState) { + func present(state: ImageViewerState, sourceView: UIView? = nil) { guard presentedController == nil else { return } - let coordinator = GalleryDismissPanCoordinator() - panCoordinator = coordinator - - let viewer = ImageGalleryViewer( - state: state, - panCoordinator: coordinator, - onDismiss: { [weak self] in self?.dismiss() } - ) - - let hc = StatusBarHiddenHostingController(rootView: AnyView(viewer)) - hc.modalPresentationStyle = .overFullScreen - hc.view.backgroundColor = .clear - - let pan = UIPanGestureRecognizer( - target: coordinator, - action: #selector(GalleryDismissPanCoordinator.handlePan) - ) - pan.minimumNumberOfTouches = 1 - pan.maximumNumberOfTouches = 1 - pan.delegate = coordinator - hc.view.addGestureRecognizer(pan) + let galleryVC = GalleryViewController(state: state, sourceView: sourceView) + galleryVC.onShowInChat = { [weak self] messageId in + self?.onShowInChat?(messageId) + } guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let root = scene.keyWindow?.rootViewController else { return } var presenter = root while let p = presenter.presentedViewController { presenter = p } - presenter.present(hc, animated: false) - presentedController = hc + presenter.present(galleryVC, animated: false) + presentedController = galleryVC } func dismiss() { - panCoordinator = nil presentedController?.dismiss(animated: false) presentedController = nil } } - -// MARK: - Window Safe Area Helper - -private var windowSafeArea: UIEdgeInsets { - UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .first?.keyWindow?.safeAreaInsets ?? .zero -} - -// MARK: - ImageGalleryViewer - -struct ImageGalleryViewer: View { - - let state: ImageViewerState - @ObservedObject var panCoordinator: GalleryDismissPanCoordinator - let onDismiss: () -> Void - - @State private var currentPage: Int - @State private var showControls = true - @State private var currentZoomScale: CGFloat = 1.0 - @State private var isDismissing = false - @State private var isExpanded: Bool = false - - private let screenSize = UIScreen.main.bounds.size - - private static let dateFormatter: DateFormatter = { - let f = DateFormatter() - f.dateStyle = .none - f.timeStyle = .short - return f - }() - - private static let relativeDateFormatter: DateFormatter = { - let f = DateFormatter() - f.dateStyle = .medium - f.timeStyle = .none - f.doesRelativeDateFormatting = true - return f - }() - - init(state: ImageViewerState, panCoordinator: GalleryDismissPanCoordinator, onDismiss: @escaping () -> Void) { - self.state = state - self.panCoordinator = panCoordinator - self.onDismiss = onDismiss - self._currentPage = State(initialValue: state.initialIndex) - } - - private var currentInfo: ViewableImageInfo? { - state.images.indices.contains(currentPage) ? state.images[currentPage] : nil - } - - private var backgroundOpacity: CGFloat { - let progress = min(abs(panCoordinator.dragOffset.height) / 80, 1) - return isExpanded ? max(1 - progress, 0) : 0 - } - - private var overlayDragOpacity: CGFloat { - 1 - min(abs(panCoordinator.dragOffset.height) / 50, 1) - } - - private func formattedDate(_ date: Date) -> String { - let day = Self.relativeDateFormatter.string(from: date) - let time = Self.dateFormatter.string(from: date) - return "\(day) at \(time)" - } - - // MARK: - Body - - var body: some View { - TabView(selection: $currentPage) { - ForEach(Array(state.images.enumerated()), id: \.element.attachmentId) { index, info in - ZoomableImagePage( - attachmentId: info.attachmentId, - onDismiss: { dismissAction() }, - showControls: $showControls, - currentScale: $currentZoomScale, - onEdgeTap: { dir in navigateEdgeTap(direction: dir) } - ) - .offset(panCoordinator.dragOffset) - .tag(index) - } - } - .tabViewStyle(.page(indexDisplayMode: .never)) - .ignoresSafeArea() - .scrollDisabled(currentZoomScale > 1.05 || isDismissing) - .contentShape(Rectangle()) - .overlay { galleryOverlay } - .background { - Color.black.opacity(backgroundOpacity).ignoresSafeArea() - } - .allowsHitTesting(isExpanded) - .statusBarHidden(true) - .task { - prefetchAdjacentImages(around: state.initialIndex) - guard !isExpanded else { return } - withAnimation(.easeOut(duration: 0.2)) { isExpanded = true } - } - .onChange(of: currentPage) { _, p in prefetchAdjacentImages(around: p) } - .onChange(of: currentZoomScale) { _, s in panCoordinator.isEnabled = s <= 1.05 } - .onChange(of: panCoordinator.panEndSignal) { _, _ in handlePanEnd() } - } - - // MARK: - Pan End - - private func handlePanEnd() { - let y = panCoordinator.dragOffset.height - let v = panCoordinator.endVelocityY - if y > 50 || v > 1000 { - dismissAction() - } else { - withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) { - panCoordinator.dragOffset = .zero - } - } - } - - // MARK: - Overlay (Telegram parity) - - @ViewBuilder - private var galleryOverlay: some View { - let sa = windowSafeArea - - if !isDismissing && isExpanded { - ZStack { - // Top panel - VStack(spacing: 4) { - topPanel - if state.images.count > 1 { - counterBadge - } - Spacer() - } - .frame(maxWidth: .infinity) - .padding(.top, sa.top > 0 ? sa.top : 20) - .offset(y: showControls ? 0 : -(sa.top + 120)) - .allowsHitTesting(showControls) - - // Bottom panel - VStack { - Spacer() - bottomPanel - .padding(.bottom, sa.bottom > 0 ? sa.bottom : 16) - } - .offset(y: showControls ? 0 : (sa.bottom + 120)) - .allowsHitTesting(showControls) - } - .compositingGroup() - .opacity(overlayDragOpacity) - .animation(.spring(response: 0.3, dampingFraction: 0.85), value: showControls) - .environment(\.colorScheme, .dark) - } - } - - // MARK: - Top Panel (Telegram parity) - - private var topPanel: some View { - HStack(alignment: .top) { - glassCircleButton(systemName: "chevron.left") { dismissAction() } - Spacer(minLength: 0) - glassCircleButton(systemName: "ellipsis") { } - } - .overlay(alignment: .top) { - if let info = currentInfo { - VStack(spacing: 2) { - Text(info.senderName) - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(.white) - .lineLimit(1) - Text(formattedDate(info.timestamp)) - .font(.system(size: 12)) - .foregroundStyle(.white.opacity(0.6)) - } - .padding(.horizontal, 14) - .padding(.vertical, 8) - .background { TelegramGlassCapsule() } - .contentTransition(.numericText()) - .animation(.easeInOut, value: currentPage) - } - } - .padding(.horizontal, 16) - } - - // MARK: - Counter - - private var counterBadge: some View { - Text("\(currentPage + 1) of \(state.images.count)") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.white) - .padding(.horizontal, 12) - .padding(.vertical, 4) - .background { TelegramGlassCapsule() } - .padding(.top, 6) - .contentTransition(.numericText()) - .animation(.easeInOut, value: currentPage) - } - - // MARK: - Bottom Panel (Telegram parity) - // Telegram: Forward (left) — [draw | caption center] — Delete (right) - // Rosetta: Forward — Share — Save — Delete - - private var bottomPanel: some View { - HStack(spacing: 0) { - // Forward - glassCircleButton(systemName: "arrowshape.turn.up.right") { } - - Spacer() - - // Share - glassCircleButton(systemName: "square.and.arrow.up") { shareCurrentImage() } - - Spacer() - - // Save to Photos - glassCircleButton(systemName: "square.and.arrow.down") { saveCurrentImage() } - - Spacer() - - // Delete - glassCircleButton(systemName: "trash") { } - } - .padding(.horizontal, 16) - } - - // MARK: - Glass Button - - private func glassCircleButton(systemName: String, action: @escaping () -> Void) -> some View { - Button(action: action) { - Image(systemName: systemName) - .font(.system(size: 22)) - .foregroundStyle(.white) - .frame(width: 44, height: 44) - } - .background { TelegramGlassCircle() } - } - - // MARK: - Navigation - - private func navigateEdgeTap(direction: Int) { - let t = currentPage + direction - guard t >= 0, t < state.images.count else { return } - currentPage = t - } - - // MARK: - Dismiss - - private func dismissAction() { - fadeDismiss() - } - - private func fadeDismiss() { - guard !isDismissing else { return } - isDismissing = true - panCoordinator.isEnabled = false - withAnimation(.easeOut(duration: 0.25)) { - panCoordinator.dragOffset = CGSize(width: 0, height: screenSize.height * 0.4) - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.27) { onDismiss() } - } - - // MARK: - Actions - - private func shareCurrentImage() { - guard let info = currentInfo, - let image = AttachmentCache.shared.loadImage(forAttachmentId: info.attachmentId) - else { return } - let vc = UIActivityViewController(activityItems: [image], applicationActivities: nil) - if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let root = scene.keyWindow?.rootViewController { - var p = root; while let pp = p.presentedViewController { p = pp } - vc.popoverPresentationController?.sourceView = p.view - vc.popoverPresentationController?.sourceRect = CGRect( - x: p.view.bounds.midX, y: p.view.bounds.maxY - 50, width: 0, height: 0) - p.present(vc, animated: true) - } - } - - private func saveCurrentImage() { - guard let info = currentInfo, - let image = AttachmentCache.shared.loadImage(forAttachmentId: info.attachmentId) - 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 [-2, -1, 1, 2] { - let i = index + offset - guard i >= 0, i < state.images.count else { continue } - let aid = state.images[i].attachmentId - guard AttachmentCache.shared.cachedImage(forAttachmentId: aid) == nil else { continue } - Task.detached(priority: .utility) { - await ImageLoadLimiter.shared.acquire() - _ = AttachmentCache.shared.loadImage(forAttachmentId: aid) - await ImageLoadLimiter.shared.release() - } - } - } -} - diff --git a/Rosetta/Features/Chats/ChatDetail/MessageCellActions.swift b/Rosetta/Features/Chats/ChatDetail/MessageCellActions.swift index 793baea..a14af4c 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageCellActions.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageCellActions.swift @@ -1,4 +1,5 @@ import Foundation +import UIKit /// Stable callback reference for message cell interactions. /// Class ref means SwiftUI sees the same pointer on parent re-render, @@ -9,11 +10,15 @@ final class MessageCellActions { var onForward: (ChatMessage) -> Void = { _ in } var onDelete: (ChatMessage) -> Void = { _ in } var onCopy: (String) -> Void = { _ in } - var onImageTap: (String, CGRect) -> Void = { _, _ in } + var onImageTap: (String, CGRect, UIView?) -> Void = { _, _, _ in } var onScrollToMessage: (String) -> Void = { _ in } var onRetry: (ChatMessage) -> Void = { _ in } var onRemove: (ChatMessage) -> Void = { _ in } var onCall: (String) -> Void = { _ in } // peer public key var onGroupInviteTap: (String) -> Void = { _ in } // invite string var onGroupInviteOpen: (String) -> Void = { _ in } // group dialog key → navigate + + // Multi-select + var onEnterSelection: (ChatMessage) -> Void = { _ in } + var onToggleSelection: (String) -> Void = { _ in } // messageId } diff --git a/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift b/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift index 71ff8dc..9c11063 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift @@ -129,7 +129,7 @@ struct MessageCellView: View, Equatable { replyQuoteView(reply: reply, outgoing: outgoing) } - Text(parsedMarkdown(messageText)) + Text(parsedMarkdown(messageText, outgoing: outgoing)) .font(.system(size: 17, weight: .regular)) .tracking(-0.43) .foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text) @@ -224,7 +224,7 @@ struct MessageCellView: View, Equatable { attachments: imageAttachments, outgoing: outgoing, maxWidth: imageContentWidth, - onImageTap: { attId in actions.onImageTap(attId, .zero) } + onImageTap: { attId in actions.onImageTap(attId, .zero, nil) } ) .padding(.horizontal, 6) .padding(.top, 4) @@ -237,7 +237,7 @@ struct MessageCellView: View, Equatable { } if hasCaption { - Text(parsedMarkdown(reply.message)) + Text(parsedMarkdown(reply.message, outgoing: outgoing)) .font(.system(size: 17, weight: .regular)) .tracking(-0.43) .foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text) @@ -280,7 +280,7 @@ struct MessageCellView: View, Equatable { onTap: !imageAttachments.isEmpty ? { _, overlayView in if let firstId = imageAttachments.first?.id { let frame = overlayView?.convert(overlayView?.bounds ?? .zero, to: nil) ?? .zero - actions.onImageTap(firstId, frame) + actions.onImageTap(firstId, frame, nil) } } : nil ) @@ -348,7 +348,7 @@ struct MessageCellView: View, Equatable { } if hasCaption { - Text(parsedMarkdown(message.text)) + Text(parsedMarkdown(message.text, outgoing: outgoing)) .font(.system(size: 17, weight: .regular)) .tracking(-0.43) .foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text) @@ -389,7 +389,7 @@ struct MessageCellView: View, Equatable { ) if AttachmentCache.shared.loadImage(forAttachmentId: tappedId) != nil { let frame = overlayView?.convert(overlayView?.bounds ?? .zero, to: nil) ?? .zero - actions.onImageTap(tappedId, frame) + actions.onImageTap(tappedId, frame, nil) } else { NotificationCenter.default.post( name: .triggerAttachmentDownload, object: tappedId @@ -739,15 +739,16 @@ struct MessageCellView: View, Equatable { @MainActor private static var markdownCache: [String: AttributedString] = [:] - private func parsedMarkdown(_ text: String) -> AttributedString { - if let cached = Self.markdownCache[text] { + private func parsedMarkdown(_ text: String, outgoing: Bool = false) -> AttributedString { + let cacheKey = outgoing ? "out:\(text)" : text + if let cached = Self.markdownCache[cacheKey] { PerformanceLogger.shared.track("markdown.cacheHit") return cached } PerformanceLogger.shared.track("markdown.cacheMiss") let withEmoji = EmojiParser.replaceShortcodes(in: text) - let result: AttributedString + var result: AttributedString if let parsed = try? AttributedString( markdown: withEmoji, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) @@ -756,11 +757,21 @@ struct MessageCellView: View, Equatable { } else { result = AttributedString(withEmoji) } + + // Outgoing links: white on blue bubble (Telegram parity) + if outgoing { + for run in result.runs { + if result[run.range].link != nil { + result[run.range].foregroundColor = .white + } + } + } + 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 + Self.markdownCache[cacheKey] = result return result } diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift index 83a2c62..58730a0 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift @@ -1,4 +1,5 @@ import UIKit +import SwiftUI /// Universal pure UIKit message cell — handles ALL message types. /// Rosetta equivalent of Telegram's ChatMessageBubbleItemNode. @@ -68,6 +69,28 @@ final class NativeMessageCell: UICollectionViewCell { UIGraphicsEndImageContext() return image.withRenderingMode(.alwaysTemplate) }() + /// Gold admin badge (Desktop parity: IconArrowBadgeDownFilled, gold #FFD700). + private static let goldAdminBadgeImage: UIImage = { + let viewBox = CGSize(width: 24, height: 24) + let canvasSize = CGSize(width: 16, height: 16) + let scale = UIScreen.main.scale + UIGraphicsBeginImageContextWithOptions(canvasSize, false, scale) + guard let ctx = UIGraphicsGetCurrentContext() else { return UIImage() } + var parser = SVGPathParser(pathData: TablerIconPath.arrowBadgeDownFilled) + let cgPath = parser.parse() + let fitScale = min(canvasSize.width / viewBox.width, canvasSize.height / viewBox.height) + let scaledW = viewBox.width * fitScale + let scaledH = viewBox.height * fitScale + ctx.translateBy(x: (canvasSize.width - scaledW) / 2, y: (canvasSize.height - scaledH) / 2) + ctx.scaleBy(x: fitScale, y: fitScale) + ctx.addPath(cgPath) + ctx.setFillColor(UIColor(red: 1.0, green: 0.843, blue: 0.0, alpha: 1.0).cgColor) + ctx.fillPath() + let image = UIGraphicsGetImageFromCurrentImageContext() ?? UIImage() + UIGraphicsEndImageContext() + return image + }() + // Telegram-exact stretchable bubble images (raster, not vector — only way to get exact tail). // `var` so they can be regenerated on theme switch (colors baked into raster at generation time). private static var bubbleImages = BubbleImageFactory.generate( @@ -179,6 +202,7 @@ final class NativeMessageCell: UICollectionViewCell { // Group sender info (Telegram parity) private let senderNameLabel = UILabel() + private let senderAdminIconView = UIImageView() private let senderAvatarContainer = UIView() private let senderAvatarImageView = UIImageView() private let senderAvatarInitialLabel = UILabel() @@ -201,6 +225,12 @@ final class NativeMessageCell: UICollectionViewCell { // Highlight overlay (scroll-to-message flash) private let highlightOverlay = UIView() + // Multi-select (Telegram parity: 28×28 checkbox, 42pt content shift) + private let selectionCheckContainer = UIView() + private let selectionCheckBorder = CAShapeLayer() + private let selectionCheckFill = CAShapeLayer() + private let selectionCheckmarkView = UIImageView() + // Swipe-to-reply private let replyCircleView = UIView() private let replyIconView = UIImageView() @@ -230,6 +260,11 @@ final class NativeMessageCell: UICollectionViewCell { private var downloadingAttachmentIds: Set = [] private var failedAttachmentIds: Set = [] + // Multi-select state + private(set) var isInSelectionMode = false + private(set) var isMessageSelected = false + private var selectionOffset: CGFloat = 0 // 0 or 42 (Telegram: 42pt shift) + // MARK: - Init override init(frame: CGRect) { @@ -511,12 +546,20 @@ final class NativeMessageCell: UICollectionViewCell { senderNameLabel.isHidden = true bubbleView.addSubview(senderNameLabel) // INSIDE bubble (Telegram: name is first line in bubble) + senderAdminIconView.contentMode = .scaleAspectFit + senderAdminIconView.isHidden = true + bubbleView.addSubview(senderAdminIconView) + senderAvatarContainer.layer.cornerRadius = 18 // 36pt circle senderAvatarContainer.clipsToBounds = true senderAvatarContainer.isHidden = true contentView.addSubview(senderAvatarContainer) - senderAvatarInitialLabel.font = .systemFont(ofSize: 11, weight: .medium) + // Match AvatarView: size * 0.38, bold, rounded design + let avatarFontSize: CGFloat = 36 * 0.38 + let descriptor = UIFont.systemFont(ofSize: avatarFontSize, weight: .bold) + .fontDescriptor.withDesign(.rounded) ?? UIFont.systemFont(ofSize: avatarFontSize, weight: .bold).fontDescriptor + senderAvatarInitialLabel.font = UIFont(descriptor: descriptor, size: avatarFontSize) senderAvatarInitialLabel.textColor = .white senderAvatarInitialLabel.textAlignment = .center senderAvatarContainer.addSubview(senderAvatarInitialLabel) @@ -558,6 +601,43 @@ final class NativeMessageCell: UICollectionViewCell { deliveryFailedButton.addTarget(self, action: #selector(handleDeliveryFailedTap), for: .touchUpInside) contentView.addSubview(deliveryFailedButton) + // Multi-select checkbox (Telegram: 28×28pt circle, position at x:6) + selectionCheckContainer.frame = CGRect(x: 0, y: 0, width: 28, height: 28) + selectionCheckContainer.isHidden = true + selectionCheckContainer.isUserInteractionEnabled = false + + let checkPath = UIBezierPath(ovalIn: CGRect(x: 0, y: 0, width: 28, height: 28)) + selectionCheckBorder.path = checkPath.cgPath + selectionCheckBorder.fillColor = UIColor.clear.cgColor + selectionCheckBorder.strokeColor = UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor.white.withAlphaComponent(0.5) + : UIColor.black.withAlphaComponent(0.3) + }.cgColor + selectionCheckBorder.lineWidth = 1.5 // Telegram: 1.0 + UIScreenPixel + selectionCheckContainer.layer.addSublayer(selectionCheckBorder) + + // Telegram CheckNode overlay shadow + selectionCheckContainer.layer.shadowColor = UIColor.black.cgColor + selectionCheckContainer.layer.shadowOpacity = 0.22 + selectionCheckContainer.layer.shadowRadius = 2.5 + selectionCheckContainer.layer.shadowOffset = .zero + + selectionCheckFill.path = checkPath.cgPath + selectionCheckFill.fillColor = UIColor(red: 0.14, green: 0.54, blue: 0.90, alpha: 1).cgColor // #248AE6 + selectionCheckFill.isHidden = true + selectionCheckContainer.layer.addSublayer(selectionCheckFill) + + let checkConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold) + selectionCheckmarkView.image = UIImage(systemName: "checkmark", withConfiguration: checkConfig) + selectionCheckmarkView.tintColor = .white + selectionCheckmarkView.contentMode = .center + selectionCheckmarkView.frame = CGRect(x: 0, y: 0, width: 28, height: 28) + selectionCheckmarkView.isHidden = true + selectionCheckContainer.addSubview(selectionCheckmarkView) + + contentView.addSubview(selectionCheckContainer) + // Long-press → Telegram context menu let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:))) longPress.minimumPressDuration = 0.35 @@ -727,7 +807,7 @@ final class NativeMessageCell: UICollectionViewCell { // Title (16pt medium — Telegram parity) fileNameLabel.font = UIFont.systemFont(ofSize: 16, weight: .medium) - fileNameLabel.textColor = .label + fileNameLabel.textColor = isOutgoing ? .white : .label if isMissed { fileNameLabel.text = isIncoming ? "Missed Call" : "Cancelled Call" } else { @@ -740,7 +820,7 @@ final class NativeMessageCell: UICollectionViewCell { fileSizeLabel.textColor = UIColor(red: 1.0, green: 0.28, blue: 0.28, alpha: 0.95) } else { fileSizeLabel.text = Self.formattedDuration(seconds: durationSec) - fileSizeLabel.textColor = .secondaryLabel + fileSizeLabel.textColor = isOutgoing ? UIColor.white.withAlphaComponent(0.6) : .secondaryLabel } // Directional arrow (green/red) @@ -782,12 +862,14 @@ final class NativeMessageCell: UICollectionViewCell { // Telegram parity: accent blue filename (incoming) or white (outgoing) fileNameLabel.textColor = isFileOutgoing ? .white : UIColor(red: 0, green: 0x8B/255.0, blue: 1.0, alpha: 1) fileSizeLabel.text = Self.formattedFileSize(bytes: parsed.fileSize) - fileSizeLabel.textColor = .secondaryLabel + fileSizeLabel.textColor = isFileOutgoing ? UIColor.white.withAlphaComponent(0.5) : .secondaryLabel callArrowView.isHidden = true callBackButton.isHidden = true } else if let avatarAtt = message.attachments.first(where: { $0.type == .avatar }) { + let isOutgoing = currentLayout?.isOutgoing ?? false fileNameLabel.font = Self.fileNameFont fileNameLabel.text = "Avatar" + fileNameLabel.textColor = isOutgoing ? .white : .label callArrowView.isHidden = true callBackButton.isHidden = true @@ -798,9 +880,8 @@ final class NativeMessageCell: UICollectionViewCell { avatarImageView.isHidden = false fileIconView.isHidden = true fileSizeLabel.text = "Shared profile photo" - fileSizeLabel.textColor = .secondaryLabel + fileSizeLabel.textColor = isOutgoing ? UIColor.white.withAlphaComponent(0.5) : .secondaryLabel } else { - let isOutgoing = currentLayout?.isOutgoing ?? false if isOutgoing { // Own avatar — already uploaded, just loading from disk fileSizeLabel.text = "Shared profile photo" @@ -808,7 +889,7 @@ final class NativeMessageCell: UICollectionViewCell { // Incoming avatar — needs download on tap (Android parity) fileSizeLabel.text = "Tap to download" } - fileSizeLabel.textColor = .secondaryLabel + fileSizeLabel.textColor = isOutgoing ? UIColor.white.withAlphaComponent(0.5) : .secondaryLabel // Show blurhash placeholder (decode async if not cached) let hash = AttachmentPreviewCodec.blurHash(from: avatarAtt.preview) if !hash.isEmpty, let blurImg = Self.blurHashCache.object(forKey: hash as NSString) { @@ -853,13 +934,15 @@ final class NativeMessageCell: UICollectionViewCell { } } } else { + let isOutgoing = currentLayout?.isOutgoing ?? false avatarImageView.isHidden = true fileIconView.isHidden = false fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2) fileIconSymbolView.image = UIImage(systemName: "doc.fill") fileNameLabel.font = Self.fileNameFont fileNameLabel.text = "File" - fileSizeLabel.textColor = .secondaryLabel + fileNameLabel.textColor = isOutgoing ? .white : .label + fileSizeLabel.textColor = isOutgoing ? UIColor.white.withAlphaComponent(0.5) : .secondaryLabel callArrowView.isHidden = true callBackButton.isHidden = true } @@ -976,14 +1059,21 @@ final class NativeMessageCell: UICollectionViewCell { // Group incoming: offset right by 40pt for avatar lane (Telegram parity). let isGroupIncoming = !layout.isOutgoing && layout.senderKey.count > 0 let groupAvatarLane: CGFloat = isGroupIncoming ? 38 : 0 - let bubbleX: CGFloat + let baseBubbleX: CGFloat if layout.isOutgoing { - bubbleX = cellW - layout.bubbleSize.width - tailProtrusion - 2 - layout.deliveryFailedInset + baseBubbleX = cellW - layout.bubbleSize.width - tailProtrusion - 2 - layout.deliveryFailedInset } else if isGroupIncoming { // Group: avatar lane replaces tail space. Avatar at 4pt, bubble after lane. - bubbleX = 4 + groupAvatarLane + baseBubbleX = 4 + groupAvatarLane } else { - bubbleX = tailProtrusion + 2 + baseBubbleX = tailProtrusion + 2 + } + // Telegram parity: only incoming messages shift right in selection mode + let bubbleX: CGFloat + if layout.isOutgoing { + bubbleX = baseBubbleX + } else { + bubbleX = baseBubbleX + selectionOffset } bubbleView.frame = CGRect( @@ -991,6 +1081,12 @@ final class NativeMessageCell: UICollectionViewCell { width: layout.bubbleSize.width, height: layout.bubbleSize.height ) + // ── Selection checkbox (Telegram: 28×28, x:6, vertically centered with bubble) ── + if isInSelectionMode { + let checkY = layout.bubbleFrame.minY + (layout.bubbleSize.height - 28) / 2 + selectionCheckContainer.frame = CGRect(x: 6, y: checkY, width: 28, height: 28) + } + // ── Raster bubble image (Telegram-exact tail via stretchable image) ── // Telegram includes tail space (6pt) in backgroundFrame for ALL bubbles, // not just tailed ones. This keeps right edges aligned in a group. @@ -1200,14 +1296,7 @@ final class NativeMessageCell: UICollectionViewCell { deliveryFailedButton.alpha = 0 } - // Reply icon (for swipe gesture) — positioned behind bubble's trailing edge. - // Starts hidden (alpha=0, scale=0). As bubble slides left via transform, - // the icon is revealed in the gap between shifted bubble and original position. - let replyIconDiameter: CGFloat = 34 - let replyIconX = bubbleView.frame.maxX - replyIconDiameter - let replyIconY = bubbleView.frame.midY - replyIconDiameter / 2 - replyCircleView.frame = CGRect(x: replyIconX, y: replyIconY, width: replyIconDiameter, height: replyIconDiameter) - replyIconView.frame = CGRect(x: replyIconX + 7, y: replyIconY + 7, width: 20, height: 20) + // Reply icon position is set AFTER sender name expansion (see below). // Group sender name — INSIDE bubble as first line (Telegram parity). // When shown, shift all bubble content (text, reply, etc.) down by senderNameShift. @@ -1218,13 +1307,28 @@ final class NativeMessageCell: UICollectionViewCell { let nameColorIdx = RosettaColors.avatarColorIndex(for: layout.senderName, publicKey: layout.senderKey) senderNameLabel.textColor = RosettaColors.avatarTextColor(for: nameColorIdx) senderNameLabel.sizeToFit() + // Reserve space for admin badge icon if sender is group owner + let adminIconSpace: CGFloat = layout.isGroupAdmin ? 20 : 0 // Position inside bubble: top-left, with standard bubble padding senderNameLabel.frame = CGRect( x: 10, y: 6, - width: min(senderNameLabel.bounds.width, bubbleView.bounds.width - 24), + width: min(senderNameLabel.bounds.width, bubbleView.bounds.width - 24 - adminIconSpace), height: 16 ) + // Desktop parity: gold admin badge next to sender name + if layout.isGroupAdmin { + senderAdminIconView.isHidden = false + senderAdminIconView.image = Self.goldAdminBadgeImage + senderAdminIconView.frame = CGRect( + x: senderNameLabel.frame.maxX + 3, + y: 5, + width: 16, + height: 16 + ) + } else { + senderAdminIconView.isHidden = true + } // Expand bubble to fit the name bubbleView.frame.size.height += senderNameShift // Shift text and other content down to make room for the name @@ -1278,6 +1382,7 @@ final class NativeMessageCell: UICollectionViewCell { bubbleOutlineLayer.path = bubbleLayer.path } else { senderNameLabel.isHidden = true + senderAdminIconView.isHidden = true } // Group sender avatar (left of bubble, last in run, Telegram parity) @@ -1286,7 +1391,7 @@ final class NativeMessageCell: UICollectionViewCell { let avatarSize: CGFloat = 36 senderAvatarContainer.isHidden = false senderAvatarContainer.frame = CGRect( - x: 4, + x: 4 + selectionOffset, y: bubbleView.frame.maxY - avatarSize, width: avatarSize, height: avatarSize @@ -1296,8 +1401,20 @@ final class NativeMessageCell: UICollectionViewCell { senderAvatarImageView.layer.cornerRadius = avatarSize / 2 let colorIdx = RosettaColors.avatarColorIndex(for: layout.senderName, publicKey: layout.senderKey) - senderAvatarContainer.backgroundColor = RosettaColors.avatarColor(for: colorIdx) + // Mantine "light" variant: base + tint overlay (matches AvatarView SwiftUI rendering). + // Dark: #1A1B1E base + tint at 15%. Light: white base + tint at 10%. + let isDark = traitCollection.userInterfaceStyle == .dark + senderAvatarContainer.backgroundColor = isDark + ? UIColor(red: 0x1A/255, green: 0x1B/255, blue: 0x1E/255, alpha: 1) + : .white + let tintAlpha: CGFloat = isDark ? 0.15 : 0.10 + let tintColor = RosettaColors.avatarColor(for: colorIdx).withAlphaComponent(tintAlpha) + senderAvatarInitialLabel.backgroundColor = tintColor senderAvatarInitialLabel.text = RosettaColors.initials(name: layout.senderName, publicKey: layout.senderKey) + // Dark: shade-3 text. Light: shade-6 (tint) text. + senderAvatarInitialLabel.textColor = isDark + ? RosettaColors.avatarTextColor(for: colorIdx) + : RosettaColors.avatarColor(for: colorIdx) if let image = AvatarRepository.shared.loadAvatar(publicKey: layout.senderKey) { senderAvatarImageView.image = image senderAvatarImageView.isHidden = false @@ -1308,6 +1425,14 @@ final class NativeMessageCell: UICollectionViewCell { } else { senderAvatarContainer.isHidden = true } + + // Reply icon (for swipe gesture) — positioned AFTER all bubble size adjustments + // (sender name shift, etc.) so it's vertically centered on the final bubble. + let replyIconDiameter: CGFloat = 34 + let replyIconX = bubbleView.frame.maxX - replyIconDiameter + let replyIconY = bubbleView.frame.midY - replyIconDiameter / 2 + replyCircleView.frame = CGRect(x: replyIconX, y: replyIconY, width: replyIconDiameter, height: replyIconDiameter) + replyIconView.frame = CGRect(x: replyIconX + 7, y: replyIconY + 7, width: 20, height: 20) } private static func formattedDuration(seconds: Int) -> String { @@ -1447,6 +1572,12 @@ final class NativeMessageCell: UICollectionViewCell { // MARK: - Link Tap @objc private func handleLinkTap(_ gesture: UITapGestureRecognizer) { + // In selection mode: any tap toggles selection + if isInSelectionMode { + guard let msgId = message?.id else { return } + actions?.onToggleSelection(msgId) + return + } let pointInText = gesture.location(in: textLabel) guard let url = textLabel.textLayout?.linkAt(point: pointInText) else { return } var finalURL = url @@ -1462,6 +1593,13 @@ final class NativeMessageCell: UICollectionViewCell { @objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) { guard gesture.state == .began else { return } + // In selection mode: tap toggles selection instead of context menu + if isInSelectionMode { + guard let msgId = message?.id else { return } + contextMenuHaptic.impactOccurred() + actions?.onToggleSelection(msgId) + return + } contextMenuHaptic.impactOccurred() presentContextMenu() } @@ -1500,6 +1638,7 @@ final class NativeMessageCell: UICollectionViewCell { // MARK: - Swipe to Reply @objc private func handleSwipe(_ gesture: UIPanGestureRecognizer) { + if isInSelectionMode { return } // Disable swipe-to-reply in selection mode if isSavedMessages || isSystemAccount { return } let isReplyBlocked = (message?.attachments.contains(where: { $0.type == .avatar }) ?? false) || (currentLayout?.messageType == .groupInvite) @@ -1536,6 +1675,10 @@ final class NativeMessageCell: UICollectionViewCell { } bubbleView.transform = CGAffineTransform(translationX: clamped, y: 0) + // Move sender avatar with bubble during swipe (group chats) + if !senderAvatarContainer.isHidden { + senderAvatarContainer.transform = CGAffineTransform(translationX: clamped, y: 0) + } // Icon progress: fade in from 4pt to threshold let absClamped = abs(clamped) @@ -1569,6 +1712,7 @@ final class NativeMessageCell: UICollectionViewCell { let animator = UIViewPropertyAnimator(duration: 0, timingParameters: timing) animator.addAnimations { self.bubbleView.transform = .identity + self.senderAvatarContainer.transform = .identity self.replyCircleView.alpha = 0 self.replyCircleView.transform = .identity self.replyIconView.alpha = 0 @@ -1765,6 +1909,11 @@ final class NativeMessageCell: UICollectionViewCell { } @objc private func fileContainerTapped() { + if isInSelectionMode { + guard let msgId = message?.id else { return } + actions?.onToggleSelection(msgId) + return + } guard let message, let actions else { return } let isCallType = message.attachments.contains { $0.type == .call } if isCallType { @@ -1782,6 +1931,12 @@ final class NativeMessageCell: UICollectionViewCell { } @objc private func handlePhotoTileTap(_ sender: UIButton) { + // In selection mode: any tap toggles selection + if isInSelectionMode { + guard let msgId = message?.id else { return } + actions?.onToggleSelection(msgId) + return + } guard sender.tag >= 0, sender.tag < photoAttachments.count, let message, let actions else { @@ -1793,7 +1948,7 @@ final class NativeMessageCell: UICollectionViewCell { let sourceFrame = imageView.convert(imageView.bounds, to: nil) if AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) != nil { - actions.onImageTap(attachment.id, sourceFrame) + actions.onImageTap(attachment.id, sourceFrame, imageView) return } @@ -1810,7 +1965,7 @@ final class NativeMessageCell: UICollectionViewCell { return } if loaded != nil { - actions.onImageTap(attachment.id, sourceFrame) + actions.onImageTap(attachment.id, sourceFrame, imageView) } else { self.downloadPhotoAttachment(attachment: attachment, message: message) } @@ -2534,10 +2689,13 @@ final class NativeMessageCell: UICollectionViewCell { forwardAvatarView.isHidden = true forwardNameLabel.isHidden = true senderNameLabel.isHidden = true + senderAdminIconView.isHidden = true senderAvatarContainer.isHidden = true + senderAvatarInitialLabel.backgroundColor = .clear senderAvatarImageView.image = nil photoContainer.isHidden = true bubbleView.transform = .identity + senderAvatarContainer.transform = .identity replyCircleView.alpha = 0 replyCircleView.transform = .identity replyIconView.alpha = 0 @@ -2547,6 +2705,93 @@ final class NativeMessageCell: UICollectionViewCell { deliveryFailedButton.isHidden = true deliveryFailedButton.alpha = 0 isDeliveryFailedVisible = false + // Selection: reset selected state on reuse, keep mode (same for all cells) + isMessageSelected = false + selectionCheckFill.isHidden = true + selectionCheckmarkView.isHidden = true + } + + // MARK: - Multi-Select + + func setSelectionMode(_ enabled: Bool, animated: Bool) { + guard isInSelectionMode != enabled else { return } + isInSelectionMode = enabled + let newOffset: CGFloat = enabled ? 42 : 0 + let duration: TimeInterval = enabled ? 0.3 : 0.4 + let damping: CGFloat = enabled ? 0.8 : 0.85 + + if animated { + selectionCheckContainer.isHidden = false + let fromAlpha: Float = enabled ? 0 : 1 + let toAlpha: Float = enabled ? 1 : 0 + let slideFrom = enabled ? -42.0 : 0.0 + let slideTo = enabled ? 0.0 : -42.0 + + // Checkbox fade + slide + let alphaAnim = CABasicAnimation(keyPath: "opacity") + alphaAnim.fromValue = fromAlpha + alphaAnim.toValue = toAlpha + alphaAnim.duration = duration + alphaAnim.timingFunction = CAMediaTimingFunction(name: .easeOut) + alphaAnim.fillMode = .forwards + alphaAnim.isRemovedOnCompletion = false + selectionCheckContainer.layer.add(alphaAnim, forKey: "selectionAlpha") + + let posAnim = CABasicAnimation(keyPath: "position.x") + posAnim.fromValue = selectionCheckContainer.layer.position.x + slideFrom + posAnim.toValue = selectionCheckContainer.layer.position.x + slideTo + posAnim.duration = duration + posAnim.timingFunction = CAMediaTimingFunction(name: .easeOut) + selectionCheckContainer.layer.add(posAnim, forKey: "selectionSlide") + + selectionCheckContainer.layer.opacity = toAlpha + + // Content shift (spring animation, Telegram parity) + selectionOffset = newOffset + UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: damping, initialSpringVelocity: 0, options: []) { + self.setNeedsLayout() + self.layoutIfNeeded() + } completion: { _ in + if !enabled { + self.selectionCheckContainer.isHidden = true + self.selectionCheckContainer.layer.removeAnimation(forKey: "selectionAlpha") + self.selectionCheckContainer.layer.removeAnimation(forKey: "selectionSlide") + self.selectionCheckContainer.layer.opacity = 1 + } + } + } else { + selectionOffset = newOffset + selectionCheckContainer.isHidden = !enabled + selectionCheckContainer.layer.opacity = enabled ? 1 : 0 + setNeedsLayout() + } + } + + func setMessageSelected(_ selected: Bool, animated: Bool) { + guard isMessageSelected != selected else { return } + isMessageSelected = selected + selectionCheckFill.isHidden = !selected + selectionCheckmarkView.isHidden = !selected + selectionCheckBorder.isHidden = selected + + if animated && selected { + // Telegram CheckNode: 3-stage scale 1→0.9→1.1→1 over 0.21s + let anim = CAKeyframeAnimation(keyPath: "transform.scale") + anim.values = [1.0, 0.9, 1.1, 1.0] + anim.keyTimes = [0, 0.26, 0.62, 1.0] + anim.duration = 0.21 + anim.timingFunction = CAMediaTimingFunction(name: .easeOut) + selectionCheckFill.add(anim, forKey: "checkBounce") + selectionCheckmarkView.layer.add(anim, forKey: "checkBounce") + } else if animated && !selected { + // Telegram CheckNode: 2-stage scale 1→0.9→1 over 0.15s + let anim = CAKeyframeAnimation(keyPath: "transform.scale") + anim.values = [1.0, 0.9, 1.0] + anim.keyTimes = [0, 0.53, 1.0] + anim.duration = 0.15 + anim.timingFunction = CAMediaTimingFunction(name: .easeIn) + selectionCheckContainer.layer.add(anim, forKey: "checkBounce") + } } } @@ -2555,6 +2800,7 @@ final class NativeMessageCell: UICollectionViewCell { extension NativeMessageCell: UIGestureRecognizerDelegate { override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true } + if isInSelectionMode { return false } // No swipe in selection mode let velocity = pan.velocity(in: contentView) // Telegram: only left swipe (negative velocity.x), clear horizontal dominance return velocity.x < 0 && abs(velocity.x) > abs(velocity.y) * 2.0 diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift index 9337297..9c12a3d 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift @@ -31,6 +31,7 @@ final class NativeMessageListController: UIViewController { var isSavedMessages: Bool var isSystemAccount: Bool var isGroupChat: Bool = false + var groupAdminKey: String = "" var opponentPublicKey: String var opponentTitle: String var opponentUsername: String @@ -131,6 +132,11 @@ final class NativeMessageListController: UIViewController { private var emptyStateHosting: UIHostingController? private var emptyStateGuide: UILayoutGuide? + // MARK: - Multi-Select + + private(set) var isSelectionMode = false + private(set) var selectedMessageIds: Set = [] + // MARK: - Layout Cache (Telegram asyncLayout pattern) /// Cache: messageId → pre-calculated layout from background thread. @@ -184,6 +190,25 @@ final class NativeMessageListController: UIViewController { self, selector: #selector(handleAvatarDidUpdate), name: Notification.Name("avatarDidUpdate"), object: nil ) + // Resolve group admin key: try cached first (instant, no flash), then async fallback. + if config.isGroupChat && config.groupAdminKey.isEmpty { + let account = SessionManager.shared.currentPublicKey + if let cached = GroupRepository.shared.cachedMembers(account: account, groupDialogKey: config.opponentPublicKey) { + config.groupAdminKey = cached.adminKey + } else { + Task { @MainActor in + let members = try? await GroupService.shared.requestMembers( + groupDialogKey: self.config.opponentPublicKey + ) + if let adminKey = members?.first, !adminKey.isEmpty { + self.config.groupAdminKey = adminKey + self.calculateLayouts() + self.collectionView.reloadData() + } + } + } + } + // Regenerate bubble images + full cell refresh on theme switch. registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (self: NativeMessageListController, previousTraitCollection: UITraitCollection) in let oldStyle = previousTraitCollection.userInterfaceStyle @@ -362,6 +387,12 @@ final class NativeMessageListController: UIViewController { forwardSenderName: forwardSenderName, forwardSenderKey: forwardSenderKey ) + + // Multi-select: apply selection state on cell (re)configuration + cell.setSelectionMode(self.isSelectionMode, animated: false) + if self.isSelectionMode { + cell.setMessageSelected(self.selectedMessageIds.contains(msg.id), animated: false) + } } } @@ -875,6 +906,43 @@ final class NativeMessageListController: UIViewController { emptyStateGuide = guide } + // MARK: - Multi-Select Methods + + func setSelectionMode(_ enabled: Bool, animated: Bool) { + guard isSelectionMode != enabled else { return } + isSelectionMode = enabled + if !enabled { selectedMessageIds.removeAll() } + + // Update all visible cells + for cell in collectionView.visibleCells { + guard let msgCell = cell as? NativeMessageCell else { continue } + msgCell.setSelectionMode(enabled, animated: animated) + if enabled, let indexPath = collectionView.indexPath(for: msgCell), + let msgId = dataSource.itemIdentifier(for: indexPath) { + msgCell.setMessageSelected(selectedMessageIds.contains(msgId), animated: false) + } + } + + // Hide/show composer in selection mode + composerView?.isHidden = enabled + } + + func updateSelectedIds(_ ids: Set) { + let oldIds = selectedMessageIds + selectedMessageIds = ids + + for cell in collectionView.visibleCells { + guard let msgCell = cell as? NativeMessageCell, + let indexPath = collectionView.indexPath(for: msgCell), + let msgId = dataSource.itemIdentifier(for: indexPath) else { continue } + let wasSelected = oldIds.contains(msgId) + let isSelected = ids.contains(msgId) + if wasSelected != isSelected { + msgCell.setMessageSelected(isSelected, animated: true) + } + } + } + // MARK: - Update /// Called from SwiftUI when messages array changes. @@ -1038,6 +1106,7 @@ final class NativeMessageListController: UIViewController { opponentPublicKey: config.opponentPublicKey, opponentTitle: config.opponentTitle, isGroupChat: config.isGroupChat, + groupAdminKey: config.groupAdminKey, isDarkMode: isDark ) layoutCache = layouts @@ -1431,6 +1500,10 @@ struct NativeMessageListView: UIViewControllerRepresentable { var onComposerHeightChange: ((CGFloat) -> Void)? var onKeyboardDidHide: (() -> Void)? + // Multi-select state + var isMultiSelectMode: Bool = false + var selectedMessageIds: Set = [] + // Composer state (iOS < 26, forwarded to ComposerView) @Binding var messageText: String @Binding var isInputFocused: Bool @@ -1500,6 +1573,14 @@ struct NativeMessageListView: UIViewControllerRepresentable { wireCallbacks(controller, context: context) + // Multi-select state sync + if controller.isSelectionMode != isMultiSelectMode { + controller.setSelectionMode(isMultiSelectMode, animated: true) + } + if controller.selectedMessageIds != selectedMessageIds { + controller.updateSelectedIds(selectedMessageIds) + } + // Sync composer state (iOS < 26) if useUIKitComposer { syncComposerState(controller) diff --git a/Rosetta/Features/Chats/ChatDetail/OpponentProfileView.swift b/Rosetta/Features/Chats/ChatDetail/OpponentProfileView.swift index f2f8091..e0afc0a 100644 --- a/Rosetta/Features/Chats/ChatDetail/OpponentProfileView.swift +++ b/Rosetta/Features/Chats/ChatDetail/OpponentProfileView.swift @@ -1,15 +1,31 @@ import SwiftUI -/// Profile screen for viewing opponent (other user) information. -/// Pushed from ChatDetailView when tapping the toolbar capsule or avatar. -/// -/// Desktop parity: ProfileCard (avatar + name + subtitle) → -/// Username section (copyable) → Public Key section (copyable). +/// Telegram-parity peer profile screen with expandable header, shared media tabs. struct OpponentProfileView: View { let route: ChatRoute + @StateObject private var viewModel: PeerProfileViewModel @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme @State private var copiedField: String? + @State private var isLargeHeader = false + @State private var topInset: CGFloat = 0 + @State private var isMuted = false + @State private var showMoreSheet = false + @State private var selectedTab: PeerProfileTab = .media + @Namespace private var tabNamespace + + enum PeerProfileTab: String, CaseIterable { + case media = "Media" + case files = "Files" + case links = "Links" + case groups = "Groups" + } + + init(route: ChatRoute) { + self.route = route + _viewModel = StateObject(wrappedValue: PeerProfileViewModel(dialogKey: route.publicKey)) + } // MARK: - Computed properties @@ -36,198 +52,421 @@ struct OpponentProfileView: View { return 0 } - private var avatarInitials: String { - RosettaColors.initials(name: displayName, publicKey: route.publicKey) - } - - private var avatarColorIndex: Int { - RosettaColors.avatarColorIndex(for: displayName, publicKey: route.publicKey) - } - private var opponentAvatar: UIImage? { AvatarRepository.shared.loadAvatar(publicKey: route.publicKey) } - /// Desktop parity: @username • shortKey + /// Only real photos can expand to full-width; letter avatars stay as circles. + private var canExpand: Bool { opponentAvatar != nil } + + /// Telegram parity: show online status, not username/key private var subtitleText: String { - let shortKey = route.publicKey.prefix(4) + "..." + route.publicKey.suffix(4) - if !username.isEmpty { - return "@\(username) · \(shortKey)" - } - return String(shortKey) + viewModel.isOnline ? "online" : "offline" } // MARK: - Body var body: some View { - ScrollView { - VStack(spacing: 0) { - profileCard - .padding(.top, 32) - - infoSections - .padding(.top, 32) - .padding(.horizontal, 16) + scrollContent + .scrollIndicators(.hidden) + .modifier(ProfileScrollTracker(isLargeHeader: $isLargeHeader, topInset: $topInset, canExpand: canExpand)) + .background(RosettaColors.Adaptive.background.ignoresSafeArea()) + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + .enableSwipeBack() + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { dismiss() } label: { backButtonLabel } + .buttonStyle(.plain) + } } - } - .scrollIndicators(.hidden) - .background(RosettaColors.Adaptive.background.ignoresSafeArea()) - .navigationBarTitleDisplayMode(.inline) - .navigationBarBackButtonHidden(true) - .enableSwipeBack() - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button { dismiss() } label: { backButtonLabel } - .buttonStyle(.plain) + .toolbarBackground(.hidden, for: .navigationBar) + .task { + isMuted = dialog?.isMuted ?? false + // Only expand when user has a REAL photo (not letter avatar) + if opponentAvatar != nil { + isLargeHeader = true + } + viewModel.startObservingOnline() + viewModel.loadSharedContent() + viewModel.loadCommonGroups() + } + .confirmationDialog("", isPresented: $showMoreSheet, titleVisibility: .hidden) { + Button("Block User", role: .destructive) {} + Button("Clear Chat History", role: .destructive) {} } - } - .toolbarBackground(.hidden, for: .navigationBar) } - // MARK: - Back Button + private var scrollContent: some View { + ScrollView(.vertical) { + LazyVStack(spacing: 0) { + infoSections + .padding(.top, 16) + .padding(.horizontal, 16) + + sharedMediaTabBar + .padding(.top, 20) + + sharedMediaContent + .padding(.top, 12) + + Spacer(minLength: 40) + } + .safeAreaInset(edge: .top, spacing: 0) { + PeerProfileHeaderView( + isLargeHeader: $isLargeHeader, + topInset: $topInset, + displayName: displayName, + subtitleText: subtitleText, + effectiveVerified: effectiveVerified, + avatarImage: opponentAvatar, + avatarInitials: RosettaColors.initials(name: displayName, publicKey: route.publicKey), + avatarColorIndex: RosettaColors.avatarColorIndex(for: displayName, publicKey: route.publicKey), + isMuted: isMuted, + onCall: handleCall, + onMuteToggle: handleMuteToggle, + onSearch: { dismiss() }, + onMore: { showMoreSheet = true } + ) + } + } + } + + // MARK: - Back Button (always white chevron — glass capsule provides dark tint for contrast) private var backButtonLabel: some View { TelegramVectorIcon( pathData: TelegramIconPath.backChevron, viewBox: CGSize(width: 11, height: 20), - color: .white + color: isLargeHeader ? .white : RosettaColors.Adaptive.text ) .frame(width: 11, height: 20) - .allowsHitTesting(false) .frame(width: 36, height: 36) .frame(height: 44) .padding(.horizontal, 4) - .background { glassCapsule() } - } - - // MARK: - Profile Card (Desktop: ProfileCard component) - - private var profileCard: some View { - VStack(spacing: 0) { - AvatarView( - initials: avatarInitials, - colorIndex: avatarColorIndex, - size: 100, - isOnline: false, - image: opponentAvatar - ) - - HStack(spacing: 5) { - Text(displayName) - .font(.system(size: 22, weight: .semibold)) - .foregroundStyle(RosettaColors.Adaptive.text) - .lineLimit(2) - .multilineTextAlignment(.center) - - if effectiveVerified > 0 { - VerifiedBadge(verified: effectiveVerified, size: 18, badgeTint: .white) - } + .contentShape(Rectangle()) + .background { + if isLargeHeader { + TelegramGlassCapsule() + } else { + Capsule() + .fill(colorScheme == .dark + ? Color.white.opacity(0.12) + : Color.black.opacity(0.06)) + .overlay( + Capsule() + .strokeBorder(colorScheme == .dark + ? Color.white.opacity(0.08) + : Color.black.opacity(0.08), lineWidth: 0.5) + ) } - .padding(.top, 12) - .padding(.horizontal, 32) - - Text(subtitleText) - .font(.system(size: 14)) - .foregroundStyle(RosettaColors.Adaptive.textSecondary) - .padding(.top, 4) } } - // MARK: - Info Sections (Desktop: SettingsInput.Copy rows) + // MARK: - Info Sections private var infoSections: some View { - VStack(spacing: 16) { - if !username.isEmpty { - copyRow( - label: "Username", - value: "@\(username)", - rawValue: username, - fieldId: "username", - helper: "Username for search user or send message." - ) + TelegramSectionCard { + VStack(spacing: 0) { + if !username.isEmpty { + infoRow(label: "username", value: "@\(username)", rawValue: username, fieldId: "username") + telegramDivider + } + infoRow(label: "public key", value: route.publicKey, rawValue: route.publicKey, fieldId: "publicKey") } - - copyRow( - label: "Public Key", - value: route.publicKey, - rawValue: route.publicKey, - fieldId: "publicKey", - helper: "This is user public key. If user haven't set a @username yet, you can send message using public key." - ) } } - // MARK: - Copy Row (Desktop: SettingsInput.Copy) + private var telegramDivider: some View { + Rectangle() + .fill(telegramSeparatorColor) + .frame(height: 1 / UIScreen.main.scale) + .padding(.leading, 16) + } - private func copyRow( - label: String, - value: String, - rawValue: String, - fieldId: String, - helper: String - ) -> some View { - VStack(alignment: .leading, spacing: 6) { - Button { - UIPasteboard.general.string = rawValue - withAnimation(.easeInOut(duration: 0.2)) { copiedField = fieldId } - Task { @MainActor in - try? await Task.sleep(for: .seconds(1.5)) - withAnimation(.easeInOut(duration: 0.2)) { - if copiedField == fieldId { copiedField = nil } + private func infoRow(label: String, value: String, rawValue: String, fieldId: String) -> some View { + Button { + UIPasteboard.general.string = rawValue + withAnimation(.easeInOut(duration: 0.2)) { copiedField = fieldId } + Task { @MainActor in + try? await Task.sleep(for: .seconds(1.5)) + withAnimation(.easeInOut(duration: 0.2)) { + if copiedField == fieldId { copiedField = nil } + } + } + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.system(size: 14)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + Text(copiedField == fieldId ? "Copied" : value) + .font(.system(size: 17)) + .foregroundStyle(copiedField == fieldId ? RosettaColors.online : RosettaColors.primaryBlue) + .lineLimit(1) + .truncationMode(.middle) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + .buttonStyle(.plain) + } + + // MARK: - Shared Media Tab Bar (Telegram parity) + + private var tabActiveColor: Color { colorScheme == .dark ? .white : .black } + private var tabInactiveColor: Color { colorScheme == .dark ? Color.white.opacity(0.6) : Color.black.opacity(0.4) } + private var tabIndicatorFill: Color { colorScheme == .dark ? Color.white.opacity(0.18) : Color.black.opacity(0.08) } + + private var sharedMediaTabBar: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 0) { + ForEach(PeerProfileTab.allCases, id: \.self) { tab in + Button { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + selectedTab = tab + } + } label: { + Text(tab.rawValue) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(selectedTab == tab ? tabActiveColor : tabInactiveColor) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background { + if selectedTab == tab { + Capsule() + .fill(tabIndicatorFill) + .matchedGeometryEffect(id: "peer_tab", in: tabNamespace) + } + } + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 3) + .padding(.vertical, 3) + } + .background(Capsule().fill(telegramSectionFill)) + .padding(.horizontal, 16) + } + + // MARK: - Shared Media Content + + @ViewBuilder + private var sharedMediaContent: some View { + switch selectedTab { + case .media: + if viewModel.mediaItems.isEmpty { + emptyState(icon: "photo.on.rectangle", title: "No Media Yet") + } else { + peerMediaGrid + } + case .files: + if viewModel.fileItems.isEmpty { + emptyState(icon: "doc", title: "No Files Yet") + } else { + peerFilesList + } + case .links: + if viewModel.linkItems.isEmpty { + emptyState(icon: "link", title: "No Links Yet") + } else { + peerLinksList + } + case .groups: + if viewModel.commonGroups.isEmpty { + emptyState(icon: "person.2", title: "No Groups in Common") + } else { + commonGroupsList + } + } + } + + private func emptyState(icon: String, title: String) -> some View { + VStack(spacing: 12) { + Image(systemName: icon) + .font(.system(size: 40)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.5)) + Text(title) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 40) + } + + private var peerMediaGrid: some View { + let columns = Array(repeating: GridItem(.flexible(), spacing: 1), count: 3) + return LazyVGrid(columns: columns, spacing: 1) { + ForEach(viewModel.mediaItems) { item in + PeerMediaTile(item: item, allItems: viewModel.mediaItems) + } + } + } + + private var peerFilesList: some View { + TelegramSectionCard { + VStack(spacing: 0) { + ForEach(Array(viewModel.fileItems.enumerated()), id: \.element.id) { index, file in + HStack(spacing: 12) { + Image(systemName: "doc.fill") + .font(.system(size: 22)) + .foregroundStyle(RosettaColors.primaryBlue) + .frame(width: 40, height: 40) + .background(RoundedRectangle(cornerRadius: 10).fill(RosettaColors.primaryBlue.opacity(0.12))) + VStack(alignment: .leading, spacing: 2) { + Text(file.fileName).font(.system(size: 16)).foregroundStyle(RosettaColors.Adaptive.text).lineLimit(1) + Text(file.subtitle).font(.system(size: 13)).foregroundStyle(RosettaColors.Adaptive.textSecondary) + } + Spacer() + } + .padding(.horizontal, 16).padding(.vertical, 10) + + if index < viewModel.fileItems.count - 1 { + Rectangle().fill(telegramSeparatorColor).frame(height: 1 / UIScreen.main.scale).padding(.leading, 68) } } - } label: { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(label) - .font(.system(size: 13)) - .foregroundStyle(RosettaColors.Adaptive.textSecondary) + } + } + .padding(.horizontal, 16) + } - Text(copiedField == fieldId ? "Copied" : value) - .font(.system(size: 16)) - .foregroundStyle( - copiedField == fieldId - ? RosettaColors.online - : RosettaColors.Adaptive.text - ) + private var peerLinksList: some View { + TelegramSectionCard { + VStack(spacing: 0) { + ForEach(Array(viewModel.linkItems.enumerated()), id: \.element.id) { index, link in + Button { if let url = URL(string: link.url) { UIApplication.shared.open(url) } } label: { + HStack(spacing: 12) { + Text(String(link.displayHost.prefix(1)).uppercased()) + .font(.system(size: 18, weight: .bold)).foregroundStyle(.white) + .frame(width: 40, height: 40) + .background(RoundedRectangle(cornerRadius: 10).fill(RosettaColors.primaryBlue)) + VStack(alignment: .leading, spacing: 2) { + Text(link.displayHost).font(.system(size: 16, weight: .medium)).foregroundStyle(RosettaColors.Adaptive.text).lineLimit(1) + Text(link.context).font(.system(size: 13)).foregroundStyle(RosettaColors.Adaptive.textSecondary).lineLimit(2) + } + Spacer() + } + .padding(.horizontal, 16).padding(.vertical, 10) + }.buttonStyle(.plain) + + if index < viewModel.linkItems.count - 1 { + Rectangle().fill(telegramSeparatorColor).frame(height: 1 / UIScreen.main.scale).padding(.leading, 68) + } + } + } + } + .padding(.horizontal, 16) + } + + private var commonGroupsList: some View { + TelegramSectionCard { + VStack(spacing: 0) { + ForEach(Array(viewModel.commonGroups.enumerated()), id: \.element.id) { index, group in + HStack(spacing: 12) { + let initials = RosettaColors.initials(name: group.title, publicKey: group.dialogKey) + let colorIdx = RosettaColors.avatarColorIndex(for: group.title, publicKey: group.dialogKey) + AvatarView(initials: initials, colorIndex: colorIdx, size: 40, isOnline: false, image: group.avatar) + + Text(group.title) + .font(.system(size: 17)) + .foregroundStyle(RosettaColors.Adaptive.text) .lineLimit(1) - .truncationMode(.middle) + Spacer() } + .padding(.horizontal, 16).padding(.vertical, 8) - Spacer() - - Image(systemName: copiedField == fieldId ? "checkmark" : "doc.on.doc") - .font(.system(size: 13)) - .foregroundStyle( - copiedField == fieldId - ? RosettaColors.online - : RosettaColors.Adaptive.textSecondary - ) + if index < viewModel.commonGroups.count - 1 { + Rectangle().fill(telegramSeparatorColor).frame(height: 1 / UIScreen.main.scale).padding(.leading, 68) + } } - .padding(.horizontal, 16) - .padding(.vertical, 14) - .background { glassCard() } } - .buttonStyle(.plain) + } + .padding(.horizontal, 16) + } - Text(helper) - .font(.system(size: 12)) - .foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.7)) - .padding(.horizontal, 8) + // MARK: - Actions + + private func handleCall() { + let name = displayName + let user = username + Task { @MainActor in + _ = CallManager.shared.startOutgoingCall(toPublicKey: route.publicKey, title: name, username: user) } } - // MARK: - Glass helpers - - // Use TelegramGlass* UIViewRepresentable for ALL iOS versions. - // SwiftUI .glassEffect() creates UIKit containers that intercept taps - // even with .allowsHitTesting(false) — breaks back button. - - private func glassCapsule() -> some View { - TelegramGlassCapsule() - } - - private func glassCard() -> some View { - TelegramGlassRoundedRect(cornerRadius: 14) + private func handleMuteToggle() { + DialogRepository.shared.toggleMute(opponentKey: route.publicKey) + isMuted.toggle() + } +} + +// MARK: - Media Tile + +private struct PeerMediaTile: View { + let item: SharedMediaItem + let allItems: [SharedMediaItem] + @State private var image: UIImage? + @State private var blurImage: UIImage? + + var body: some View { + GeometryReader { proxy in + ZStack { + if let image { Image(uiImage: image).resizable().scaledToFill().frame(width: proxy.size.width, height: proxy.size.width).clipped() } + else if let blurImage { Image(uiImage: blurImage).resizable().scaledToFill().frame(width: proxy.size.width, height: proxy.size.width).clipped() } + else { Color(white: 0.15) } + } + .contentShape(Rectangle()) + .onTapGesture { openGallery() } + } + .aspectRatio(1, contentMode: .fit) + .task { loadImages() } + } + + private func loadImages() { + if let cached = AttachmentCache.shared.loadImage(forAttachmentId: item.attachmentId) { image = cached } + else if !item.blurhash.isEmpty { blurImage = BlurHashDecoder.decode(blurHash: item.blurhash, width: 32, height: 32) } + } + + private func openGallery() { + let viewable = allItems.map { ViewableImageInfo(attachmentId: $0.attachmentId, messageId: $0.messageId, senderName: $0.senderName, timestamp: Date(timeIntervalSince1970: Double($0.timestamp) / 1000.0), caption: $0.caption) } + let index = allItems.firstIndex(where: { $0.attachmentId == item.attachmentId }) ?? 0 + ImageViewerPresenter.shared.present(state: ImageViewerState(images: viewable, initialIndex: index, sourceFrame: .zero)) + } +} + +// MARK: - Scroll Tracking + +private struct ProfileScrollTracker: ViewModifier { + @Binding var isLargeHeader: Bool + @Binding var topInset: CGFloat + let canExpand: Bool + + func body(content: Content) -> some View { + if #available(iOS 18, *) { + IOS18ScrollTracker(isLargeHeader: $isLargeHeader, topInset: $topInset, canExpand: canExpand) { content } + } else { content } + } +} + +@available(iOS 18, *) +private struct IOS18ScrollTracker: View { + @Binding var isLargeHeader: Bool + @Binding var topInset: CGFloat + let canExpand: Bool + @State private var scrollPhase: ScrollPhase = .idle + let content: () -> Content + + var body: some View { + content() + .onScrollGeometryChange(for: CGFloat.self) { $0.contentInsets.top } action: { _, v in topInset = v } + .onScrollGeometryChange(for: CGFloat.self) { $0.contentOffset.y + $0.contentInsets.top } action: { _, v in + if scrollPhase == .interacting { + withAnimation(.snappy(duration: 0.2, extraBounce: 0)) { + isLargeHeader = canExpand && (v < -10 || (isLargeHeader && v < 0)) + } + } + } + .onScrollPhaseChange { _, p in scrollPhase = p } } } diff --git a/Rosetta/Features/Chats/ChatDetail/PeerProfileHeaderView.swift b/Rosetta/Features/Chats/ChatDetail/PeerProfileHeaderView.swift new file mode 100644 index 0000000..2aa1447 --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/PeerProfileHeaderView.swift @@ -0,0 +1,213 @@ +import SwiftUI + +/// Telegram-parity expandable profile header. +/// Collapsed: 100pt circular avatar, centered name, action buttons. +/// Expanded (pull-down): full-width rectangular photo, left-aligned name, glass buttons. +struct PeerProfileHeaderView: View { + @Binding var isLargeHeader: Bool + @Binding var topInset: CGFloat + let displayName: String + let subtitleText: String + let effectiveVerified: Int + let avatarImage: UIImage? + let avatarInitials: String + let avatarColorIndex: Int + let isMuted: Bool + var showCallButton: Bool = true + let onCall: () -> Void + let onMuteToggle: () -> Void + let onSearch: () -> Void + let onMore: () -> Void + + @Environment(\.colorScheme) private var colorScheme + + // MARK: - Body + + var body: some View { + VStack(spacing: 12) { + // Invisible placeholder — drives geometry for the resizable avatar behind it + Rectangle() + .foregroundStyle(.clear) + .frame(width: 100, height: isLargeHeader ? 300 : 100) + .clipShape(.circle) + + VStack(spacing: 20) { + navigationBarContent + .foregroundStyle(isLargeHeader ? .white : RosettaColors.Adaptive.text) + + actionButtons + .foregroundStyle(isLargeHeader ? .white : RosettaColors.primaryBlue) + .geometryGroup() + } + } + .padding(.horizontal, 15) + .padding(.bottom, 15) + .background(alignment: .top) { avatarBackground } + .padding(.top, 15) + } + + // MARK: - Avatar Background (GeometryReader parallax) + + private var avatarBackground: some View { + GeometryReader { geo in + let size = geo.size + let minY = geo.frame(in: .global).minY + let topOffset = isLargeHeader ? minY : 0 + + avatarContent + .frame(width: size.width, height: size.height + topOffset) + .clipped() + .clipShape(.rect(cornerRadius: isLargeHeader ? 0 : 50)) + .offset(y: -topOffset) + } + .frame( + width: isLargeHeader ? nil : 100, + height: isLargeHeader ? nil : 100 + ) + } + + @ViewBuilder + private var avatarContent: some View { + if let image = avatarImage { + Image(uiImage: image) + .resizable() + .scaledToFill() + } else { + let pair = RosettaColors.avatarColors[avatarColorIndex % RosettaColors.avatarColors.count] + ZStack { + Rectangle().fill(pair.tint) + Text(avatarInitials) + .font(.system(size: isLargeHeader ? 120 : 36, weight: .medium)) + .foregroundStyle(pair.text) + } + } + } + + // MARK: - Navigation Bar Content (sticky + scale) + + private var navigationBarContent: some View { + VStack(alignment: isLargeHeader ? .leading : .center, spacing: 4) { + HStack(spacing: 5) { + Text(displayName) + .font(.title2) + .fontWeight(.semibold) + .lineLimit(2) + + if effectiveVerified > 0 { + VerifiedBadge( + verified: effectiveVerified, + size: 18, + badgeTint: isLargeHeader ? .white : nil + ) + } + } + + Text(subtitleText) + .font(.callout) + .foregroundStyle(isLargeHeader ? .white.opacity(0.7) : .secondary) + } + .frame(maxWidth: .infinity, alignment: isLargeHeader ? .leading : .center) + .visualEffect { content, proxy in + let minY = proxy.frame(in: .scrollView(axis: .vertical)).minY + let progress = max(min(minY / 50, 1), 0) + let scale = 0.7 + (0.3 * progress) + let scaledH = proxy.size.height * scale + // Center title at nav bar vertical center when stuck + let navBarCenterY = topInset - 22 + let centeringOffset = navBarCenterY - scaledH / 2 + + return content + .scaleEffect(scale, anchor: .top) + .offset(y: minY < 0 ? -minY + centeringOffset * (1 - progress) : 0) + } + .background { navBarBackground } + .zIndex(1000) + } + + // MARK: - Navigation Bar Background (progressive blur) + + private var navBarBackground: some View { + GeometryReader { geo in + let minY = geo.frame(in: .scrollView(axis: .vertical)).minY + let opacity = 1.0 - max(min(minY / 50, 1), 0) + let tint: Color = colorScheme == .dark ? .black : .white + + ZStack { + if #available(iOS 26, *) { + Rectangle() + .fill(.clear) + .glassEffect(.clear.tint(tint.opacity(0.8)), in: .rect) + .mask { + LinearGradient( + colors: [.black, .black, .black, .black.opacity(0.5), .clear], + startPoint: .top, + endPoint: .bottom + ) + } + } else { + Rectangle() + .fill(tint) + .mask { + LinearGradient( + colors: [.black, .black, .black, .black.opacity(0.9), .black.opacity(0.4), .clear], + startPoint: .top, + endPoint: .bottom + ) + } + } + } + .padding(-20) + .padding(.bottom, -40) + .padding(.top, -topInset) + .offset(y: -minY) + .opacity(opacity) + } + .allowsHitTesting(false) + } + + // MARK: - Action Buttons + + private var actionButtons: some View { + HStack(spacing: 6) { + if showCallButton { + profileActionButton(icon: "phone.fill", title: "Call", action: onCall) + } + profileActionButton( + icon: isMuted ? "bell.slash.fill" : "bell.fill", + title: isMuted ? "Unmute" : "Mute", + action: onMuteToggle + ) + profileActionButton(icon: "magnifyingglass", title: "Search", action: onSearch) + profileActionButton(icon: "ellipsis", title: "More", action: onMore) + } + } + + private func profileActionButton(icon: String, title: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + VStack(spacing: 2) { + Image(systemName: icon) + .font(.title3) + .frame(height: 30) + + Text(title) + .font(.caption) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 5) + .background { + ZStack { + RoundedRectangle(cornerRadius: 15, style: .continuous) + .fill(telegramSectionFill) + .opacity(isLargeHeader ? 0 : 1) + + RoundedRectangle(cornerRadius: 15, style: .continuous) + .fill(.ultraThinMaterial) + .opacity(isLargeHeader ? 0.8 : 0) + .environment(\.colorScheme, .dark) + } + } + .contentShape(.rect) + } + .buttonStyle(.plain) + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/PeerProfileViewModel.swift b/Rosetta/Features/Chats/ChatDetail/PeerProfileViewModel.swift new file mode 100644 index 0000000..5f229ce --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/PeerProfileViewModel.swift @@ -0,0 +1,148 @@ +import UIKit +import Combine + +/// ViewModel for OpponentProfileView — loads shared media and tracks online status. +/// Uses ObservableObject + @Published (project rule: NOT @Observable). +final class PeerProfileViewModel: ObservableObject { + let dialogKey: String + + @Published var mediaItems: [SharedMediaItem] = [] + @Published var fileItems: [SharedFileItem] = [] + @Published var linkItems: [SharedLinkItem] = [] + @Published var commonGroups: [CommonGroupItem] = [] + @Published var isOnline = false + + private var dialogCancellable: AnyCancellable? + + init(dialogKey: String) { + self.dialogKey = dialogKey + let dialog = DialogRepository.shared.dialogs[dialogKey] + isOnline = dialog?.isOnline ?? false + } + + // MARK: - Online Status + + var onlineStatusText: String { + isOnline ? "online" : "offline" + } + + func startObservingOnline() { + // Poll dialog changes (DialogRepository is @Observable, so we check periodically) + dialogCancellable = Timer.publish(every: 2, on: .main, in: .common) + .autoconnect() + .sink { [weak self] _ in + guard let self else { return } + let online = DialogRepository.shared.dialogs[self.dialogKey]?.isOnline ?? false + if self.isOnline != online { + self.isOnline = online + } + } + } + + // MARK: - Shared Content (reuses GroupInfoViewModel pattern) + + func loadSharedContent() { + let messages = MessageRepository.shared.messages(for: dialogKey) + var media: [SharedMediaItem] = [] + var files: [SharedFileItem] = [] + var links: [SharedLinkItem] = [] + + let urlDetector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) + + for message in messages { + let senderName = resolveSenderName(for: message.fromPublicKey) + + for attachment in message.attachments { + switch attachment.type { + case .image: + media.append(SharedMediaItem( + attachmentId: attachment.id, + messageId: message.id, + senderName: senderName, + timestamp: message.timestamp, + caption: message.text, + blurhash: AttachmentPreviewCodec.blurHash(from: attachment.preview) + )) + case .file: + let parsed = AttachmentPreviewCodec.parseFilePreview(attachment.preview) + files.append(SharedFileItem( + attachmentId: attachment.id, + messageId: message.id, + fileName: parsed.fileName, + fileSize: parsed.fileSize, + preview: attachment.preview, + timestamp: message.timestamp, + senderName: senderName + )) + default: + break + } + } + + let displayText = message.text + if !displayText.isEmpty, let detector = urlDetector { + let range = NSRange(displayText.startIndex..., in: displayText) + for match in detector.matches(in: displayText, range: range) { + if let urlRange = Range(match.range, in: displayText) { + links.append(SharedLinkItem( + url: String(displayText[urlRange]), + messageId: message.id, + context: String(displayText.prefix(200)), + timestamp: message.timestamp, + senderName: senderName + )) + } + } + } + } + + mediaItems = Array(media.suffix(30).reversed()) + fileItems = Array(files.suffix(30).reversed()) + linkItems = Array(links.suffix(30).reversed()) + } + + // MARK: - Common Groups + + func loadCommonGroups() { + let allDialogs = DialogRepository.shared.dialogs + let account = SessionManager.shared.currentPublicKey + var groups: [CommonGroupItem] = [] + + for (key, dialog) in allDialogs { + guard DatabaseManager.isGroupDialogKey(key) else { continue } + + // Check cached member list for this group + if let cached = GroupRepository.shared.cachedMembers(account: account, groupDialogKey: key) { + if cached.memberKeys.contains(dialogKey) { + let avatar = AvatarRepository.shared.loadAvatar(publicKey: key) + groups.append(CommonGroupItem( + dialogKey: key, + title: dialog.opponentTitle.isEmpty ? String(key.prefix(12)) : dialog.opponentTitle, + avatar: avatar + )) + } + } + } + + commonGroups = groups + } + + // MARK: - Helpers + + private func resolveSenderName(for publicKey: String) -> String { + if publicKey == SessionManager.shared.currentPublicKey { + return SessionManager.shared.displayName.isEmpty ? "You" : SessionManager.shared.displayName + } + let dialog = DialogRepository.shared.dialogs[publicKey] + return dialog?.opponentTitle ?? String(publicKey.prefix(12)) + } +} + +// MARK: - Common Group Item + +struct CommonGroupItem: Identifiable { + let dialogKey: String + let title: String + let avatar: UIImage? + var id: String { dialogKey } +} diff --git a/Rosetta/Features/Chats/ChatDetail/TelegramContextMenuCardView.swift b/Rosetta/Features/Chats/ChatDetail/TelegramContextMenuCardView.swift index 336ecc1..ecbe8fd 100644 --- a/Rosetta/Features/Chats/ChatDetail/TelegramContextMenuCardView.swift +++ b/Rosetta/Features/Chats/ChatDetail/TelegramContextMenuCardView.swift @@ -2,30 +2,46 @@ import UIKit /// Telegram-exact context menu card. /// -/// Source: ContextActionsContainerNode.swift + DefaultDarkPresentationTheme.swift -/// - Background: .systemMaterialDark blur + UIColor(0x252525, alpha: 0.78) tint -/// - Items: 17pt font, icon LEFT + title RIGHT +/// Source: ContextActionsContainerNode.swift + ContextActionNode.swift + DefaultDarkPresentationTheme.swift +/// - Background: .systemMaterial blur + UIColor(0x252525, alpha: 0.78) tint +/// - Items: 17pt regular, text LEFT + icon RIGHT (Telegram standard) +/// - Icon container: 32pt, right-aligned 16pt from edge /// - Corner radius: 14pt continuous /// - Separator: screenPixel, white 15% alpha, full width /// - Destructive: 0xeb5545 final class TelegramContextMenuCardView: UIView { + // MARK: - Layout Style + + enum LayoutStyle { + /// Icons RIGHT, text LEFT — Telegram `ContextActionNode` (message context menu) + case iconRight + /// Icons LEFT, text RIGHT — Telegram `ContextControllerActionsStackNode` (gallery menu) + case iconLeft + } + // MARK: - Constants private static let itemHeight: CGFloat = 44 private static let cornerRadius: CGFloat = 14 - private static let hPad: CGFloat = 16 - private static let iconSize: CGFloat = 24 private static let screenPixel = 1.0 / max(UIScreen.main.scale, 1) + // iconRight constants (ContextActionNode) + private static let irHPad: CGFloat = 16 + private static let irIconContainerW: CGFloat = 32 + private static let irIconSideInset: CGFloat = 12 + + // iconLeft constants (ContextControllerActionsStackNode) + private static let ilIconSideInset: CGFloat = 20 + private static let ilIconContainerW: CGFloat = 32 + private static let ilIconSpacing: CGFloat = 8 + private static let ilSideInset: CGFloat = 18 + // MARK: - Colors (adaptive light/dark) - private static let tintBg = UIColor { traits in - traits.userInterfaceStyle == .dark - ? UIColor(red: 0x25/255, green: 0x25/255, blue: 0x25/255, alpha: 0.78) - : UIColor(red: 0xF5/255, green: 0xF5/255, blue: 0xF7/255, alpha: 0.78) + private static let textColor = UIColor { traits in + traits.userInterfaceStyle == .dark ? .white : .black } - private static let textColor = UIColor.label private static let destructiveColor = UIColor(red: 0xEB/255, green: 0x55/255, blue: 0x45/255, alpha: 1) private static let separatorColor = UIColor { traits in traits.userInterfaceStyle == .dark @@ -43,10 +59,23 @@ final class TelegramContextMenuCardView: UIView { let itemCount: Int var onItemSelected: (() -> Void)? + /// Calculates minimum width needed to fit all items without clipping. + var preferredWidth: CGFloat { + var maxTextW: CGFloat = 0 + for label in titleLabels { + let sz = label.sizeThatFits(CGSize(width: .greatestFiniteMagnitude, height: Self.itemHeight)) + maxTextW = max(maxTextW, ceil(sz.width)) + } + switch layoutStyle { + case .iconLeft: + return Self.ilIconSideInset + Self.ilIconContainerW + Self.ilIconSpacing + maxTextW + Self.ilSideInset + case .iconRight: + return Self.irHPad + maxTextW + Self.irIconSideInset + Self.irIconContainerW + Self.irHPad + } + } + // MARK: - Views - private let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) - private let tintView = UIView() private let items: [TelegramContextMenuItem] // Row subviews stored for layout @@ -55,11 +84,16 @@ final class TelegramContextMenuCardView: UIView { private var highlightViews: [UIView] = [] private var separators: [UIView] = [] + private let showSeparators: Bool + private let layoutStyle: LayoutStyle + // MARK: - Init - init(items: [TelegramContextMenuItem]) { + init(items: [TelegramContextMenuItem], showSeparators: Bool = true, layoutStyle: LayoutStyle = .iconRight) { self.items = items self.itemCount = items.count + self.showSeparators = showSeparators + self.layoutStyle = layoutStyle super.init(frame: .zero) setup() } @@ -74,12 +108,13 @@ final class TelegramContextMenuCardView: UIView { layer.cornerRadius = Self.cornerRadius layer.cornerCurve = .continuous - blurView.clipsToBounds = true - addSubview(blurView) - - tintView.backgroundColor = Self.tintBg - tintView.isUserInteractionEnabled = false - addSubview(tintView) + // Telegram iPhone: no blur, just semi-transparent backgroundColor + // (ContextActionsContainerNode.swift — blur only on iPad/regular width) + backgroundColor = UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor(red: 0x25/255, green: 0x25/255, blue: 0x25/255, alpha: 0.78) + : UIColor(red: 0xF9/255, green: 0xF9/255, blue: 0xF9/255, alpha: 0.78) + } for (i, item) in items.enumerated() { let color = item.isDestructive ? Self.destructiveColor : Self.textColor @@ -102,14 +137,14 @@ final class TelegramContextMenuCardView: UIView { // Icon let iv = UIImageView() iv.image = UIImage(systemName: item.iconName)? - .withConfiguration(UIImage.SymbolConfiguration(pointSize: Self.iconSize, weight: .medium)) + .withConfiguration(UIImage.SymbolConfiguration(pointSize: 17, weight: .medium)) iv.tintColor = color iv.contentMode = .center addSubview(iv) iconViews.append(iv) - // Separator - if i < items.count - 1 { + // Separator (optional — Telegram gallery menu has none) + if showSeparators, i < items.count - 1 { let sep = UIView() sep.backgroundColor = Self.separatorColor addSubview(sep) @@ -133,24 +168,29 @@ final class TelegramContextMenuCardView: UIView { override func layoutSubviews() { super.layoutSubviews() let w = bounds.width - blurView.frame = bounds - tintView.frame = bounds for i in 0.. Void - @Binding var showControls: Bool - @Binding var currentScale: CGFloat - let onEdgeTap: ((Int) -> Void)? - - @State private var image: UIImage? - - @State private var zoomScale: CGFloat = 1.0 - @State private var zoomOffset: CGSize = .zero - @GestureState private var pinchScale: CGFloat = 1.0 - - /// Fixed screen bounds for fittedSize calculation — not dependent on GeometryReader. - /// Telegram uses the same approach: sizes are computed against screen dimensions, - /// UIScrollView handles the rest. - private let screenSize = UIScreen.main.bounds.size - - var body: some View { - let effectiveScale = zoomScale * pinchScale - - GeometryReader { geo in - let centerX = geo.size.width / 2 - let centerY = geo.size.height / 2 - - if let image { - let fitted = fittedSize(image.size, in: screenSize) - - Image(uiImage: image) - .resizable() - .frame(width: fitted.width, height: fitted.height) - .scaleEffect(effectiveScale) - .offset( - x: effectiveScale > 1.05 ? zoomOffset.width : 0, - y: effectiveScale > 1.05 ? zoomOffset.height : 0 - ) - .position(x: centerX, y: centerY) - } else { - placeholder - .position(x: centerX, y: centerY) - } - } - .contentShape(Rectangle()) - .onTapGesture(count: 2) { - withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) { - if zoomScale > 1.1 { - zoomScale = 1.0 - zoomOffset = .zero - } else { - zoomScale = 2.5 - } - currentScale = zoomScale - } - } - .onTapGesture { location in - let width = UIScreen.main.bounds.width - let edgeZone = width * 0.20 - if location.x < edgeZone { - onEdgeTap?(-1) - } else if location.x > width - edgeZone { - onEdgeTap?(1) - } else { - showControls.toggle() - } - } - .simultaneousGesture( - MagnifyGesture() - .updating($pinchScale) { value, state, _ in - state = value.magnification - } - .onEnded { value in - let newScale = zoomScale * value.magnification - withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) { - zoomScale = min(max(newScale, 1.0), 5.0) - if zoomScale <= 1.05 { - zoomScale = 1.0 - zoomOffset = .zero - } - currentScale = zoomScale - } - } - ) - .simultaneousGesture( - zoomScale > 1.05 ? - DragGesture() - .onChanged { value in zoomOffset = value.translation } - .onEnded { _ in } - : nil - ) - .task { - if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachmentId) { - image = cached - return - } - await ImageLoadLimiter.shared.acquire() - let loaded = await Task.detached(priority: .utility) { - AttachmentCache.shared.loadImage(forAttachmentId: attachmentId) - }.value - await ImageLoadLimiter.shared.release() - if !Task.isCancelled { - image = loaded - } - } - } - - // MARK: - Aspect-fit calculation (Telegram parity) - - /// Calculates the image frame to fill the view while maintaining aspect ratio. - /// Telegram: `contentSize.fitted(boundsSize)` in ZoomableContentGalleryItemNode. - private func fittedSize(_ imageSize: CGSize, in viewSize: CGSize) -> CGSize { - guard imageSize.width > 0, imageSize.height > 0, - viewSize.width > 0, viewSize.height > 0 else { - return viewSize - } - let scale = min(viewSize.width / imageSize.width, - viewSize.height / imageSize.height) - return CGSize(width: imageSize.width * scale, - height: imageSize.height * scale) - } - - // MARK: - Placeholder - - private var placeholder: some View { - VStack(spacing: 16) { - ProgressView() - .tint(.white) - Text("Loading...") - .font(.system(size: 14)) - .foregroundStyle(.white.opacity(0.5)) - } - } -} diff --git a/Rosetta/Features/Groups/EncryptionKeyView.swift b/Rosetta/Features/Groups/EncryptionKeyView.swift new file mode 100644 index 0000000..6cfb789 --- /dev/null +++ b/Rosetta/Features/Groups/EncryptionKeyView.swift @@ -0,0 +1,163 @@ +import SwiftUI + +// MARK: - EncryptionKeyView + +/// Displays the group encryption key as a pixel grid + hex bytes. +/// Desktop parity: pixel visualization (5 discrete Mantine blue colors) + XOR-encoded hex + secure message. +struct EncryptionKeyView: View { + let groupDialogKey: String + + @Environment(\.dismiss) private var dismiss + + private var keyHex: String { + let account = SessionManager.shared.currentPublicKey + guard let privateKey = SessionManager.shared.privateKeyHex else { return "" } + return GroupRepository.shared.groupKey( + account: account, + privateKeyHex: privateKey, + groupDialogKey: groupDialogKey + ) ?? "" + } + + var body: some View { + ZStack { + RosettaColors.Adaptive.background.ignoresSafeArea() + + ScrollView { + VStack(spacing: 24) { + Spacer(minLength: 32) + + // Pixel grid visualization (desktop parity: 8×8, no spacing, no radius) + EncryptionKeyPixelGrid(keyString: keyHex, size: 180) + .frame(width: 180, height: 180) + + // Hex bytes formatted in rows (desktop parity: XOR 27) + hexBytesView + .padding(.horizontal, 24) + + // Secure message + HStack(spacing: 6) { + Text("Your messages is secure") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(RosettaColors.Adaptive.text) + + Image(systemName: "lock.fill") + .font(.system(size: 14)) + .foregroundStyle(Color(hex: 0x34C759)) + } + .padding(.top, 8) + + // Description + Text("This key is used to encrypt and decrypt messages. Your messages is secure and not stored on our servers.") + .font(.system(size: 15)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + + Spacer(minLength: 40) + } + .frame(maxWidth: .infinity) + } + } + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + .enableSwipeBack() + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { dismiss() } label: { + TelegramVectorIcon( + pathData: TelegramIconPath.backChevron, + viewBox: CGSize(width: 11, height: 20), + color: RosettaColors.Adaptive.text + ) + .frame(width: 11, height: 20) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + .background { TelegramGlassCapsule() } + } + .buttonStyle(.plain) + } + } + .toolbarBackground(.hidden, for: .navigationBar) + } + + // MARK: - Hex Bytes (Desktop Parity) + + /// Desktop algorithm: (charCode ^ 27).toString(16), padded to 2 chars. + /// 4 rows, each fitting within iPhone screen width. + private var hexBytesView: some View { + let key = keyHex + let charsPerRow = 12 + let totalChars = min(key.count, charsPerRow * 4) + + let rows: [String] = stride(from: 0, to: totalChars, by: charsPerRow).map { start in + let end = min(start + charsPerRow, totalChars) + guard start < key.count else { return "" } + + let startIdx = key.index(key.startIndex, offsetBy: start) + let endIdx = key.index(key.startIndex, offsetBy: min(end, key.count)) + let substring = key[startIdx.. 0 else { return Self.mantineBlues[0] } + let charIndex = keyString.index(keyString.startIndex, offsetBy: i % totalChars) + let charCode = Int(keyString[charIndex].asciiValue ?? 0) + let colorIndex = charCode % Self.mantineBlues.count + return Self.mantineBlues[colorIndex] + } + + VStack(spacing: 0) { + ForEach(0.. Void)? + + /// Telegram accent blue (#3e88f7) — same on both themes. + private let accentBlue = Color(hex: 0x3e88f7) + + private enum Field { case title, description } + + init( + groupDialogKey: String, + initialTitle: String, + initialDescription: String, + onSave: ((String, String) -> Void)? = nil + ) { + self.groupDialogKey = groupDialogKey + self.initialTitle = initialTitle + self.initialDescription = initialDescription + self.onSave = onSave + _title = State(initialValue: initialTitle) + _description = State(initialValue: initialDescription) + } + + private var hasChanges: Bool { + title != initialTitle || description != initialDescription + } + + var body: some View { + NavigationStack { + ZStack { + RosettaColors.Adaptive.background.ignoresSafeArea() + + ScrollView { + VStack(spacing: 0) { + avatarSection + .padding(.top, 24) + + fieldsSection + .padding(.top, 24) + + Spacer(minLength: 40) + } + } + .scrollIndicators(.hidden) + } + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { dismiss() } label: { + Text("Cancel") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(RosettaColors.Adaptive.text) + .padding(.horizontal, 12) + .frame(height: 44) + .background { TelegramGlassCapsule() } + } + .buttonStyle(.plain) + } + ToolbarItem(placement: .navigationBarTrailing) { + Button { save() } label: { + Text("Done") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(hasChanges ? accentBlue : RosettaColors.Adaptive.textSecondary) + .padding(.horizontal, 12) + .frame(height: 44) + .background { TelegramGlassCapsule() } + } + .buttonStyle(.plain) + .disabled(!hasChanges) + } + } + .toolbarBackground(.hidden, for: .navigationBar) + } + } + + // MARK: - Avatar Section + + private var avatarSection: some View { + VStack(spacing: 12) { + ZStack { + AvatarView( + initials: RosettaColors.initials(name: title.isEmpty ? initialTitle : title, publicKey: groupDialogKey), + colorIndex: RosettaColors.avatarColorIndex(for: title.isEmpty ? initialTitle : title, publicKey: groupDialogKey), + size: 100, + isOnline: false, + isSavedMessages: false + ) + .opacity(0.6) + + Image(systemName: "camera.fill") + .font(.system(size: 28)) + .foregroundStyle(accentBlue) + } + + Text("Set New Photo") + .font(.system(size: 17)) + .foregroundStyle(accentBlue) + } + } + + // MARK: - Fields Section + + private var fieldsSection: some View { + TelegramSectionCard { + VStack(spacing: 0) { + // Title — 15pt vertical padding + TextField("", text: $title, prompt: Text("Group Name").foregroundStyle(telegramPlaceholderColor)) + .font(.system(size: 17)) + .foregroundStyle(RosettaColors.Adaptive.text) + .focused($focusedField, equals: .title) + .padding(.horizontal, 16) + .padding(.vertical, 15) + .onSubmit { focusedField = .description } + + // Separator + Rectangle() + .fill(telegramSeparatorColor) + .frame(height: 1.0 / UIScreen.main.scale) + .padding(.leading, 16) + + // Description — 255 char limit + ZStack(alignment: .topLeading) { + if description.isEmpty { + Text("Description") + .font(.system(size: 17)) + .foregroundStyle(telegramPlaceholderColor) + .padding(.horizontal, 16) + .padding(.vertical, 15) + } + TextEditor(text: $description) + .font(.system(size: 17)) + .foregroundStyle(RosettaColors.Adaptive.text) + .scrollContentBackground(.hidden) + .focused($focusedField, equals: .description) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .frame(minHeight: 44) + .onChange(of: description) { newValue in + if newValue.count > 255 { + description = String(newValue.prefix(255)) + } + } + } + } + } + .padding(.horizontal, 16) + } + + // MARK: - Save + + private func save() { + let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedTitle.isEmpty else { return } + + let account = SessionManager.shared.currentPublicKey + GroupRepository.shared.updateGroupMetadata( + account: account, + groupDialogKey: groupDialogKey, + title: trimmedTitle, + description: description.trimmingCharacters(in: .whitespacesAndNewlines) + ) + onSave?(trimmedTitle, description.trimmingCharacters(in: .whitespacesAndNewlines)) + dismiss() + } +} diff --git a/Rosetta/Features/Groups/GroupInfoView.swift b/Rosetta/Features/Groups/GroupInfoView.swift index 55db14f..421577d 100644 --- a/Rosetta/Features/Groups/GroupInfoView.swift +++ b/Rosetta/Features/Groups/GroupInfoView.swift @@ -2,55 +2,108 @@ import SwiftUI // MARK: - GroupInfoView -/// Telegram-style group detail screen: header, members, invite link, leave. +/// Telegram-parity group info screen. +/// Layout: header → description → action buttons (mute/search/more) → members. +/// All colors are adaptive for light/dark theme support. struct GroupInfoView: View { @StateObject private var viewModel: GroupInfoViewModel @Environment(\.dismiss) private var dismiss @State private var showLeaveAlert = false + @State private var showMoreSheet = false @State private var memberToKick: GroupMember? + @State private var isMuted = false + @State private var selectedMemberRoute: ChatRoute? + @State private var showMemberChat = false + @State private var selectedTab: GroupInfoTab = .members + @State private var showEncryptionKeyPage = false + @State private var isLargeHeader = false + @State private var topInset: CGFloat = 0 + @Namespace private var tabNamespace + @Environment(\.colorScheme) private var colorScheme + + enum GroupInfoTab: String, CaseIterable { + case members = "Members" + case media = "Media" + case files = "Files" + case voice = "Voice" + case links = "Links" + } + + /// Telegram accent blue (#3e88f7) — same on both themes. + private let accentBlue = Color(hex: 0x3e88f7) init(groupDialogKey: String) { _viewModel = StateObject(wrappedValue: GroupInfoViewModel(groupDialogKey: groupDialogKey)) } + private var groupAvatar: UIImage? { + AvatarRepository.shared.loadAvatar(publicKey: viewModel.groupDialogKey) + } + var body: some View { ZStack { RosettaColors.Adaptive.background.ignoresSafeArea() - ScrollView { - VStack(spacing: 16) { - headerSection - if !viewModel.groupDescription.isEmpty { - descriptionSection - } - inviteLinkSection - membersSection - leaveSection - } - .padding(.vertical, 16) - } + groupScrollContent + .scrollIndicators(.hidden) + .modifier(GroupScrollTracker(isLargeHeader: $isLargeHeader, topInset: $topInset, canExpand: groupAvatar != nil)) if viewModel.isLeaving { Color.black.opacity(0.5) .ignoresSafeArea() .overlay { - ProgressView().tint(.white).scaleEffect(1.2) + ProgressView().tint(RosettaColors.Adaptive.text).scaleEffect(1.2) } } } .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + .enableSwipeBack() .toolbar { - ToolbarItem(placement: .principal) { - Text("Group Info") - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(RosettaColors.Adaptive.text) + ToolbarItem(placement: .navigationBarLeading) { + Button { dismiss() } label: { + TelegramVectorIcon( + pathData: TelegramIconPath.backChevron, + viewBox: CGSize(width: 11, height: 20), + color: RosettaColors.Adaptive.text + ) + .frame(width: 11, height: 20) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + .background { TelegramGlassCapsule() } + } + .buttonStyle(.plain) + } + // Edit button removed — not needed for Telegram parity + } + .toolbarBackground(.hidden, for: .navigationBar) + .task { + // Start expanded if group has avatar photo + if groupAvatar != nil { + isLargeHeader = true } } - .task { await viewModel.loadMembers() } + .navigationDestination(isPresented: $showMemberChat) { + if let route = selectedMemberRoute { + ChatDetailView(route: route) + } + } + .navigationDestination(isPresented: $showEncryptionKeyPage) { + EncryptionKeyView(groupDialogKey: viewModel.groupDialogKey) + } + .task { + await viewModel.loadMembers() + viewModel.loadSharedContent() + let dialog = DialogRepository.shared.dialogs[viewModel.groupDialogKey] + isMuted = dialog?.isMuted ?? false + } .onChange(of: viewModel.didLeaveGroup) { left in if left { dismiss() } } + .confirmationDialog("", isPresented: $showMoreSheet, titleVisibility: .hidden) { + moreSheetButtons + } .alert("Leave Group", isPresented: $showLeaveAlert) { Button("Leave", role: .destructive) { Task { await viewModel.leaveGroup() } @@ -89,104 +142,155 @@ struct GroupInfoView: View { // MARK: - Sections private extension GroupInfoView { - var headerSection: some View { - VStack(spacing: 12) { - // Group avatar - ZStack { - Circle() - .fill(RosettaColors.figmaBlue.opacity(0.2)) - .frame(width: 90, height: 90) - Image(systemName: "person.2.fill") - .font(.system(size: 36)) - .foregroundStyle(RosettaColors.figmaBlue) + + // MARK: Scroll Content (with expandable header via safeAreaInset) + + var groupScrollContent: some View { + ScrollView(.vertical) { + LazyVStack(spacing: 0) { + if !viewModel.groupDescription.isEmpty { + descriptionSection + .padding(.top, 16) + } + + encryptionKeyCard + .padding(.top, 16) + + tabBar + .padding(.top, 20) + + // Tab content + switch selectedTab { + case .members: + membersSection + .padding(.top, 12) + + if !viewModel.isAdmin { + HStack(spacing: 6) { + Text("Group administrator has marked in messages with") + .font(.system(size: 13)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + VerifiedBadge(verified: 3, size: 20, interactive: false) + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 16) + .padding(.top, 8) + } + + case .media: + if viewModel.mediaItems.isEmpty { + emptyStateView(icon: "photo.on.rectangle", title: "No Media Yet", subtitle: "Photos and videos shared in this group will appear here.") + } else { + mediaGrid + .padding(.top, 12) + } + + case .files: + if viewModel.fileItems.isEmpty { + emptyStateView(icon: "doc", title: "No Files Yet", subtitle: "Files shared in this group will appear here.") + } else { + filesList + .padding(.top, 12) + } + + case .voice: + emptyStateView(icon: "mic", title: "No Voice Messages Yet", subtitle: "Voice messages shared in this group will appear here.") + + case .links: + if viewModel.linkItems.isEmpty { + emptyStateView(icon: "link", title: "No Links Yet", subtitle: "Links shared in this group will appear here.") + } else { + linksList + .padding(.top, 12) + } + } + + Spacer(minLength: 40) + } + .safeAreaInset(edge: .top, spacing: 0) { + PeerProfileHeaderView( + isLargeHeader: $isLargeHeader, + topInset: $topInset, + displayName: viewModel.groupTitle, + subtitleText: "\(viewModel.memberCount) members", + effectiveVerified: 0, + avatarImage: groupAvatar, + avatarInitials: RosettaColors.initials(name: viewModel.groupTitle, publicKey: viewModel.groupDialogKey), + avatarColorIndex: RosettaColors.avatarColorIndex(for: viewModel.groupTitle, publicKey: viewModel.groupDialogKey), + isMuted: isMuted, + showCallButton: false, + onCall: {}, + onMuteToggle: { + DialogRepository.shared.toggleMute(opponentKey: viewModel.groupDialogKey) + isMuted.toggle() + }, + onSearch: { dismiss() }, + onMore: { showMoreSheet = true } + ) } - - Text(viewModel.groupTitle) - .font(.system(size: 22, weight: .bold)) - .foregroundStyle(RosettaColors.Adaptive.text) - - Text("\(viewModel.members.count) members") - .font(.system(size: 15)) - .foregroundStyle(RosettaColors.Adaptive.textSecondary) } - .frame(maxWidth: .infinity) - .padding(.vertical, 8) } + // MARK: Description + var descriptionSection: some View { - GlassCard(cornerRadius: 16) { - VStack(alignment: .leading, spacing: 4) { + TelegramSectionCard { + VStack(alignment: .leading, spacing: 2) { Text("Description") - .font(.system(size: 13, weight: .medium)) + .font(.system(size: 13)) .foregroundStyle(RosettaColors.Adaptive.textSecondary) + Text(viewModel.groupDescription) .font(.system(size: 15)) .foregroundStyle(RosettaColors.Adaptive.text) } - .padding(16) .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 12) } .padding(.horizontal, 16) } - var inviteLinkSection: some View { - GlassCard(cornerRadius: 16) { - VStack(alignment: .leading, spacing: 12) { - Text("Invite Link") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(RosettaColors.Adaptive.textSecondary) + // MARK: More Action Sheet - if let invite = viewModel.inviteString { - Text(invite) - .font(.system(size: 13, design: .monospaced)) - .foregroundStyle(RosettaColors.Adaptive.text) - .lineLimit(2) - - HStack(spacing: 12) { - Button { - UIPasteboard.general.string = invite - } label: { - Label("Copy", systemImage: "doc.on.doc") - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(RosettaColors.figmaBlue) - } - } - } else { - Button { - viewModel.generateInvite() - } label: { - Label("Generate Link", systemImage: "link") - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(RosettaColors.figmaBlue) - } - } + @ViewBuilder + var moreSheetButtons: some View { + Button("Copy Invite Link") { + if let invite = viewModel.inviteString ?? { + viewModel.generateInvite() + return viewModel.inviteString + }() { + UIPasteboard.general.string = invite } - .padding(16) - .frame(maxWidth: .infinity, alignment: .leading) } - .padding(.horizontal, 16) + + Button("Leave Group", role: .destructive) { + showLeaveAlert = true + } } + // MARK: Members + var membersSection: some View { - GlassCard(cornerRadius: 16) { - VStack(alignment: .leading, spacing: 0) { - Text("Members (\(viewModel.members.count))") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(RosettaColors.Adaptive.textSecondary) - .padding(.horizontal, 16) - .padding(.top, 16) - .padding(.bottom, 8) - + TelegramSectionCard { + VStack(spacing: 0) { if viewModel.isLoading { HStack { Spacer() - ProgressView().tint(.white) + ProgressView().tint(RosettaColors.Adaptive.text) Spacer() } .padding(.vertical, 20) } else { - ForEach(viewModel.members) { member in + ForEach(Array(viewModel.members.enumerated()), id: \.element.id) { index, member in memberRow(member) + + if index < viewModel.members.count - 1 { + Rectangle() + .fill(telegramSeparatorColor) + .frame(height: 1.0 / UIScreen.main.scale) + .padding(.leading, 65) + } } } } @@ -199,37 +303,464 @@ private extension GroupInfoView { let myKey = SessionManager.shared.currentPublicKey let canKick = viewModel.isAdmin && member.id != myKey && !member.isAdmin - GroupMemberRow(member: member) - .padding(.horizontal, 16) - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - if canKick { - Button(role: .destructive) { - memberToKick = member - } label: { - Label("Remove", systemImage: "person.badge.minus") - } + Button { + // Tap on member → push their personal chat (skip self) + guard member.id != myKey else { return } + selectedMemberRoute = ChatRoute( + publicKey: member.id, + title: member.title, + username: member.username, + verified: member.verified + ) + showMemberChat = true + } label: { + GroupMemberRow(member: member) + .padding(.horizontal, 16) + } + .buttonStyle(.plain) + .contextMenu { + if canKick { + Button(role: .destructive) { + memberToKick = member + } label: { + Label("Remove", systemImage: "person.badge.minus") } } + } } - var leaveSection: some View { - Button { - showLeaveAlert = true - } label: { - HStack { - Image(systemName: "rectangle.portrait.and.arrow.right") - Text("Leave Group") + // MARK: Tab Bar (Telegram parity — horizontal capsule tabs with liquid selection) + + /// Adaptive tab text color: white on dark, dark text on light. + private var tabActiveColor: Color { + colorScheme == .dark ? .white : .black + } + + private var tabInactiveColor: Color { + colorScheme == .dark ? Color.white.opacity(0.6) : Color.black.opacity(0.4) + } + + private var tabIndicatorFill: Color { + colorScheme == .dark ? Color.white.opacity(0.18) : Color.black.opacity(0.08) + } + + var tabBar: some View { + HStack(spacing: 0) { + ForEach(GroupInfoTab.allCases, id: \.self) { tab in + Button { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + selectedTab = tab + } + } label: { + Text(tab.rawValue) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(selectedTab == tab ? tabActiveColor : tabInactiveColor) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background { + if selectedTab == tab { + Capsule() + .fill(tabIndicatorFill) + .matchedGeometryEffect(id: "tab_indicator", in: tabNamespace) + } + } + } + .buttonStyle(.plain) } - .font(.system(size: 17, weight: .medium)) - .foregroundStyle(.red) - .frame(maxWidth: .infinity) - .padding(.vertical, 14) } - .background { - GlassCard(cornerRadius: 16) { - Color.clear.frame(height: 50) + .padding(.horizontal, 3) + .padding(.vertical, 3) + .background( + Capsule() + .fill(telegramSectionFill) + ) + .padding(.horizontal, 16) + } + + // MARK: Encryption Key Card + + var encryptionKeyCard: some View { + let keyString: String = { + let account = SessionManager.shared.currentPublicKey + guard let privateKey = SessionManager.shared.privateKeyHex else { return "" } + return GroupRepository.shared.groupKey( + account: account, + privateKeyHex: privateKey, + groupDialogKey: viewModel.groupDialogKey + ) ?? "" + }() + + return Button { + showEncryptionKeyPage = true + } label: { + TelegramSectionCard { + HStack(spacing: 12) { + EncryptionKeyPixelGrid(keyString: keyString, size: 32) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + + Text("Encryption key") + .font(.system(size: 17)) + .foregroundStyle(RosettaColors.Adaptive.text) + + Spacer(minLength: 0) + + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + } + .buttonStyle(.plain) + .padding(.horizontal, 16) + } + + // MARK: Files List + + var filesList: some View { + TelegramSectionCard { + VStack(spacing: 0) { + ForEach(Array(viewModel.fileItems.enumerated()), id: \.element.id) { index, file in + Button { + openFile(file) + } label: { + HStack(spacing: 12) { + Image(systemName: fileIcon(for: file.fileName)) + .font(.system(size: 24)) + .foregroundStyle(accentBlue) + .frame(width: 40, height: 40) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(accentBlue.opacity(0.12)) + ) + + VStack(alignment: .leading, spacing: 2) { + Text(file.fileName) + .font(.system(size: 16)) + .foregroundStyle(RosettaColors.Adaptive.text) + .lineLimit(1) + + Text(file.subtitle) + .font(.system(size: 13)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + } + + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + .buttonStyle(.plain) + + if index < viewModel.fileItems.count - 1 { + Rectangle() + .fill(telegramSeparatorColor) + .frame(height: 1.0 / UIScreen.main.scale) + .padding(.leading, 68) + } + } } } .padding(.horizontal, 16) } + + private func fileIcon(for fileName: String) -> String { + let ext = (fileName as NSString).pathExtension.lowercased() + switch ext { + case "pdf": return "doc.richtext.fill" + case "doc", "docx": return "doc.text.fill" + case "xls", "xlsx": return "tablecells.fill" + case "ppt", "pptx": return "slider.horizontal.below.rectangle" + case "zip", "rar", "7z", "tar", "gz": return "doc.zipper" + case "mp3", "wav", "aac", "m4a", "ogg": return "music.note" + case "mp4", "mov", "avi", "mkv": return "film" + case "jpg", "jpeg", "png", "gif", "webp", "heic": return "photo" + case "txt", "md", "rtf": return "doc.plaintext" + case "json", "xml", "csv": return "doc.text" + case "swift", "js", "ts", "py", "java", "kt": return "chevron.left.forwardslash.chevron.right" + default: return "doc.fill" + } + } + + private func openFile(_ file: SharedFileItem) { + let parsed = AttachmentPreviewCodec.parseFilePreview(file.preview) + let tag = parsed.downloadTag + guard !tag.isEmpty else { return } + + // Try loading from cache first + if let data = AttachmentCache.shared.loadFileData(forAttachmentId: file.attachmentId, fileName: file.fileName) { + shareFileData(data, fileName: file.fileName) + return + } + + // Download from CDN and decrypt + Task { + do { + let rawData = try await TransportManager.shared.downloadFile(tag: tag) + let encryptedString = String(decoding: rawData, as: UTF8.self) + + // Get group key and build password candidates + let account = SessionManager.shared.currentPublicKey + guard let privateKey = SessionManager.shared.privateKeyHex else { return } + let groupKey = GroupRepository.shared.groupKey( + account: account, privateKeyHex: privateKey, groupDialogKey: viewModel.groupDialogKey + ) ?? "" + + let candidates = groupKey.isEmpty ? [] : MessageCrypto.attachmentPasswordCandidates(from: groupKey) + + // Try each password candidate + var decryptedData: Data? + for password in candidates { + // Try with compression first (standard path) + if let d = try? CryptoManager.shared.decryptWithPassword( + encryptedString, password: password, requireCompression: true + ) { + decryptedData = d + break + } + // Fallback without compression requirement + if let d = try? CryptoManager.shared.decryptWithPassword( + encryptedString, password: password + ) { + decryptedData = d + break + } + } + + // Parse data URI if present (CDN stores as data:...;base64,) + let fileData: Data + if let decrypted = decryptedData { + if let dataStr = String(data: decrypted, encoding: .utf8), + let range = dataStr.range(of: ";base64,"), + let binaryData = Data(base64Encoded: String(dataStr[range.upperBound...])) { + fileData = binaryData + } else { + fileData = decrypted + } + } else { + // Fallback: use raw downloaded data + fileData = rawData + } + + AttachmentCache.shared.saveFile(fileData, forAttachmentId: file.attachmentId, fileName: file.fileName) + shareFileData(fileData, fileName: file.fileName) + } catch { + // Show error feedback on main thread + } + } + } + + private func shareFileData(_ data: Data, fileName: String) { + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName) + try? data.write(to: tempURL) + let activityVC = UIActivityViewController(activityItems: [tempURL], applicationActivities: nil) + if let popover = activityVC.popoverPresentationController { + popover.sourceView = UIView() + } + guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootVC = scene.windows.first?.rootViewController else { return } + var topVC = rootVC + while let presented = topVC.presentedViewController { + topVC = presented + } + topVC.present(activityVC, animated: true) + } + + // MARK: Links List + + var linksList: some View { + TelegramSectionCard { + VStack(spacing: 0) { + ForEach(Array(viewModel.linkItems.enumerated()), id: \.element.id) { index, link in + Button { + if let url = URL(string: link.url) { + UIApplication.shared.open(url) + } + } label: { + HStack(spacing: 12) { + Text(String(link.displayHost.prefix(1)).uppercased()) + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(.white) + .frame(width: 40, height: 40) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(accentBlue) + ) + + VStack(alignment: .leading, spacing: 2) { + Text(link.displayHost) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(RosettaColors.Adaptive.text) + .lineLimit(1) + + Text(link.context) + .font(.system(size: 13)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + .lineLimit(2) + } + + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + .buttonStyle(.plain) + + if index < viewModel.linkItems.count - 1 { + Rectangle() + .fill(telegramSeparatorColor) + .frame(height: 1.0 / UIScreen.main.scale) + .padding(.leading, 68) + } + } + } + } + .padding(.horizontal, 16) + } + + // MARK: Empty State (Telegram parity) + + func emptyStateView(icon: String, title: String, subtitle: String) -> some View { + VStack(spacing: 12) { + Image(systemName: icon) + .font(.system(size: 48)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.5)) + + Text(title) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(RosettaColors.Adaptive.text) + + Text(subtitle) + .font(.system(size: 15)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + .frame(maxWidth: .infinity) + .padding(.top, 60) + .padding(.bottom, 40) + } + + // MARK: Media Grid (Telegram parity — edge-to-edge, 3 columns, 1px spacing) + + var mediaGrid: some View { + let columns = Array(repeating: GridItem(.flexible(), spacing: 1), count: 3) + return LazyVGrid(columns: columns, spacing: 1) { + ForEach(viewModel.mediaItems) { item in + SharedMediaTileView(item: item, allItems: viewModel.mediaItems) + } + } + } +} + +// MARK: - SharedMediaTileView (edge-to-edge tile for media grid) + +private struct SharedMediaTileView: View { + let item: SharedMediaItem + let allItems: [SharedMediaItem] + + @State private var image: UIImage? + @State private var blurImage: UIImage? + + var body: some View { + GeometryReader { proxy in + ZStack { + if let image { + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: proxy.size.width, height: proxy.size.width) + .clipped() + } else if let blurImage { + Image(uiImage: blurImage) + .resizable() + .scaledToFill() + .frame(width: proxy.size.width, height: proxy.size.width) + .clipped() + } else { + Color(white: 0.15) + } + } + .contentShape(Rectangle()) + .onTapGesture { openGallery() } + } + .aspectRatio(1, contentMode: .fit) + .task { loadImages() } + } + + private func loadImages() { + if let cached = AttachmentCache.shared.loadImage(forAttachmentId: item.attachmentId) { + image = cached + } else if !item.blurhash.isEmpty { + blurImage = BlurHashDecoder.decode(blurHash: item.blurhash, width: 32, height: 32) + } + } + + private func openGallery() { + let viewableImages = allItems.map { mediaItem in + ViewableImageInfo( + attachmentId: mediaItem.attachmentId, + messageId: mediaItem.messageId, + senderName: mediaItem.senderName, + timestamp: Date(timeIntervalSince1970: Double(mediaItem.timestamp) / 1000.0), + caption: mediaItem.caption + ) + } + let index = allItems.firstIndex(where: { $0.attachmentId == item.attachmentId }) ?? 0 + let state = ImageViewerState( + images: viewableImages, + initialIndex: index, + sourceFrame: .zero + ) + ImageViewerPresenter.shared.present(state: state) + } +} + +// MARK: - Scroll Tracking (iOS 18+ with fallback) + +private struct GroupScrollTracker: ViewModifier { + @Binding var isLargeHeader: Bool + @Binding var topInset: CGFloat + let canExpand: Bool + + func body(content: Content) -> some View { + if #available(iOS 18, *) { + GroupIOS18ScrollTracker(isLargeHeader: $isLargeHeader, topInset: $topInset, canExpand: canExpand) { + content + } + } else { + content + } + } +} + +@available(iOS 18, *) +private struct GroupIOS18ScrollTracker: View { + @Binding var isLargeHeader: Bool + @Binding var topInset: CGFloat + let canExpand: Bool + @State private var scrollPhase: ScrollPhase = .idle + let content: () -> Content + + var body: some View { + content() + .onScrollGeometryChange(for: CGFloat.self) { + $0.contentInsets.top + } action: { _, newValue in + topInset = newValue + } + .onScrollGeometryChange(for: CGFloat.self) { + $0.contentOffset.y + $0.contentInsets.top + } action: { _, newValue in + if scrollPhase == .interacting { + withAnimation(.snappy(duration: 0.2, extraBounce: 0)) { + isLargeHeader = canExpand && (newValue < -10 || (isLargeHeader && newValue < 0)) + } + } + } + .onScrollPhaseChange { _, newPhase in + scrollPhase = newPhase + } + } } diff --git a/Rosetta/Features/Groups/GroupInfoViewModel.swift b/Rosetta/Features/Groups/GroupInfoViewModel.swift index 9d33c2a..01bf77a 100644 --- a/Rosetta/Features/Groups/GroupInfoViewModel.swift +++ b/Rosetta/Features/Groups/GroupInfoViewModel.swift @@ -21,12 +21,16 @@ final class GroupInfoViewModel: ObservableObject { @Published var groupTitle: String = "" @Published var groupDescription: String = "" @Published var members: [GroupMember] = [] + @Published var memberCount: Int = 0 @Published var isAdmin: Bool = false @Published var isLoading: Bool = false @Published var isLeaving: Bool = false @Published var errorMessage: String? @Published var didLeaveGroup: Bool = false @Published var inviteString: String? + @Published var mediaItems: [SharedMediaItem] = [] + @Published var fileItems: [SharedFileItem] = [] + @Published var linkItems: [SharedLinkItem] = [] private let groupService = GroupService.shared private let groupRepo = GroupRepository.shared @@ -34,6 +38,7 @@ final class GroupInfoViewModel: ObservableObject { init(groupDialogKey: String) { self.groupDialogKey = groupDialogKey loadLocalMetadata() + loadCachedMembers() } private func loadLocalMetadata() { @@ -44,14 +49,71 @@ final class GroupInfoViewModel: ObservableObject { } } + /// Instantly populates members from in-memory cache (no network). + private func loadCachedMembers() { + let account = SessionManager.shared.currentPublicKey + guard let cached = groupRepo.cachedMembers(account: account, groupDialogKey: groupDialogKey) else { return } + let resolved = resolveMembers(keys: cached.memberKeys) + members = resolved + memberCount = resolved.count + isAdmin = cached.adminKey == SessionManager.shared.currentPublicKey + } + func loadMembers() async { - isLoading = true + let hasCachedData = !members.isEmpty + if !hasCachedData { isLoading = true } do { let memberKeys = try await groupService.requestMembers(groupDialogKey: groupDialogKey) let myKey = SessionManager.shared.currentPublicKey - var resolved: [GroupMember] = [] - for (index, key) in memberKeys.enumerated() { + // Update cache + groupRepo.updateMemberCache( + account: myKey, + groupDialogKey: groupDialogKey, + memberKeys: memberKeys + ) + + let resolved = resolveMembers(keys: memberKeys) + let newAdmin = memberKeys.first == myKey + let newCount = resolved.count + + // Only update @Published properties if data actually changed + // to avoid unnecessary re-renders / "flash" effect. + if members.map(\.id) != resolved.map(\.id) { + members = resolved + } + if memberCount != newCount { + memberCount = newCount + } + if isAdmin != newAdmin { + isAdmin = newAdmin + } + } catch { + if !hasCachedData { + errorMessage = error.localizedDescription + } + } + if isLoading { isLoading = false } + } + + /// Resolves public keys into GroupMember objects using DialogRepository. + private func resolveMembers(keys: [String]) -> [GroupMember] { + let myKey = SessionManager.shared.currentPublicKey + var resolved: [GroupMember] = [] + for (index, key) in keys.enumerated() { + if key == myKey { + let sm = SessionManager.shared + let myTitle = sm.displayName.isEmpty ? String(key.prefix(12)) : sm.displayName + let myUsername = sm.username + resolved.append(GroupMember( + id: key, + title: myTitle, + username: myUsername, + isAdmin: index == 0, + isOnline: true, + verified: 0 + )) + } else { let dialog = DialogRepository.shared.dialogs[key] resolved.append(GroupMember( id: key, @@ -62,12 +124,8 @@ final class GroupInfoViewModel: ObservableObject { verified: dialog?.verified ?? 0 )) } - members = resolved - isAdmin = memberKeys.first == myKey - } catch { - errorMessage = error.localizedDescription } - isLoading = false + return resolved } func leaveGroup() async { @@ -93,4 +151,78 @@ final class GroupInfoViewModel: ObservableObject { func generateInvite() { inviteString = groupService.generateInviteString(groupDialogKey: groupDialogKey) } + + func loadSharedContent() { + let messages = MessageRepository.shared.messages(for: groupDialogKey) + var media: [SharedMediaItem] = [] + var files: [SharedFileItem] = [] + var links: [SharedLinkItem] = [] + + // URL detection + let urlDetector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) + + for message in messages { + let senderName = resolveSenderName(for: message.fromPublicKey) + + // Media & Files from attachments + for attachment in message.attachments { + switch attachment.type { + case .image: + media.append(SharedMediaItem( + attachmentId: attachment.id, + messageId: message.id, + senderName: senderName, + timestamp: message.timestamp, + caption: message.text, + blurhash: AttachmentPreviewCodec.blurHash(from: attachment.preview) + )) + case .file: + let parsed = AttachmentPreviewCodec.parseFilePreview(attachment.preview) + files.append(SharedFileItem( + attachmentId: attachment.id, + messageId: message.id, + fileName: parsed.fileName, + fileSize: parsed.fileSize, + preview: attachment.preview, + timestamp: message.timestamp, + senderName: senderName + )) + default: + break + } + } + + // Links from message text + let displayText = message.text + if !displayText.isEmpty, let detector = urlDetector { + let range = NSRange(displayText.startIndex..., in: displayText) + let matches = detector.matches(in: displayText, range: range) + for match in matches { + if let urlRange = Range(match.range, in: displayText) { + let urlString = String(displayText[urlRange]) + links.append(SharedLinkItem( + url: urlString, + messageId: message.id, + context: String(displayText.prefix(200)), + timestamp: message.timestamp, + senderName: senderName + )) + } + } + } + } + + mediaItems = Array(media.suffix(30).reversed()) + fileItems = Array(files.suffix(30).reversed()) + linkItems = Array(links.suffix(30).reversed()) + } + + private func resolveSenderName(for publicKey: String) -> String { + if let dialog = DialogRepository.shared.dialogs[publicKey] { + return dialog.opponentTitle.isEmpty ? String(publicKey.prefix(8)) : dialog.opponentTitle + } else if publicKey == SessionManager.shared.currentPublicKey { + return SessionManager.shared.displayName + } + return String(publicKey.prefix(8)) + } } diff --git a/Rosetta/Features/Groups/GroupMemberRow.swift b/Rosetta/Features/Groups/GroupMemberRow.swift index 71c1af6..ecf7aee 100644 --- a/Rosetta/Features/Groups/GroupMemberRow.swift +++ b/Rosetta/Features/Groups/GroupMemberRow.swift @@ -2,57 +2,65 @@ import SwiftUI // MARK: - GroupMemberRow -/// Reusable row for displaying a group member with avatar, name, role badge. +/// Telegram-parity group member row. All colors adaptive for light/dark. +/// Avatar 40pt, name 17pt regular, status 14pt, role badge right-aligned. struct GroupMemberRow: View { let member: GroupMember + /// Telegram accent blue (#3e88f7) — same on both themes. + private let accentBlue = Color(hex: 0x3e88f7) + /// Telegram admin badge green (#49a355). + private let adminBadge = Color(hex: 0x49a355) + var body: some View { - HStack(spacing: 12) { - // Avatar + HStack(spacing: 9) { AvatarView( initials: RosettaColors.initials(name: member.title, publicKey: member.id), colorIndex: RosettaColors.avatarColorIndex(for: member.title, publicKey: member.id), - size: 44, + size: 40, isOnline: member.isOnline, isSavedMessages: false, - image: AvatarRepository.shared.loadAvatar(publicKey: member.id) + image: AvatarRepository.shared.loadAvatar(publicKey: member.id), + onlineBorderColor: telegramSectionFill ) - // Name + username VStack(alignment: .leading, spacing: 2) { HStack(spacing: 4) { Text(member.title) - .font(.system(size: 16, weight: .medium)) + .font(.system(size: 17)) .foregroundStyle(RosettaColors.Adaptive.text) .lineLimit(1) - if member.isAdmin { - Text("admin") - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(.white) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background( - Capsule().fill(RosettaColors.figmaBlue) - ) - } - if member.verified > 0 { VerifiedBadge(verified: member.verified, size: 14) } } - if !member.username.isEmpty { + if member.isOnline { + Text("online") + .font(.system(size: 14)) + .foregroundStyle(accentBlue) + .lineLimit(1) + } else if !member.username.isEmpty { Text("@\(member.username)") - .font(.system(size: 13)) + .font(.system(size: 14)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + .lineLimit(1) + } else { + Text("last seen recently") + .font(.system(size: 14)) .foregroundStyle(RosettaColors.Adaptive.textSecondary) .lineLimit(1) } } - Spacer() + Spacer(minLength: 4) + + if member.isAdmin { + VerifiedBadge(verified: 3, size: 20) + } } - .padding(.vertical, 6) + .padding(.vertical, 10) .contentShape(Rectangle()) } } diff --git a/Rosetta/Features/Groups/SharedMediaSection.swift b/Rosetta/Features/Groups/SharedMediaSection.swift new file mode 100644 index 0000000..089f05a --- /dev/null +++ b/Rosetta/Features/Groups/SharedMediaSection.swift @@ -0,0 +1,56 @@ +import Foundation + +// MARK: - Shared Content Models + +struct SharedMediaItem: Identifiable { + let attachmentId: String + let messageId: String + let senderName: String + let timestamp: Int64 + let caption: String + let blurhash: String + var id: String { attachmentId } +} + +struct SharedFileItem: Identifiable { + let attachmentId: String + let messageId: String + let fileName: String + let fileSize: Int + let preview: String + let timestamp: Int64 + let senderName: String + var id: String { attachmentId } + + var formattedDate: String { + let date = Date(timeIntervalSince1970: Double(timestamp) / 1000.0) + let formatter = DateFormatter() + formatter.dateFormat = "MMM d, yyyy" + return formatter.string(from: date) + } + + var formattedSize: String { + if fileSize <= 0 { return "" } + if fileSize < 1024 { return "\(fileSize) B" } + if fileSize < 1024 * 1024 { return String(format: "%.1f KB", Double(fileSize) / 1024.0) } + return String(format: "%.1f MB", Double(fileSize) / (1024.0 * 1024.0)) + } + + var subtitle: String { + let parts = [formattedSize, formattedDate].filter { !$0.isEmpty } + return parts.joined(separator: " · ") + } +} + +struct SharedLinkItem: Identifiable { + let url: String + let messageId: String + let context: String + let timestamp: Int64 + let senderName: String + var id: String { "\(messageId)_\(url)" } + + var displayHost: String { + URL(string: url)?.host ?? url + } +} diff --git a/Rosetta/Features/Settings/DynamicIslandBlurView.swift b/Rosetta/Features/Settings/DynamicIslandBlurView.swift new file mode 100644 index 0000000..57904fc --- /dev/null +++ b/Rosetta/Features/Settings/DynamicIslandBlurView.swift @@ -0,0 +1,148 @@ +import SwiftUI +import UIKit + +/// Telegram-parity Dynamic Island blur effect. +/// Replicates DynamicIslandBlurNode.swift from Telegram iOS: +/// - UIVisualEffectView(.dark) with UIViewPropertyAnimator for progressive blur +/// - Black fade overlay with alpha formula +/// - Radial gradient for edge feathering +struct DynamicIslandBlurView: UIViewRepresentable { + let progress: CGFloat + + func makeUIView(context: Context) -> DynamicIslandBlurUIView { + DynamicIslandBlurUIView() + } + + func updateUIView(_ uiView: DynamicIslandBlurUIView, context: Context) { + uiView.update(progress) + } +} + +final class DynamicIslandBlurUIView: UIView { + private var effectView: UIVisualEffectView? + private let fadeView = UIView() + private let gradientView = UIImageView() + private var animator: UIViewPropertyAnimator? + + override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + private func setup() { + isUserInteractionEnabled = false + clipsToBounds = true + + // Blur effect view (Telegram: effectView with nil initial effect) + let effectView = UIVisualEffectView(effect: nil) + effectView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + self.effectView = effectView + addSubview(effectView) + + // Radial gradient (Telegram: 100×100, center offset +38, radius 90) + gradientView.image = Self.makeGradientImage() + gradientView.contentMode = .center + gradientView.autoresizingMask = [.flexibleLeftMargin, .flexibleRightMargin] + addSubview(gradientView) + + // Fade overlay (Telegram: black, alpha driven by formula) + fadeView.backgroundColor = .black + fadeView.alpha = 0 + fadeView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + addSubview(fadeView) + } + + override func layoutSubviews() { + super.layoutSubviews() + effectView?.frame = bounds + fadeView.frame = bounds + + let gradientSize = CGSize(width: 100, height: 100) + gradientView.frame = CGRect( + x: (bounds.width - gradientSize.width) / 2, + y: 0, + width: gradientSize.width, + height: gradientSize.height + ) + } + + func update(_ value: CGFloat) { + // Telegram formula: fadeAlpha = min(1.0, max(0.0, -0.25 + value * 1.55)) + let fadeAlpha = min(1.0, max(0.0, -0.25 + value * 1.55)) + + if value > 0.0 { + var adjustedValue = value + let prepared = prepare() + if adjustedValue > 0.99 && prepared { + adjustedValue = 0.99 + } + // Telegram formula: fractionComplete = max(0.0, -0.1 + value * 1.1) + animator?.fractionComplete = max(0.0, -0.1 + adjustedValue * 1.1) + } else { + animator?.stopAnimation(true) + animator = nil + effectView?.effect = nil + } + + fadeView.alpha = fadeAlpha + } + + private func prepare() -> Bool { + guard animator == nil else { return false } + + let anim = UIViewPropertyAnimator(duration: 1.0, curve: .linear) + animator = anim + effectView?.effect = nil + anim.addAnimations { [weak self] in + self?.effectView?.effect = UIBlurEffect(style: .dark) + } + return true + } + + deinit { + animator?.stopAnimation(true) + } + + // Telegram: radial gradient 100×100, center (50, 88), radius 90 + // Colors: transparent → transparent (0.87) → black (1.0) + private static func makeGradientImage() -> UIImage? { + let size = CGSize(width: 100, height: 100) + UIGraphicsBeginImageContextWithOptions(size, false, 0) + guard let ctx = UIGraphicsGetCurrentContext() else { return nil } + + let colorSpace = CGColorSpaceCreateDeviceRGB() + var locations: [CGFloat] = [0.0, 0.87, 1.0] + let colors: [CGColor] = [ + UIColor(white: 0, alpha: 0).cgColor, + UIColor(white: 0, alpha: 0).cgColor, + UIColor(white: 0, alpha: 1).cgColor, + ] + guard let gradient = CGGradient( + colorsSpace: colorSpace, + colors: colors as CFArray, + locations: &locations + ) else { + UIGraphicsEndImageContext() + return nil + } + + let center = CGPoint(x: size.width / 2, y: size.height / 2 + 38) + ctx.drawRadialGradient( + gradient, + startCenter: center, + startRadius: 0, + endCenter: center, + endRadius: 90, + options: .drawsAfterEndLocation + ) + + let image = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return image + } +} diff --git a/Rosetta/Features/Settings/SettingsProfileHeader.swift b/Rosetta/Features/Settings/SettingsProfileHeader.swift new file mode 100644 index 0000000..199227e --- /dev/null +++ b/Rosetta/Features/Settings/SettingsProfileHeader.swift @@ -0,0 +1,101 @@ +import SwiftUI + +/// Profile header with Dynamic Island metaball morphing. +/// Avatar shrinks, blurs, and fades as user scrolls up. +/// Name/subtitle pins to top when scrolled past (sticky header). +struct SettingsProfileHeader: View { + let viewModel: SettingsViewModel + let avatarImage: UIImage? + let safeArea: EdgeInsets + let isHavingNotch: Bool + let screenWidth: CGFloat + @State private var scrollProgress: CGFloat = 0 + + @State private var textHeaderOffset: CGFloat = .infinity + + private let avatarFullSize: CGFloat = 100 + private let avatarMinScale: CGFloat = 0.55 + + private var avatarSize: CGFloat { + let scale = 1.0 - (1.0 - avatarMinScale) * scrollProgress + return avatarFullSize * scale + } + + var body: some View { + let fixedTop: CGFloat = safeArea.top + 3 + + VStack(spacing: 12) { + // MARK: Avatar with morph tracking + AvatarView( + initials: viewModel.initials, + colorIndex: viewModel.avatarColorIndex, + size: avatarSize, + isSavedMessages: false, + image: avatarImage + ) + .background(RosettaColors.Adaptive.background) + .opacity(1 - scrollProgress) + .blur(radius: scrollProgress * 10, opaque: true) + .clipShape(Circle()) + .anchorPreference(key: AnchorKey.self, value: .bounds) { + ["HEADER": $0] + } + .padding(.top, safeArea.top + 15) + .offsetExtractor(coordinateSpace: "SETTINGS_SCROLL") { scrollRect in + guard isHavingNotch else { return } + let progress = -scrollRect.minY / 25 + scrollProgress = min(max(progress, 0), 1) + } + + // MARK: Sticky text header + VStack(spacing: 4) { + HStack(spacing: 4) { + Text(viewModel.headerName) + .font(.system(size: 22, weight: .bold)) + .foregroundStyle(RosettaColors.Adaptive.text) + .scaleEffect(1.0 - scrollProgress * 0.4) + + VerifiedBadge(verified: viewModel.verified, size: 18) + } + + if !viewModel.username.isEmpty { + Text("@\(viewModel.username)") + .font(.system(size: 15)) + .foregroundStyle(RosettaColors.secondaryText) + .opacity(1.0 - scrollProgress) + } + } + .padding(.vertical, 15) + .background { + Rectangle() + .fill(RosettaColors.Adaptive.background) + .frame(width: screenWidth) + .padding(.top, textHeaderOffset < fixedTop ? -safeArea.top : 0) + .shadow( + color: .black.opacity(min(1.0, max(0.0, (fixedTop - textHeaderOffset) / 20.0)) * 0.1), + radius: 5, x: 0, y: 5 + ) + } + .offset(y: textHeaderOffset < fixedTop ? -(textHeaderOffset - fixedTop) : 0) + .offsetExtractor(coordinateSpace: "SETTINGS_SCROLL") { + textHeaderOffset = $0.minY + } + .zIndex(1000) + + // MARK: Public key (below sticky area) + CopyableText( + displayText: formatPublicKey(viewModel.publicKey), + fullText: viewModel.publicKey, + font: .monospacedSystemFont(ofSize: 12, weight: .regular), + textColor: UIColor(RosettaColors.tertiaryText) + ) + .frame(height: 16) + } + .padding(.vertical, 8) + } + + private func formatPublicKey(_ key: String) -> String { + guard key.count > 16 else { return key } + return String(key.prefix(8)) + "..." + String(key.suffix(6)) + } +} diff --git a/Rosetta/Features/Settings/SettingsView.swift b/Rosetta/Features/Settings/SettingsView.swift index d282c3c..7bac26f 100644 --- a/Rosetta/Features/Settings/SettingsView.swift +++ b/Rosetta/Features/Settings/SettingsView.swift @@ -40,31 +40,20 @@ struct SettingsView: View { @State private var displayNameError: String? @State private var usernameError: String? @State private var isSaving = false + @State private var viewSafeArea: EdgeInsets = EdgeInsets() var body: some View { - NavigationStack(path: $navigationPath) { - ScrollView(showsIndicators: false) { - if isEditingProfile { - ProfileEditView( - onAddAccount: onAddAccount, - displayName: $editDisplayName, - username: $editUsername, - publicKey: viewModel.publicKey, - displayNameError: $displayNameError, - usernameError: $usernameError, - pendingPhoto: $pendingAvatarPhoto - ) - .transition(.opacity) - } else { - settingsContent - .transition(.opacity) + ZStack(alignment: .top) { + NavigationStack(path: $navigationPath) { + GeometryReader { geometry in + let size = geometry.size + let safeArea = geometry.safeAreaInsets + + settingsScrollContent(size: size, safeArea: safeArea) + .ignoresSafeArea() + .onAppear { viewSafeArea = safeArea } } - } - .background(RosettaColors.Adaptive.background) - .scrollContentBackground(.hidden) - .navigationBarTitleDisplayMode(.inline) - .toolbar { toolbarContent } - .toolbarBackground(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .navigationBar) .navigationDestination(for: SettingsDestination.self) { destination in switch destination { case .updates: @@ -158,13 +147,89 @@ struct SettingsView: View { Text("You may create a new account or import an existing one.") } } + + // Toolbar OUTSIDE NavigationStack — above hidden nav bar + if !isDetailPresented { + settingsToolbarOverlay(safeArea: viewSafeArea) + .ignoresSafeArea(.all, edges: .top) + } + } } - // MARK: - Toolbar + // MARK: - Scroll Content (reference: Home.swift structure) - @ToolbarContentBuilder - private var toolbarContent: some ToolbarContent { - ToolbarItem(placement: .navigationBarLeading) { + /// Main scrollable content with metaball Canvas — matches reference exactly. + @ViewBuilder + private func settingsScrollContent(size: CGSize, safeArea: EdgeInsets) -> some View { + let isHavingNotch = safeArea.bottom != 0 + ScrollView(.vertical, showsIndicators: false) { + if isEditingProfile { + ProfileEditView( + onAddAccount: onAddAccount, + displayName: $editDisplayName, + username: $editUsername, + publicKey: viewModel.publicKey, + displayNameError: $displayNameError, + usernameError: $usernameError, + pendingPhoto: $pendingAvatarPhoto + ) + .transition(.opacity) + .padding(.top, safeArea.top + 50) + } else { + settingsContentInner(safeArea: safeArea, screenWidth: size.width) + .transition(.opacity) + } + } + .backgroundPreferenceValue(AnchorKey.self) { pref in + GeometryReader { proxy in + if let anchor = pref["HEADER"], isHavingNotch { + let frameRect = proxy[anchor] + let isHavingDynamicIsland = safeArea.top > 51 + let capsuleHeight: CGFloat = isHavingDynamicIsland ? 37 : (safeArea.top - 15) + + Canvas { out, canvasSize in + out.addFilter(.alphaThreshold(min: 0.5)) + out.addFilter(.blur(radius: 12)) + out.drawLayer { ctx in + if let headerView = out.resolveSymbol(id: 0) { + ctx.draw(headerView, in: frameRect) + } + if let dynamicIsland = out.resolveSymbol(id: 1) { + let rect = CGRect( + x: (canvasSize.width - 120) / 2, + y: isHavingDynamicIsland ? 11 : 0, + width: 120, + height: capsuleHeight + ) + ctx.draw(dynamicIsland, in: rect) + } + } + } symbols: { + Circle() + .fill(.black) + .frame(width: frameRect.width, height: frameRect.height) + .tag(0).id(0) + Capsule() + .fill(.black) + .frame(width: 120, height: capsuleHeight) + .tag(1).id(1) + } + } + } + .overlay(alignment: .top) { + Rectangle() + .fill(RosettaColors.Adaptive.background) + .frame(height: 15) + } + } + .coordinateSpace(name: "SETTINGS_SCROLL") + } + + // MARK: - Toolbar Overlay (replaces NavigationStack toolbar) + + @ViewBuilder + private func settingsToolbarOverlay(safeArea: EdgeInsets) -> some View { + HStack { if isEditingProfile { Button { pendingAvatarPhoto = nil @@ -185,9 +250,9 @@ struct SettingsView: View { DarkModeButton() .glassCircle() } - } - ToolbarItem(placement: .navigationBarTrailing) { + Spacer() + if isEditingProfile { Button { saveProfile() @@ -225,6 +290,10 @@ struct SettingsView: View { .glassCapsule() } } + .padding(.horizontal, 15) + .padding(.top, safeArea.top) + .frame(maxWidth: .infinity) + .frame(height: safeArea.top + 44) } // MARK: - Profile Save @@ -341,9 +410,17 @@ struct SettingsView: View { // MARK: - Settings Content - private var settingsContent: some View { + @ViewBuilder + private func settingsContentInner(safeArea: EdgeInsets, screenWidth: CGFloat) -> some View { + let isHavingNotch = safeArea.bottom != 0 VStack(spacing: 0) { - profileHeader + SettingsProfileHeader( + viewModel: viewModel, + avatarImage: avatarImage, + safeArea: safeArea, + isHavingNotch: isHavingNotch, + screenWidth: screenWidth + ) accountSwitcherCard @@ -359,7 +436,7 @@ struct SettingsView: View { } .padding(.horizontal, 16) .padding(.top, 0) - .padding(.bottom, 100) + .padding(.bottom, 300) } /// Desktop parity: "rosetta — powering freedom" footer with small R icon. @@ -377,45 +454,6 @@ struct SettingsView: View { .padding(.top, 32) } - // MARK: - Profile Header - - private var profileHeader: some View { - VStack(spacing: 12) { - AvatarView( - initials: viewModel.initials, - colorIndex: viewModel.avatarColorIndex, - size: 80, - isSavedMessages: false, - image: avatarImage - ) - - VStack(spacing: 4) { - HStack(spacing: 4) { - Text(viewModel.headerName) - .font(.system(size: 22, weight: .bold)) - .foregroundStyle(RosettaColors.Adaptive.text) - - VerifiedBadge(verified: viewModel.verified, size: 18) - } - - if !viewModel.username.isEmpty { - Text("@\(viewModel.username)") - .font(.system(size: 15)) - .foregroundStyle(RosettaColors.secondaryText) - } - } - - CopyableText( - displayText: formatPublicKey(viewModel.publicKey), - fullText: viewModel.publicKey, - font: .monospacedSystemFont(ofSize: 12, weight: .regular), - textColor: UIColor(RosettaColors.tertiaryText) - ) - .frame(height: 16) - } - .padding(.vertical, 8) - } - // MARK: - Account Switcher Card @State private var accountToDelete: Account? @@ -906,11 +944,6 @@ struct SettingsView: View { .padding(.leading, 62) } - private func formatPublicKey(_ key: String) -> String { - guard key.count > 16 else { return key } - return String(key.prefix(8)) + "..." + String(key.suffix(6)) - } - } // MARK: - Account Swipe Row diff --git a/Rosetta/RosettaApp.swift b/Rosetta/RosettaApp.swift index cc1ef83..2d9595a 100644 --- a/Rosetta/RosettaApp.swift +++ b/Rosetta/RosettaApp.swift @@ -863,31 +863,33 @@ struct RosettaApp: App { // Telegram parity: 8pt inset below safe area top (NotificationItemContainerNode.swift:98). VStack(spacing: 0) { if let banner = bannerManager.currentBanner { + let navigateToChat = { + bannerManager.dismiss() + let route = ChatRoute( + publicKey: banner.senderKey, + title: banner.senderName, + username: "", + verified: banner.verified + ) + AppDelegate.pendingChatRoute = route + AppDelegate.pendingChatRouteTimestamp = Date() + NotificationCenter.default.post( + name: .openChatFromNotification, + object: route + ) + } InAppBannerView( senderName: banner.senderName, messagePreview: banner.messagePreview, senderKey: banner.senderKey, isGroup: banner.isGroup, - onTap: { - bannerManager.dismiss() - let route = ChatRoute( - publicKey: banner.senderKey, - title: banner.senderName, - username: "", - verified: banner.verified - ) - AppDelegate.pendingChatRoute = route - AppDelegate.pendingChatRouteTimestamp = Date() - NotificationCenter.default.post( - name: .openChatFromNotification, - object: route - ) - }, - onDismiss: { - bannerManager.dismiss() - } + onTap: navigateToChat, + onDismiss: { bannerManager.dismiss() }, + onExpand: navigateToChat, + onDragBegan: { bannerManager.cancelAutoDismiss() } ) - .transition(.move(edge: .top).combined(with: .opacity)) + .padding(.top, 8) + .transition(.move(edge: .top)) } Spacer() }