Фикс: peer profile — offline статус, запрет расширения letter-аватара, центрирование sticky title, адаптивный chevron

This commit is contained in:
2026-04-09 20:21:24 +05:00
parent f6fc34e7d9
commit adad5b8b83
42 changed files with 4947 additions and 1247 deletions

3
.gitignore vendored
View File

@@ -12,7 +12,8 @@ Telegram-iOS
AGENTS.md
voip.p12
CertificateSigningRequest.certSigningRequest
PhotosTransition
TelegramDynamicIslandHeader
TelegramHeader
# Xcode
build/

View File

@@ -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 */

View File

@@ -66,7 +66,7 @@
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Release"
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"

View File

@@ -19,6 +19,7 @@ final class GroupRepository {
/// (e.g., MessageRepository.lastDecryptedMessage decryptRecord tryReDecrypt groupKey).
private var keyCache: [String: String] = [:]
private var metadataCache: [String: GroupMetadata] = [:]
private var memberCache: [String: CachedGroupMembers] = [:]
private init() {}
@@ -26,6 +27,7 @@ final class GroupRepository {
func clearCaches() {
keyCache.removeAll()
metadataCache.removeAll()
memberCache.removeAll()
}
private func cacheKey(account: String, groupId: String) -> 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)

View File

@@ -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

View File

@@ -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 {

View File

@@ -23,6 +23,46 @@ struct SettingsCard<Content: View>: 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<Content: View>: 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<Content: View>: View {

View File

@@ -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))

View File

@@ -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

View File

@@ -0,0 +1,8 @@
import SwiftUI
struct AnchorKey: PreferenceKey {
static var defaultValue: [String: Anchor<CGRect>] = [:]
static func reduce(value: inout [String: Anchor<CGRect>], nextValue: () -> [String: Anchor<CGRect>]) {
value.merge(nextValue()) { $1 }
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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<String> = []
@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) {

View File

@@ -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))

View File

@@ -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 (1x5x), 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
}
}
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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()
}
}
}

View File

@@ -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)
}
}

View File

@@ -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?()
}
}
}

View File

@@ -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<AnyView> {
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()
}
}
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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<String> = []
private var failedAttachmentIds: Set<String> = []
// 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 10.91.11 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 10.91 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

View File

@@ -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<EmptyChatContent>?
private var emptyStateGuide: UILayoutGuide?
// MARK: - Multi-Select
private(set) var isSelectionMode = false
private(set) var selectedMessageIds: Set<String> = []
// 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<String>) {
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<String> = []
// 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)

View File

@@ -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,41 +52,24 @@ 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)
@@ -82,91 +81,110 @@ struct OpponentProfileView: View {
}
}
.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) {}
}
}
// 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
.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)
)
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)
}
}
.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) {
TelegramSectionCard {
VStack(spacing: 0) {
if !username.isEmpty {
copyRow(
label: "Username",
value: "@\(username)",
rawValue: username,
fieldId: "username",
helper: "Username for search user or send message."
)
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) {
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 }
@@ -177,57 +195,278 @@ struct OpponentProfileView: View {
}
}
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.system(size: 13))
.font(.system(size: 14))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
Text(copiedField == fieldId ? "Copied" : value)
.font(.system(size: 16))
.foregroundStyle(
copiedField == fieldId
? RosettaColors.online
: RosettaColors.Adaptive.text
)
.font(.system(size: 17))
.foregroundStyle(copiedField == fieldId ? RosettaColors.online : RosettaColors.primaryBlue)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer()
Image(systemName: copiedField == fieldId ? "checkmark" : "doc.on.doc")
.font(.system(size: 13))
.foregroundStyle(
copiedField == fieldId
? RosettaColors.online
: RosettaColors.Adaptive.textSecondary
)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background { glassCard() }
.padding(.vertical, 12)
}
.buttonStyle(.plain)
}
Text(helper)
.font(.system(size: 12))
.foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.7))
.padding(.horizontal, 8)
// 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
}
}
}
// 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 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 func glassCard() -> some View {
TelegramGlassRoundedRect(cornerRadius: 14)
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)
}
}
}
}
.padding(.horizontal, 16)
}
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)
Spacer()
}
.padding(.horizontal, 16).padding(.vertical, 8)
if index < viewModel.commonGroups.count - 1 {
Rectangle().fill(telegramSeparatorColor).frame(height: 1 / UIScreen.main.scale).padding(.leading, 68)
}
}
}
}
.padding(.horizontal, 16)
}
// MARK: - Actions
private func handleCall() {
let name = displayName
let user = username
Task { @MainActor in
_ = CallManager.shared.startOutgoingCall(toPublicKey: route.publicKey, title: name, username: user)
}
}
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<Content: View>: 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 }
}
}

View File

@@ -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)
}
}

View File

@@ -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 }
}

View File

@@ -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..<items.count {
let y = CGFloat(i) * Self.itemHeight
let rowRect = CGRect(x: 0, y: y, width: w, height: Self.itemHeight)
// Highlight fills row
highlightViews[i].frame = rowRect
// Icon left (Telegram: icon on LEFT side)
let iconX = Self.hPad
iconViews[i].frame = CGRect(x: iconX, y: y, width: Self.iconSize, height: Self.itemHeight)
// Title right of icon (Telegram: text follows icon)
let titleX = Self.hPad + Self.iconSize + 12
let titleW = w - titleX - Self.hPad
switch layoutStyle {
case .iconRight:
// Text LEFT, icon RIGHT (ContextActionNode)
let titleX = Self.irHPad
let titleW = w - Self.irHPad - Self.irIconSideInset - Self.irIconContainerW - Self.irHPad
titleLabels[i].frame = CGRect(x: titleX, y: y, width: titleW, height: Self.itemHeight)
let iconX = w - Self.irHPad - Self.irIconContainerW
iconViews[i].frame = CGRect(x: iconX, y: y, width: Self.irIconContainerW, height: Self.itemHeight)
case .iconLeft:
// Icon LEFT, text RIGHT (ContextControllerActionsStackNode)
iconViews[i].frame = CGRect(x: Self.ilIconSideInset, y: y, width: Self.ilIconContainerW, height: Self.itemHeight)
let titleX = Self.ilIconSideInset + Self.ilIconContainerW + Self.ilIconSpacing
let titleW = w - titleX - Self.ilSideInset
titleLabels[i].frame = CGRect(x: titleX, y: y, width: titleW, height: Self.itemHeight)
}
// Gesture receiver (topmost, transparent)
if let rv = subviews.first(where: { $0.tag == i && $0.gestureRecognizers?.isEmpty == false }) {
@@ -176,14 +216,14 @@ final class TelegramContextMenuCardView: UIView {
UIView.animate(withDuration: 0.08) { self.highlightViews[i].alpha = 1 }
case .ended:
let loc = g.location(in: g.view)
UIView.animate(withDuration: 0.12) { self.highlightViews[i].alpha = 0 }
UIView.animate(withDuration: 0.3) { self.highlightViews[i].alpha = 0 }
if g.view?.bounds.contains(loc) == true {
let handler = items[i].handler
onItemSelected?()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { handler() }
}
case .cancelled, .failed:
UIView.animate(withDuration: 0.12) { self.highlightViews[i].alpha = 0 }
UIView.animate(withDuration: 0.3) { self.highlightViews[i].alpha = 0 }
default: break
}
}

View File

@@ -73,6 +73,13 @@ enum TelegramContextMenuBuilder {
))
}
items.append(TelegramContextMenuItem(
title: "Select",
iconName: "checkmark.circle",
isDestructive: false,
handler: { actions.onEnterSelection(message) }
))
items.append(TelegramContextMenuItem(
title: "Delete",
iconName: "trash",
@@ -131,6 +138,7 @@ final class TelegramContextMenuController: UIView {
self.onDismiss = onDismiss
self.menuCard = TelegramContextMenuCardView(items: items)
super.init(frame: .zero)
overrideUserInterfaceStyle = .dark
buildHierarchy(snapshot: snapshot, bubblePath: bubblePath)
}

View File

@@ -1,142 +0,0 @@
import SwiftUI
import UIKit
// MARK: - ZoomableImagePage
/// Single page in the image gallery viewer.
/// Uses screen bounds for image sizing (Telegram parity) GeometryReader is unreliable
/// inside TabView `.page` style, especially during hero animation transitions.
/// GeometryReader is kept only for centering via `.position()`.
struct ZoomableImagePage: View {
let attachmentId: String
let onDismiss: () -> 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))
}
}
}

View File

@@ -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..<endIdx]
let hexPairs: [String] = substring.map { char in
let charCode = Int(char.asciiValue ?? 0)
let xored = charCode ^ 27
return String(format: "%02x", xored)
}
return hexPairs.joined(separator: " ")
}
return VStack(spacing: 6) {
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
if !row.isEmpty {
Text(row)
.font(.system(size: 13, weight: .medium, design: .monospaced))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
}
}
}
}
}
// MARK: - Reusable Pixel Grid
/// Desktop-parity pixel grid: 5 discrete Mantine blue colors, charCode % 5 mapping.
/// Reusable for both full-size detail page and thumbnail in card.
struct EncryptionKeyPixelGrid: View {
let keyString: String
let size: CGFloat
// Mantine blue palette: blue[1]blue[5]
private static let mantineBlues: [Color] = [
Color(red: 0.906, green: 0.961, blue: 1.0), // #E7F5FF blue[1]
Color(red: 0.816, green: 0.922, blue: 1.0), // #D0EBFF blue[2]
Color(red: 0.647, green: 0.847, blue: 1.0), // #A5D8FF blue[3]
Color(red: 0.455, green: 0.753, blue: 0.988), // #74C0FC blue[4]
Color(red: 0.302, green: 0.671, blue: 0.969), // #4DABF7 blue[5]
]
var body: some View {
let gridSize = 8
let totalChars = keyString.count
let charColors: [Color] = (0..<(gridSize * gridSize)).map { i in
guard totalChars > 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..<gridSize, id: \.self) { row in
HStack(spacing: 0) {
ForEach(0..<gridSize, id: \.self) { col in
let index = row * gridSize + col
Rectangle()
.fill(charColors[index])
}
}
}
}
.frame(width: size, height: size)
}
}

View File

@@ -0,0 +1,181 @@
import SwiftUI
// MARK: - GroupEditView
/// Telegram-parity group edit screen. All colors adaptive for light/dark.
/// Currently local-only no server packet for group metadata updates yet.
struct GroupEditView: View {
let groupDialogKey: String
let initialTitle: String
let initialDescription: String
@Environment(\.dismiss) private var dismiss
@State private var title: String
@State private var description: String
@FocusState private var focusedField: Field?
var onSave: ((String, String) -> 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()
}
}

View File

@@ -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)
}
Text(viewModel.groupTitle)
.font(.system(size: 22, weight: .bold))
.foregroundStyle(RosettaColors.Adaptive.text)
encryptionKeyCard
.padding(.top, 16)
Text("\(viewModel.members.count) members")
.font(.system(size: 15))
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(.vertical, 8)
.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 }
)
}
}
}
// 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 {
@ViewBuilder
var moreSheetButtons: some View {
Button("Copy Invite Link") {
if let invite = viewModel.inviteString ?? {
viewModel.generateInvite()
} label: {
Label("Generate Link", systemImage: "link")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(RosettaColors.figmaBlue)
return viewModel.inviteString
}() {
UIPasteboard.general.string = invite
}
}
Button("Leave Group", role: .destructive) {
showLeaveAlert = true
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.horizontal, 16)
}
// 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,9 +303,22 @@ private extension GroupInfoView {
let myKey = SessionManager.shared.currentPublicKey
let canKick = viewModel.isAdmin && member.id != myKey && !member.isAdmin
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)
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
}
.buttonStyle(.plain)
.contextMenu {
if canKick {
Button(role: .destructive) {
memberToKick = member
@@ -212,24 +329,438 @@ private extension GroupInfoView {
}
}
var leaveSection: some View {
// 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 {
showLeaveAlert = true
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
selectedTab = tab
}
} label: {
HStack {
Image(systemName: "rectangle.portrait.and.arrow.right")
Text("Leave Group")
}
.font(.system(size: 17, weight: .medium))
.foregroundStyle(.red)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
Text(tab.rawValue)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(selectedTab == tab ? tabActiveColor : tabInactiveColor)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background {
GlassCard(cornerRadius: 16) {
Color.clear.frame(height: 50)
if selectedTab == tab {
Capsule()
.fill(tabIndicatorFill)
.matchedGeometryEffect(id: "tab_indicator", in: tabNamespace)
}
}
}
.buttonStyle(.plain)
}
}
.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,<blob>)
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<Content: View>: 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
}
}
}

View File

@@ -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
// 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 memberKeys.enumerated() {
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))
}
}

View File

@@ -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())
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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))
}
}

View File

@@ -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 {
ZStack(alignment: .top) {
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)
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

View File

@@ -863,12 +863,7 @@ struct RosettaApp: App {
// Telegram parity: 8pt inset below safe area top (NotificationItemContainerNode.swift:98).
VStack(spacing: 0) {
if let banner = bannerManager.currentBanner {
InAppBannerView(
senderName: banner.senderName,
messagePreview: banner.messagePreview,
senderKey: banner.senderKey,
isGroup: banner.isGroup,
onTap: {
let navigateToChat = {
bannerManager.dismiss()
let route = ChatRoute(
publicKey: banner.senderKey,
@@ -882,12 +877,19 @@ struct RosettaApp: App {
name: .openChatFromNotification,
object: route
)
},
onDismiss: {
bannerManager.dismiss()
}
InAppBannerView(
senderName: banner.senderName,
messagePreview: banner.messagePreview,
senderKey: banner.senderKey,
isGroup: banner.isGroup,
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()
}