Фикс: peer profile — offline статус, запрет расширения letter-аватара, центрирование sticky title, адаптивный chevron
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -12,7 +12,8 @@ Telegram-iOS
|
||||
AGENTS.md
|
||||
voip.p12
|
||||
CertificateSigningRequest.certSigningRequest
|
||||
PhotosTransition
|
||||
TelegramDynamicIslandHeader
|
||||
TelegramHeader
|
||||
|
||||
# Xcode
|
||||
build/
|
||||
|
||||
@@ -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 */
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Release"
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
8
Rosetta/DesignSystem/Helpers/AnchorKey.swift
Normal file
8
Rosetta/DesignSystem/Helpers/AnchorKey.swift
Normal 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 }
|
||||
}
|
||||
}
|
||||
23
Rosetta/DesignSystem/Helpers/OffsetHelper.swift
Normal file
23
Rosetta/DesignSystem/Helpers/OffsetHelper.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - FullScreenImageViewer
|
||||
|
||||
/// Full-screen image viewer with pinch zoom, double-tap zoom, and swipe-to-dismiss.
|
||||
///
|
||||
/// Android parity: `ImageViewerScreen.kt` — zoom (1x–5x), double-tap (2.5x),
|
||||
/// vertical swipe dismiss, background fade, tap to toggle controls.
|
||||
struct FullScreenImageViewer: View {
|
||||
|
||||
let image: UIImage
|
||||
let onDismiss: () -> Void
|
||||
|
||||
/// Current zoom scale (1.0 = fit, up to maxScale).
|
||||
@State private var scale: CGFloat = 1.0
|
||||
@State private var lastScale: CGFloat = 1.0
|
||||
|
||||
/// Pan offset when zoomed.
|
||||
@State private var offset: CGSize = .zero
|
||||
@State private var lastOffset: CGSize = .zero
|
||||
|
||||
/// Vertical drag offset for dismiss gesture (only when not zoomed).
|
||||
@State private var dismissOffset: CGFloat = 0
|
||||
|
||||
/// Whether the UI controls (close button) are visible.
|
||||
@State private var showControls = true
|
||||
|
||||
private let minScale: CGFloat = 1.0
|
||||
private let maxScale: CGFloat = 5.0
|
||||
private let doubleTapScale: CGFloat = 2.5
|
||||
private let dismissThreshold: CGFloat = 150
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Background: fades as user drags to dismiss
|
||||
Color.black
|
||||
.opacity(backgroundOpacity)
|
||||
.ignoresSafeArea()
|
||||
|
||||
// Zoomable image (visual only — no gestures here)
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.scaleEffect(scale)
|
||||
.offset(x: offset.width, y: offset.height + dismissOffset)
|
||||
.allowsHitTesting(false)
|
||||
|
||||
// Close button (above gesture layer so it stays tappable)
|
||||
if showControls {
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button {
|
||||
onDismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(Color.white.opacity(0.2))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.padding(.trailing, 16)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
// Gestures on the full-screen ZStack — not on the Image.
|
||||
// scaleEffect is visual-only and doesn't expand the Image's hit-test area,
|
||||
// so when zoomed to 2.5x, taps outside the original frame were lost.
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(count: 2) {
|
||||
doubleTap()
|
||||
}
|
||||
.onTapGesture(count: 1) {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showControls.toggle()
|
||||
}
|
||||
}
|
||||
.simultaneousGesture(pinchGesture)
|
||||
.simultaneousGesture(dragGesture)
|
||||
}
|
||||
|
||||
// MARK: - Background Opacity
|
||||
|
||||
private var backgroundOpacity: Double {
|
||||
let progress = min(abs(dismissOffset) / 300, 1.0)
|
||||
return 1.0 - progress * 0.6
|
||||
}
|
||||
|
||||
// MARK: - Double Tap Zoom
|
||||
|
||||
private func doubleTap() {
|
||||
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
|
||||
if scale > 1.05 {
|
||||
scale = 1.0
|
||||
lastScale = 1.0
|
||||
offset = .zero
|
||||
lastOffset = .zero
|
||||
} else {
|
||||
scale = doubleTapScale
|
||||
lastScale = doubleTapScale
|
||||
offset = .zero
|
||||
lastOffset = .zero
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pinch Gesture
|
||||
|
||||
private var pinchGesture: some Gesture {
|
||||
MagnificationGesture()
|
||||
.onChanged { value in
|
||||
let newScale = lastScale * value
|
||||
scale = min(max(newScale, minScale * 0.5), maxScale)
|
||||
}
|
||||
.onEnded { _ in
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||
if scale < minScale { scale = minScale }
|
||||
lastScale = scale
|
||||
if scale <= 1.0 {
|
||||
offset = .zero
|
||||
lastOffset = .zero
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Drag Gesture
|
||||
|
||||
private var dragGesture: some Gesture {
|
||||
DragGesture()
|
||||
.onChanged { value in
|
||||
if scale > 1.05 {
|
||||
offset = CGSize(
|
||||
width: lastOffset.width + value.translation.width,
|
||||
height: lastOffset.height + value.translation.height
|
||||
)
|
||||
} else {
|
||||
dismissOffset = value.translation.height
|
||||
}
|
||||
}
|
||||
.onEnded { _ in
|
||||
if scale > 1.05 {
|
||||
lastOffset = offset
|
||||
} else {
|
||||
if abs(dismissOffset) > dismissThreshold {
|
||||
onDismiss()
|
||||
} else {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||
dismissOffset = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - FullScreenImageFromCache
|
||||
|
||||
/// Wrapper that loads an image from `AttachmentCache` by attachment ID and
|
||||
/// presents it in `FullScreenImageViewer`. Handles cache-miss gracefully.
|
||||
///
|
||||
/// Used as `fullScreenCover` content — the attachment ID is a stable value
|
||||
/// passed as a parameter, avoiding @State capture issues with UIImage.
|
||||
struct FullScreenImageFromCache: View {
|
||||
let attachmentId: String
|
||||
let onDismiss: () -> Void
|
||||
@State private var image: UIImage?
|
||||
@State private var isLoading = true
|
||||
|
||||
var body: some View {
|
||||
if let image {
|
||||
FullScreenImageViewer(image: image, onDismiss: onDismiss)
|
||||
} else {
|
||||
// Cache miss/loading state — show placeholder with close button.
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
if isLoading {
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
Text("Loading...")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
}
|
||||
} else {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "photo")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.white.opacity(0.3))
|
||||
Text("Image not available")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
}
|
||||
}
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button {
|
||||
onDismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(Color.white.opacity(0.2))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.padding(.trailing, 16)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.task(id: attachmentId) {
|
||||
if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachmentId) {
|
||||
image = cached
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
await ImageLoadLimiter.shared.acquire()
|
||||
let loaded = await Task.detached(priority: .userInitiated) {
|
||||
AttachmentCache.shared.loadImage(forAttachmentId: attachmentId)
|
||||
}.value
|
||||
await ImageLoadLimiter.shared.release()
|
||||
guard !Task.isCancelled else { return }
|
||||
image = loaded
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
169
Rosetta/Features/Chats/ChatDetail/Gallery/GalleryMenuPopup.swift
Normal file
169
Rosetta/Features/Chats/ChatDetail/Gallery/GalleryMenuPopup.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
266
Rosetta/Features/Chats/ChatDetail/Gallery/GalleryZoomPage.swift
Normal file
266
Rosetta/Features/Chats/ChatDetail/Gallery/GalleryZoomPage.swift
Normal 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?()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 1→0.9→1.1→1 over 0.21s
|
||||
let anim = CAKeyframeAnimation(keyPath: "transform.scale")
|
||||
anim.values = [1.0, 0.9, 1.1, 1.0]
|
||||
anim.keyTimes = [0, 0.26, 0.62, 1.0]
|
||||
anim.duration = 0.21
|
||||
anim.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
selectionCheckFill.add(anim, forKey: "checkBounce")
|
||||
selectionCheckmarkView.layer.add(anim, forKey: "checkBounce")
|
||||
} else if animated && !selected {
|
||||
// Telegram CheckNode: 2-stage scale 1→0.9→1 over 0.15s
|
||||
let anim = CAKeyframeAnimation(keyPath: "transform.scale")
|
||||
anim.values = [1.0, 0.9, 1.0]
|
||||
anim.keyTimes = [0, 0.53, 1.0]
|
||||
anim.duration = 0.15
|
||||
anim.timingFunction = CAMediaTimingFunction(name: .easeIn)
|
||||
selectionCheckContainer.layer.add(anim, forKey: "checkBounce")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2555,6 +2800,7 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
extension NativeMessageCell: UIGestureRecognizerDelegate {
|
||||
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true }
|
||||
if isInSelectionMode { return false } // No swipe in selection mode
|
||||
let velocity = pan.velocity(in: contentView)
|
||||
// Telegram: only left swipe (negative velocity.x), clear horizontal dominance
|
||||
return velocity.x < 0 && abs(velocity.x) > abs(velocity.y) * 2.0
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
213
Rosetta/Features/Chats/ChatDetail/PeerProfileHeaderView.swift
Normal file
213
Rosetta/Features/Chats/ChatDetail/PeerProfileHeaderView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
148
Rosetta/Features/Chats/ChatDetail/PeerProfileViewModel.swift
Normal file
148
Rosetta/Features/Chats/ChatDetail/PeerProfileViewModel.swift
Normal 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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
163
Rosetta/Features/Groups/EncryptionKeyView.swift
Normal file
163
Rosetta/Features/Groups/EncryptionKeyView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
181
Rosetta/Features/Groups/GroupEditView.swift
Normal file
181
Rosetta/Features/Groups/GroupEditView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
56
Rosetta/Features/Groups/SharedMediaSection.swift
Normal file
56
Rosetta/Features/Groups/SharedMediaSection.swift
Normal 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
|
||||
}
|
||||
}
|
||||
148
Rosetta/Features/Settings/DynamicIslandBlurView.swift
Normal file
148
Rosetta/Features/Settings/DynamicIslandBlurView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
101
Rosetta/Features/Settings/SettingsProfileHeader.swift
Normal file
101
Rosetta/Features/Settings/SettingsProfileHeader.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user