Уведомления в фоне, оптимизация FPS чата, release notes, read receipts паритет с Android
This commit is contained in:
@@ -19,6 +19,10 @@ struct AttachmentPanelView: View {
|
||||
|
||||
let onSend: ([PendingAttachment], String) -> Void
|
||||
let onSendAvatar: () -> Void
|
||||
/// When false, tapping avatar tab offers to set an avatar instead of sending.
|
||||
var hasAvatar: Bool = true
|
||||
/// Called when user has no avatar and taps the avatar tab — navigate to profile.
|
||||
var onSetAvatar: (() -> Void)?
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@@ -190,10 +194,7 @@ struct AttachmentPanelView: View {
|
||||
.fill(Color.white.opacity(0.08))
|
||||
.glassEffect(.regular, in: .circle)
|
||||
} else {
|
||||
Circle()
|
||||
.fill(.thinMaterial)
|
||||
.overlay { Circle().strokeBorder(Color.white.opacity(0.18), lineWidth: 0.5) }
|
||||
.shadow(color: .black.opacity(0.12), radius: 20, y: 8)
|
||||
TelegramGlassCircle()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,10 +325,7 @@ struct AttachmentPanelView: View {
|
||||
.fill(.clear)
|
||||
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: 21, style: .continuous))
|
||||
} else {
|
||||
let shape = RoundedRectangle(cornerRadius: 21, style: .continuous)
|
||||
shape.fill(.thinMaterial)
|
||||
.overlay { shape.strokeBorder(Color.white.opacity(0.18), lineWidth: 0.5) }
|
||||
.shadow(color: .black.opacity(0.12), radius: 20, y: 8)
|
||||
TelegramGlassRoundedRect(cornerRadius: 21)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,13 +356,7 @@ struct AttachmentPanelView: View {
|
||||
.fill(.clear)
|
||||
.glassEffect(.regular, in: .capsule)
|
||||
} else {
|
||||
// iOS < 26 — frosted glass material (matches RosettaTabBar)
|
||||
Capsule()
|
||||
.fill(.regularMaterial)
|
||||
.overlay(
|
||||
Capsule()
|
||||
.strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5)
|
||||
)
|
||||
TelegramGlassCapsule()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,9 +367,15 @@ struct AttachmentPanelView: View {
|
||||
|
||||
return Button {
|
||||
if tab == .avatar {
|
||||
// Avatar is an action tab — immediately sends avatar + dismisses
|
||||
onSendAvatar()
|
||||
dismiss()
|
||||
if hasAvatar {
|
||||
onSendAvatar()
|
||||
dismiss()
|
||||
} else {
|
||||
// No avatar set — offer to set one
|
||||
dismiss()
|
||||
onSetAvatar?()
|
||||
}
|
||||
return
|
||||
} else {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
selectedTab = tab
|
||||
@@ -395,13 +393,17 @@ struct AttachmentPanelView: View {
|
||||
.foregroundStyle(isSelected ? Color(hex: 0x008BFF) : .white)
|
||||
.frame(minWidth: 66, maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
// Selected tab: thin material pill (matches RosettaTabBar selection style)
|
||||
isSelected
|
||||
? AnyShapeStyle(.thinMaterial)
|
||||
: AnyShapeStyle(.clear),
|
||||
in: Capsule()
|
||||
)
|
||||
.background {
|
||||
if isSelected {
|
||||
if #available(iOS 26, *) {
|
||||
Capsule()
|
||||
.fill(.clear)
|
||||
.glassEffect(.regular, in: .capsule)
|
||||
} else {
|
||||
TelegramGlassCapsule()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
@@ -24,8 +24,9 @@ struct BubbleContextMenuOverlay: UIViewRepresentable {
|
||||
let previewShape: MessageBubbleShape
|
||||
let readStatusText: String?
|
||||
|
||||
/// Called when user single-taps the bubble (e.g., to open fullscreen image).
|
||||
var onTap: (() -> Void)?
|
||||
/// Called when user single-taps the bubble. Receives tap location in the overlay's
|
||||
/// coordinate space (for determining which sub-element was tapped, e.g., which photo in a collage).
|
||||
var onTap: ((CGPoint) -> Void)?
|
||||
|
||||
/// Height of the reply quote area at the top of the bubble (0 = no reply quote).
|
||||
/// Taps within this region call `onReplyQuoteTap` instead of `onTap`.
|
||||
@@ -41,6 +42,9 @@ struct BubbleContextMenuOverlay: UIViewRepresentable {
|
||||
view.addInteraction(interaction)
|
||||
|
||||
// Single tap recognizer — coexists with context menu's long press.
|
||||
// ALL taps go through this (overlay UIView blocks SwiftUI gestures below).
|
||||
// onTap handler in ChatDetailView routes to image viewer, file share,
|
||||
// or posts .triggerAttachmentDownload notification for downloads.
|
||||
let tap = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap(_:)))
|
||||
view.addGestureRecognizer(tap)
|
||||
|
||||
@@ -62,7 +66,7 @@ struct BubbleContextMenuOverlay: UIViewRepresentable {
|
||||
var actions: [BubbleContextAction]
|
||||
var previewShape: MessageBubbleShape
|
||||
var readStatusText: String?
|
||||
var onTap: (() -> Void)?
|
||||
var onTap: ((CGPoint) -> Void)?
|
||||
var replyQuoteHeight: CGFloat = 0
|
||||
var onReplyQuoteTap: (() -> Void)?
|
||||
private var snapshotView: UIImageView?
|
||||
@@ -85,7 +89,8 @@ struct BubbleContextMenuOverlay: UIViewRepresentable {
|
||||
return
|
||||
}
|
||||
}
|
||||
onTap?()
|
||||
let location = recognizer.location(in: recognizer.view)
|
||||
onTap?(location)
|
||||
}
|
||||
|
||||
func contextMenuInteraction(
|
||||
|
||||
@@ -80,14 +80,15 @@ struct ChatDetailView: View {
|
||||
@State private var firstUnreadMessageId: String?
|
||||
@State private var isSendingAvatar = false
|
||||
@State private var showAttachmentPanel = false
|
||||
@State private var showNoAvatarAlert = false
|
||||
@State private var pendingAttachments: [PendingAttachment] = []
|
||||
@State private var showOpponentProfile = false
|
||||
@State private var replyingToMessage: ChatMessage?
|
||||
@State private var showForwardPicker = false
|
||||
@State private var forwardingMessage: ChatMessage?
|
||||
@State private var messageToDelete: ChatMessage?
|
||||
/// Attachment ID for full-screen image viewer (nil = dismissed).
|
||||
@State private var fullScreenAttachmentId: String?
|
||||
/// State for the multi-photo gallery viewer (nil = dismissed).
|
||||
@State private var imageViewerState: ImageViewerState?
|
||||
/// ID of message to scroll to (set when tapping a reply quote).
|
||||
@State private var scrollToMessageId: String?
|
||||
/// ID of message currently highlighted after scroll-to-reply navigation.
|
||||
@@ -236,8 +237,6 @@ struct ChatDetailView: View {
|
||||
// does NOT mutate DialogRepository, so ForEach won't rebuild.
|
||||
MessageRepository.shared.setDialogActive(route.publicKey, isActive: true)
|
||||
clearDeliveredNotifications(for: route.publicKey)
|
||||
// Reset idle timer — user is actively viewing a chat.
|
||||
SessionManager.shared.recordUserInteraction()
|
||||
// Request user info (non-mutating, won't trigger list rebuild)
|
||||
requestUserInfoIfNeeded()
|
||||
// Delay DialogRepository mutations to let navigation transition complete.
|
||||
@@ -261,6 +260,13 @@ struct ChatDetailView: View {
|
||||
.onDisappear {
|
||||
isViewActive = false
|
||||
firstUnreadMessageId = nil
|
||||
// Android parity: mark all messages as read when leaving dialog.
|
||||
// Android's unmount callback does SQL UPDATE messages SET read = 1.
|
||||
// Don't re-send read receipt — it was already sent during the session.
|
||||
DialogRepository.shared.markAsRead(opponentKey: route.publicKey)
|
||||
MessageRepository.shared.markIncomingAsRead(
|
||||
opponentKey: route.publicKey, myPublicKey: currentPublicKey
|
||||
)
|
||||
MessageRepository.shared.setDialogActive(route.publicKey, isActive: false)
|
||||
// Desktop parity: save draft text on chat close.
|
||||
DraftManager.shared.saveDraft(for: route.publicKey, text: messageText)
|
||||
@@ -281,13 +287,15 @@ struct ChatDetailView: View {
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: Binding(
|
||||
get: { fullScreenAttachmentId != nil },
|
||||
set: { if !$0 { fullScreenAttachmentId = nil } }
|
||||
get: { imageViewerState != nil },
|
||||
set: { if !$0 { imageViewerState = nil } }
|
||||
)) {
|
||||
FullScreenImageFromCache(
|
||||
attachmentId: fullScreenAttachmentId ?? "",
|
||||
onDismiss: { fullScreenAttachmentId = nil }
|
||||
)
|
||||
if let state = imageViewerState {
|
||||
ImageGalleryViewer(
|
||||
state: state,
|
||||
onDismiss: { imageViewerState = nil }
|
||||
)
|
||||
}
|
||||
}
|
||||
.alert("Delete Message", isPresented: Binding(
|
||||
get: { messageToDelete != nil },
|
||||
@@ -305,6 +313,30 @@ struct ChatDetailView: View {
|
||||
} message: {
|
||||
Text("Are you sure you want to delete this message? This action cannot be undone.")
|
||||
}
|
||||
.alert("No Avatar", isPresented: $showNoAvatarAlert) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Set a profile photo in Settings to share it with contacts.")
|
||||
}
|
||||
.sheet(isPresented: $showAttachmentPanel) {
|
||||
AttachmentPanelView(
|
||||
onSend: { attachments, caption in
|
||||
// Pre-fill caption as message text (sent alongside attachments)
|
||||
let trimmedCaption = caption.trimmingCharacters(in: .whitespaces)
|
||||
if !trimmedCaption.isEmpty {
|
||||
messageText = trimmedCaption
|
||||
}
|
||||
handleAttachmentsSend(attachments)
|
||||
},
|
||||
onSendAvatar: {
|
||||
sendAvatarToChat()
|
||||
},
|
||||
hasAvatar: AvatarRepository.shared.loadAvatar(publicKey: currentPublicKey) != nil,
|
||||
onSetAvatar: {
|
||||
showNoAvatarAlert = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -673,9 +705,10 @@ private extension ChatDetailView {
|
||||
.onAppear { withAnimation(.easeOut(duration: 0.2)) { isAtBottom = true } }
|
||||
.onDisappear { withAnimation(.easeOut(duration: 0.2)) { isAtBottom = false } }
|
||||
|
||||
// PERF: use message.id as ForEach identity (stable).
|
||||
// Integer indices shift on every insert, forcing full diff.
|
||||
ForEach(Array(messages.enumerated()).reversed(), id: \.element.id) { index, message in
|
||||
// PERF: iterate reversed messages directly, avoid Array(enumerated()) allocation.
|
||||
// Use message.id identity (stable) — integer indices shift on insert.
|
||||
ForEach(messages.reversed()) { message in
|
||||
let index = messageIndex(for: message.id)
|
||||
let position = bubblePosition(for: index)
|
||||
messageRow(
|
||||
message,
|
||||
@@ -717,10 +750,14 @@ private extension ChatDetailView {
|
||||
}
|
||||
shouldScrollOnNextMessage = false
|
||||
}
|
||||
}
|
||||
.onChange(of: isInputFocused) { _, focused in
|
||||
guard focused else { return }
|
||||
SessionManager.shared.recordUserInteraction()
|
||||
// Android parity: markVisibleMessagesAsRead — when new incoming
|
||||
// messages appear while chat is open, mark as read and send receipt.
|
||||
// Safe to call repeatedly: markAsRead guards unreadCount > 0,
|
||||
// sendReadReceipt deduplicates by timestamp.
|
||||
if isViewActive && !lastIsOutgoing
|
||||
&& !route.isSavedMessages && !route.isSystemAccount {
|
||||
markDialogAsRead()
|
||||
}
|
||||
}
|
||||
// Scroll-to-reply: navigate to the original message and highlight it briefly.
|
||||
.onChange(of: scrollToMessageId) { _, targetId in
|
||||
@@ -828,49 +865,206 @@ private extension ChatDetailView {
|
||||
let messageText = message.text.isEmpty ? " " : message.text
|
||||
let replyAttachment = message.attachments.first(where: { $0.type == .messages })
|
||||
let replyData = replyAttachment.flatMap { parseReplyBlob($0.blob) }?.first
|
||||
// Forward detection: text is empty/space, but has a MESSAGES attachment with data.
|
||||
let isForward = message.text.trimmingCharacters(in: .whitespaces).isEmpty && replyData != nil
|
||||
|
||||
if isForward, let reply = replyData {
|
||||
forwardedMessageBubble(message: message, reply: reply, outgoing: outgoing, hasTail: hasTail, maxBubbleWidth: maxBubbleWidth, position: position)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Reply quote (if present, not a forward)
|
||||
if let reply = replyData {
|
||||
replyQuoteView(reply: reply, outgoing: outgoing)
|
||||
}
|
||||
|
||||
// Telegram-style compact bubble: inline time+status at bottom-trailing.
|
||||
Text(parsedMarkdown(messageText))
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
.tracking(-0.43)
|
||||
.foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineSpacing(0)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.leading, 11)
|
||||
.padding(.trailing, outgoing ? 64 : 48)
|
||||
.padding(.vertical, 5)
|
||||
}
|
||||
.frame(minWidth: outgoing ? 86 : 66, alignment: .leading)
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
timestampOverlay(message: message, outgoing: outgoing)
|
||||
}
|
||||
.padding(.trailing, outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
|
||||
.padding(.leading, !outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
|
||||
.background { bubbleBackground(outgoing: outgoing, position: position) }
|
||||
.overlay {
|
||||
BubbleContextMenuOverlay(
|
||||
actions: bubbleActions(for: message),
|
||||
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
|
||||
readStatusText: contextMenuReadStatus(for: message),
|
||||
replyQuoteHeight: replyData != nil ? 46 : 0,
|
||||
onReplyQuoteTap: replyData.map { reply in
|
||||
{ [reply] in self.scrollToMessageId = reply.message_id }
|
||||
}
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Forwarded Message Bubble (Telegram-style)
|
||||
|
||||
/// Renders a forwarded message with "Forwarded from" header, small avatar, sender name,
|
||||
/// optional image/file previews, and the forwarded text as the main bubble content.
|
||||
/// Android parity: `ForwardedMessagesBubble` + `ForwardedImagePreview` in ChatDetailComponents.kt.
|
||||
@ViewBuilder
|
||||
private func forwardedMessageBubble(
|
||||
message: ChatMessage,
|
||||
reply: ReplyMessageData,
|
||||
outgoing: Bool,
|
||||
hasTail: Bool,
|
||||
maxBubbleWidth: CGFloat,
|
||||
position: BubblePosition
|
||||
) -> some View {
|
||||
let senderName = senderDisplayName(for: reply.publicKey)
|
||||
let senderInitials = RosettaColors.initials(name: senderName, publicKey: reply.publicKey)
|
||||
let senderColorIndex = RosettaColors.avatarColorIndex(for: senderName, publicKey: reply.publicKey)
|
||||
let senderAvatar = AvatarRepository.shared.loadAvatar(publicKey: reply.publicKey)
|
||||
|
||||
// Categorize forwarded attachments (inside the ReplyMessageData, NOT on message itself).
|
||||
let imageAttachments = reply.attachments.filter { $0.type == 0 }
|
||||
let fileAttachments = reply.attachments.filter { $0.type == 2 }
|
||||
let hasVisualAttachments = !imageAttachments.isEmpty || !fileAttachments.isEmpty
|
||||
|
||||
// Text: show as caption below visual attachments, or as main content if no attachments.
|
||||
let hasCaption = !reply.message.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
|
||||
// Fallback label when no visual attachments and no text.
|
||||
let fallbackText: String = {
|
||||
if hasCaption { return reply.message }
|
||||
if !imageAttachments.isEmpty { return hasVisualAttachments ? "" : "Photo" }
|
||||
if let file = fileAttachments.first { return file.id.isEmpty ? "File" : file.id }
|
||||
if reply.attachments.contains(where: { $0.type == 3 }) { return "Avatar" }
|
||||
return " "
|
||||
}()
|
||||
|
||||
let imageContentWidth = maxBubbleWidth - 22 - (outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0) - (!outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Reply/forward quote (if present)
|
||||
if let reply = replyData {
|
||||
replyQuoteView(reply: reply, outgoing: outgoing)
|
||||
// "Forwarded from" label
|
||||
Text("Forwarded from")
|
||||
.font(.system(size: 13, weight: .regular))
|
||||
.foregroundStyle(outgoing ? Color.white.opacity(0.5) : RosettaColors.Adaptive.textSecondary)
|
||||
.padding(.leading, 11)
|
||||
.padding(.top, 6)
|
||||
|
||||
// Avatar + sender name
|
||||
HStack(spacing: 6) {
|
||||
AvatarView(
|
||||
initials: senderInitials,
|
||||
colorIndex: senderColorIndex,
|
||||
size: 20,
|
||||
image: senderAvatar
|
||||
)
|
||||
Text(senderName)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(outgoing ? Color.white : RosettaColors.figmaBlue)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.leading, 11)
|
||||
.padding(.top, 3)
|
||||
|
||||
// Forwarded image attachments — blurhash thumbnails (Android parity: ForwardedImagePreview).
|
||||
ForEach(Array(imageAttachments.enumerated()), id: \.element.id) { _, att in
|
||||
forwardedImagePreview(attachment: att, width: imageContentWidth, outgoing: outgoing)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
// Telegram-style compact bubble: inline time+status at bottom-trailing.
|
||||
// Right padding reserves space for the timestamp overlay (64pt outgoing, 48pt incoming).
|
||||
Text(parsedMarkdown(messageText))
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
.tracking(-0.43)
|
||||
.foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineSpacing(0)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.leading, 11)
|
||||
.padding(.trailing, outgoing ? 64 : 48)
|
||||
.padding(.vertical, 5)
|
||||
// Forwarded file attachments.
|
||||
ForEach(Array(fileAttachments.enumerated()), id: \.element.id) { _, att in
|
||||
forwardedFilePreview(attachment: att, outgoing: outgoing)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
// Caption text (if original message had text) or fallback label.
|
||||
if hasCaption {
|
||||
Text(parsedMarkdown(reply.message))
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
.tracking(-0.43)
|
||||
.foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineSpacing(0)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.leading, 11)
|
||||
.padding(.trailing, outgoing ? 64 : 48)
|
||||
.padding(.top, 3)
|
||||
.padding(.bottom, 5)
|
||||
} else if !hasVisualAttachments {
|
||||
// No attachments and no text — show fallback.
|
||||
Text(fallbackText)
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
.tracking(-0.43)
|
||||
.foregroundStyle(outgoing ? Color.white.opacity(0.7) : RosettaColors.Adaptive.textSecondary)
|
||||
.padding(.leading, 11)
|
||||
.padding(.trailing, outgoing ? 64 : 48)
|
||||
.padding(.top, 3)
|
||||
.padding(.bottom, 5)
|
||||
} else {
|
||||
// Visual attachments shown but no caption — just add bottom padding for timestamp.
|
||||
Spacer().frame(height: 5)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: outgoing ? 86 : 66, alignment: .leading)
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
timestampOverlay(message: message, outgoing: outgoing)
|
||||
}
|
||||
// Tail protrusion space: the unified shape draws the tail in this padding area
|
||||
.padding(.trailing, outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
|
||||
.padding(.leading, !outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
|
||||
// Single unified background: body + tail drawn in one fill (no seam)
|
||||
.background { bubbleBackground(outgoing: outgoing, position: position) }
|
||||
.overlay {
|
||||
BubbleContextMenuOverlay(
|
||||
actions: bubbleActions(for: message),
|
||||
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
|
||||
readStatusText: contextMenuReadStatus(for: message),
|
||||
replyQuoteHeight: replyData != nil ? 46 : 0,
|
||||
onReplyQuoteTap: replyData.map { reply in
|
||||
{ [reply] in self.scrollToMessageId = reply.message_id }
|
||||
}
|
||||
readStatusText: contextMenuReadStatus(for: message)
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading)
|
||||
}
|
||||
|
||||
/// Wrapper that delegates to `ForwardedImagePreviewCell` — a proper View struct with
|
||||
/// `@State` so the image updates when `AttachmentCache` is populated by `MessageImageView`.
|
||||
@ViewBuilder
|
||||
private func forwardedImagePreview(attachment: ReplyAttachmentData, width: CGFloat, outgoing: Bool) -> some View {
|
||||
ForwardedImagePreviewCell(
|
||||
attachment: attachment,
|
||||
width: width,
|
||||
outgoing: outgoing,
|
||||
onTapCachedImage: { openImageViewer(attachmentId: attachment.id) }
|
||||
)
|
||||
}
|
||||
|
||||
/// File attachment preview inside a forwarded message bubble.
|
||||
@ViewBuilder
|
||||
private func forwardedFilePreview(attachment: ReplyAttachmentData, outgoing: Bool) -> some View {
|
||||
let filename = attachment.id.isEmpty ? "File" : attachment.id
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "doc.fill")
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(outgoing ? Color.white.opacity(0.7) : RosettaColors.figmaBlue)
|
||||
Text(filename)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(outgoing ? Color.white.opacity(0.08) : Color.white.opacity(0.06))
|
||||
)
|
||||
}
|
||||
|
||||
/// PERF: static cache for decoded reply blobs — avoids JSON decode on every re-render.
|
||||
@MainActor private static var replyBlobCache: [String: [ReplyMessageData]] = [:]
|
||||
|
||||
@@ -880,7 +1074,10 @@ private extension ChatDetailView {
|
||||
if let cached = Self.replyBlobCache[blob] { return cached }
|
||||
guard let data = blob.data(using: .utf8) else { return nil }
|
||||
guard let result = try? JSONDecoder().decode([ReplyMessageData].self, from: data) else { return nil }
|
||||
if Self.replyBlobCache.count > 200 { Self.replyBlobCache.removeAll(keepingCapacity: true) }
|
||||
if Self.replyBlobCache.count > 300 {
|
||||
let keysToRemove = Array(Self.replyBlobCache.keys.prefix(150))
|
||||
for key in keysToRemove { Self.replyBlobCache.removeValue(forKey: key) }
|
||||
}
|
||||
Self.replyBlobCache[blob] = result
|
||||
return result
|
||||
}
|
||||
@@ -910,15 +1107,11 @@ private extension ChatDetailView {
|
||||
.frame(width: 3)
|
||||
.padding(.vertical, 4)
|
||||
|
||||
// Optional image thumbnail for media replies (32×32)
|
||||
// PERF: uses static cache — BlurHash decode is expensive (DCT transform).
|
||||
if let hash = blurHash,
|
||||
let image = Self.cachedBlurHash(hash, width: 32, height: 32) {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 32, height: 32)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
// Optional image thumbnail for media replies (32×32).
|
||||
// Uses ReplyQuoteThumbnail struct with @State + .task to check AttachmentCache
|
||||
// first (shows actual image), falling back to blurhash if not cached.
|
||||
if let att = imageAttachment {
|
||||
ReplyQuoteThumbnail(attachment: att, blurHash: blurHash)
|
||||
.padding(.leading, 6)
|
||||
}
|
||||
|
||||
@@ -948,9 +1141,14 @@ private extension ChatDetailView {
|
||||
.padding(.bottom, 0)
|
||||
}
|
||||
|
||||
/// PERF: static cache for sender display names — avoids DialogRepository read per cell render.
|
||||
/// DialogRepository is @Observable; reading `.dialogs[key]` in the body path creates observation
|
||||
/// on the entire dictionary, causing re-render cascades on any dialog mutation.
|
||||
@MainActor private static var senderNameCache: [String: String] = [:]
|
||||
|
||||
/// Resolves a public key to a display name for reply/forward quotes.
|
||||
/// PERF: avoids reading DialogRepository.shared.dialogs (Observable) in the
|
||||
/// body path — uses route data instead. Only the current opponent is resolved.
|
||||
/// Checks: current user → "You", current opponent → route.title, any known dialog → title (cached).
|
||||
/// Falls back to truncated public key if unknown.
|
||||
private func senderDisplayName(for publicKey: String) -> String {
|
||||
if publicKey == currentPublicKey {
|
||||
return "You"
|
||||
@@ -959,7 +1157,33 @@ private extension ChatDetailView {
|
||||
if publicKey == route.publicKey {
|
||||
return route.title.isEmpty ? String(publicKey.prefix(8)) + "…" : route.title
|
||||
}
|
||||
return String(publicKey.prefix(8)) + "…"
|
||||
// PERF: cached lookup — avoids creating @Observable tracking on DialogRepository.dialogs
|
||||
// in the per-cell render path. Cache is populated once per contact, valid for session.
|
||||
if let cached = Self.senderNameCache[publicKey] {
|
||||
return cached
|
||||
}
|
||||
if let dialog = DialogRepository.shared.dialogs[publicKey],
|
||||
!dialog.opponentTitle.isEmpty {
|
||||
Self.senderNameCache[publicKey] = dialog.opponentTitle
|
||||
return dialog.opponentTitle
|
||||
}
|
||||
let fallback = String(publicKey.prefix(8)) + "…"
|
||||
Self.senderNameCache[publicKey] = fallback
|
||||
return fallback
|
||||
}
|
||||
|
||||
/// PERF: single-pass partition of attachments into image vs non-image.
|
||||
/// Avoids 3 separate .filter() calls per cell in @ViewBuilder context.
|
||||
private static func partitionAttachments(
|
||||
_ attachments: [MessageAttachment]
|
||||
) -> (images: [MessageAttachment], others: [MessageAttachment]) {
|
||||
var images: [MessageAttachment] = []
|
||||
var others: [MessageAttachment] = []
|
||||
for att in attachments {
|
||||
if att.type == .image { images.append(att) }
|
||||
else { others.append(att) }
|
||||
}
|
||||
return (images, others)
|
||||
}
|
||||
|
||||
/// Attachment message bubble: images/files with optional text caption.
|
||||
@@ -978,8 +1202,10 @@ private extension ChatDetailView {
|
||||
position: BubblePosition
|
||||
) -> some View {
|
||||
let hasCaption = !message.text.trimmingCharacters(in: .whitespaces).isEmpty && message.text != " "
|
||||
let imageAttachments = attachments.filter { $0.type == .image }
|
||||
let otherAttachments = attachments.filter { $0.type != .image }
|
||||
// PERF: single-pass partition instead of 3 separate .filter() calls per cell.
|
||||
let partitioned = Self.partitionAttachments(attachments)
|
||||
let imageAttachments = partitioned.images
|
||||
let otherAttachments = partitioned.others
|
||||
let isImageOnly = !imageAttachments.isEmpty && otherAttachments.isEmpty && !hasCaption
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
@@ -1051,10 +1277,24 @@ private extension ChatDetailView {
|
||||
actions: bubbleActions(for: message),
|
||||
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
|
||||
readStatusText: contextMenuReadStatus(for: message),
|
||||
onTap: !imageAttachments.isEmpty ? {
|
||||
// Open the first image attachment in fullscreen viewer
|
||||
if let firstImage = imageAttachments.first {
|
||||
fullScreenAttachmentId = firstImage.id
|
||||
onTap: !attachments.isEmpty ? { tapLocation in
|
||||
// All taps go through the overlay (UIView blocks SwiftUI below).
|
||||
// Route to the correct handler based on what was tapped.
|
||||
if !imageAttachments.isEmpty {
|
||||
let tappedId = imageAttachments.count == 1
|
||||
? imageAttachments[0].id
|
||||
: collageAttachmentId(at: tapLocation, attachments: imageAttachments, maxWidth: maxBubbleWidth)
|
||||
if AttachmentCache.shared.loadImage(forAttachmentId: tappedId) != nil {
|
||||
openImageViewer(attachmentId: tappedId)
|
||||
} else {
|
||||
// Image not cached — trigger download via notification.
|
||||
NotificationCenter.default.post(name: .triggerAttachmentDownload, object: tappedId)
|
||||
}
|
||||
} else {
|
||||
// No images — tap is on file/avatar area.
|
||||
for att in otherAttachments {
|
||||
NotificationCenter.default.post(name: .triggerAttachmentDownload, object: att.id)
|
||||
}
|
||||
}
|
||||
} : nil
|
||||
)
|
||||
@@ -1117,11 +1357,14 @@ private extension ChatDetailView {
|
||||
@MainActor private static var blurHashCache: [String: UIImage] = [:]
|
||||
|
||||
@MainActor
|
||||
private static func cachedBlurHash(_ hash: String, width: Int, height: Int) -> UIImage? {
|
||||
static func cachedBlurHash(_ hash: String, width: Int, height: Int) -> UIImage? {
|
||||
let key = "\(hash)_\(width)x\(height)"
|
||||
if let cached = blurHashCache[key] { return cached }
|
||||
guard let image = UIImage.fromBlurHash(hash, width: width, height: height) else { return nil }
|
||||
if blurHashCache.count > 100 { blurHashCache.removeAll(keepingCapacity: true) }
|
||||
if blurHashCache.count > 300 {
|
||||
let keysToRemove = Array(blurHashCache.keys.prefix(150))
|
||||
for key in keysToRemove { blurHashCache.removeValue(forKey: key) }
|
||||
}
|
||||
blurHashCache[key] = image
|
||||
return image
|
||||
}
|
||||
@@ -1155,8 +1398,10 @@ private extension ChatDetailView {
|
||||
} else {
|
||||
result = AttributedString(withEmoji)
|
||||
}
|
||||
if Self.markdownCache.count > 200 {
|
||||
Self.markdownCache.removeAll(keepingCapacity: true)
|
||||
// PERF: evict oldest half instead of clearing all — preserves hot entries during scroll.
|
||||
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
|
||||
return result
|
||||
@@ -1180,11 +1425,6 @@ private extension ChatDetailView {
|
||||
|
||||
var composer: some View {
|
||||
VStack(spacing: 6) {
|
||||
// Reply preview bar (Telegram-style)
|
||||
if let replyMessage = replyingToMessage {
|
||||
replyBar(for: replyMessage)
|
||||
}
|
||||
|
||||
// Attachment preview strip — shows selected images/files before send
|
||||
if !pendingAttachments.isEmpty {
|
||||
AttachmentPreviewStrip(pendingAttachments: $pendingAttachments)
|
||||
@@ -1214,79 +1454,71 @@ private extension ChatDetailView {
|
||||
}
|
||||
.accessibilityLabel("Attach")
|
||||
.buttonStyle(ChatDetailGlassPressButtonStyle())
|
||||
.sheet(isPresented: $showAttachmentPanel) {
|
||||
AttachmentPanelView(
|
||||
onSend: { attachments, caption in
|
||||
// Pre-fill caption as message text (sent alongside attachments)
|
||||
let trimmedCaption = caption.trimmingCharacters(in: .whitespaces)
|
||||
if !trimmedCaption.isEmpty {
|
||||
messageText = trimmedCaption
|
||||
}
|
||||
handleAttachmentsSend(attachments)
|
||||
},
|
||||
onSendAvatar: {
|
||||
sendAvatarToChat()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
HStack(alignment: .bottom, spacing: 0) {
|
||||
ChatTextInput(
|
||||
text: $messageText,
|
||||
isFocused: $isInputFocused,
|
||||
onKeyboardHeightChange: { height in
|
||||
KeyboardTracker.shared.updateFromKVO(keyboardHeight: height)
|
||||
},
|
||||
onUserTextInsertion: handleComposerUserTyping,
|
||||
textColor: UIColor(RosettaColors.Adaptive.text),
|
||||
placeholderColor: UIColor(RosettaColors.Adaptive.textSecondary.opacity(0.5))
|
||||
)
|
||||
.padding(.leading, 6)
|
||||
.frame(maxWidth: .infinity, minHeight: 36, alignment: .bottomLeading)
|
||||
|
||||
HStack(alignment: .center, spacing: 0) {
|
||||
Button { } label: {
|
||||
TelegramVectorIcon(
|
||||
pathData: TelegramIconPath.emojiMoon,
|
||||
viewBox: CGSize(width: 19, height: 19),
|
||||
color: RosettaColors.Adaptive.textSecondary
|
||||
)
|
||||
.frame(width: 19, height: 19)
|
||||
.frame(width: 20, height: 36)
|
||||
}
|
||||
.accessibilityLabel("Quick actions")
|
||||
.buttonStyle(ChatDetailGlassPressButtonStyle())
|
||||
VStack(spacing: 0) {
|
||||
// Reply preview bar — inside the glass container
|
||||
if let replyMessage = replyingToMessage {
|
||||
replyBar(for: replyMessage)
|
||||
}
|
||||
.padding(.trailing, 8 + (sendButtonWidth * sendButtonProgress))
|
||||
.frame(height: 36, alignment: .center)
|
||||
.overlay(alignment: .trailing) {
|
||||
Button(action: sendCurrentMessage) {
|
||||
TelegramVectorIcon(
|
||||
pathData: TelegramIconPath.sendPlane,
|
||||
viewBox: CGSize(width: 22, height: 19),
|
||||
color: .white
|
||||
)
|
||||
.opacity(0.42 + (0.58 * sendButtonProgress))
|
||||
.scaleEffect(0.72 + (0.28 * sendButtonProgress))
|
||||
.frame(width: 22, height: 19)
|
||||
.frame(width: sendButtonWidth, height: sendButtonHeight)
|
||||
.background { Capsule().fill(Color(hex: 0x008BFF)) }
|
||||
}
|
||||
.accessibilityLabel("Send")
|
||||
.disabled(!canSend)
|
||||
.buttonStyle(ChatDetailGlassPressButtonStyle())
|
||||
.allowsHitTesting(shouldShowSendButton)
|
||||
.opacity(Double(sendButtonProgress))
|
||||
.scaleEffect(0.74 + (0.26 * sendButtonProgress), anchor: .trailing)
|
||||
.blur(radius: (1 - sendButtonProgress) * 2.1)
|
||||
.mask(
|
||||
Capsule()
|
||||
.frame(
|
||||
width: max(0.001, sendButtonWidth * sendButtonProgress),
|
||||
height: max(0.001, sendButtonHeight * sendButtonProgress)
|
||||
)
|
||||
.frame(width: sendButtonWidth, height: sendButtonHeight, alignment: .trailing)
|
||||
|
||||
HStack(alignment: .bottom, spacing: 0) {
|
||||
ChatTextInput(
|
||||
text: $messageText,
|
||||
isFocused: $isInputFocused,
|
||||
onKeyboardHeightChange: { height in
|
||||
KeyboardTracker.shared.updateFromKVO(keyboardHeight: height)
|
||||
},
|
||||
onUserTextInsertion: handleComposerUserTyping,
|
||||
textColor: UIColor(RosettaColors.Adaptive.text),
|
||||
placeholderColor: UIColor(RosettaColors.Adaptive.textSecondary.opacity(0.5))
|
||||
)
|
||||
.padding(.leading, 6)
|
||||
.frame(maxWidth: .infinity, minHeight: 36, alignment: .bottomLeading)
|
||||
|
||||
HStack(alignment: .center, spacing: 0) {
|
||||
Button { } label: {
|
||||
TelegramVectorIcon(
|
||||
pathData: TelegramIconPath.emojiMoon,
|
||||
viewBox: CGSize(width: 19, height: 19),
|
||||
color: RosettaColors.Adaptive.textSecondary
|
||||
)
|
||||
.frame(width: 19, height: 19)
|
||||
.frame(width: 20, height: 36)
|
||||
}
|
||||
.accessibilityLabel("Quick actions")
|
||||
.buttonStyle(ChatDetailGlassPressButtonStyle())
|
||||
}
|
||||
.padding(.trailing, 8 + (sendButtonWidth * sendButtonProgress))
|
||||
.frame(height: 36, alignment: .center)
|
||||
.overlay(alignment: .trailing) {
|
||||
Button(action: sendCurrentMessage) {
|
||||
TelegramVectorIcon(
|
||||
pathData: TelegramIconPath.sendPlane,
|
||||
viewBox: CGSize(width: 22, height: 19),
|
||||
color: .white
|
||||
)
|
||||
.opacity(0.42 + (0.58 * sendButtonProgress))
|
||||
.scaleEffect(0.72 + (0.28 * sendButtonProgress))
|
||||
.frame(width: 22, height: 19)
|
||||
.frame(width: sendButtonWidth, height: sendButtonHeight)
|
||||
.background { Capsule().fill(Color(hex: 0x008BFF)) }
|
||||
}
|
||||
.accessibilityLabel("Send")
|
||||
.disabled(!canSend)
|
||||
.buttonStyle(ChatDetailGlassPressButtonStyle())
|
||||
.allowsHitTesting(shouldShowSendButton)
|
||||
.opacity(Double(sendButtonProgress))
|
||||
.scaleEffect(0.74 + (0.26 * sendButtonProgress), anchor: .trailing)
|
||||
.blur(radius: (1 - sendButtonProgress) * 2.1)
|
||||
.mask(
|
||||
Capsule()
|
||||
.frame(
|
||||
width: max(0.001, sendButtonWidth * sendButtonProgress),
|
||||
height: max(0.001, sendButtonHeight * sendButtonProgress)
|
||||
)
|
||||
.frame(width: sendButtonWidth, height: sendButtonHeight, alignment: .trailing)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(3)
|
||||
@@ -1328,6 +1560,26 @@ private extension ChatDetailView {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Message Index Lookup
|
||||
|
||||
/// PERF: O(1) index lookup via cached dictionary. Rebuilt lazily when messages change.
|
||||
/// Avoids O(n) `firstIndex(where:)` per cell in reversed ForEach.
|
||||
@MainActor private static var messageIndexCache: [String: Int] = [:]
|
||||
@MainActor private static var messageIndexCacheKey: String = ""
|
||||
|
||||
private func messageIndex(for messageId: String) -> Int {
|
||||
// Rebuild cache if messages array changed (first+last+count fingerprint).
|
||||
let cacheKey = "\(messages.count)_\(messages.first?.id ?? "")_\(messages.last?.id ?? "")"
|
||||
if Self.messageIndexCacheKey != cacheKey {
|
||||
Self.messageIndexCache.removeAll(keepingCapacity: true)
|
||||
for (i, msg) in messages.enumerated() {
|
||||
Self.messageIndexCache[msg.id] = i
|
||||
}
|
||||
Self.messageIndexCacheKey = cacheKey
|
||||
}
|
||||
return Self.messageIndexCache[messageId] ?? 0
|
||||
}
|
||||
|
||||
// MARK: - Bubble Position (Figma: Single / Top / Mid / Bottom)
|
||||
|
||||
/// Determines bubble position within a group of consecutive same-sender plain-text messages.
|
||||
@@ -1391,21 +1643,14 @@ private extension ChatDetailView {
|
||||
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: radius, style: .continuous))
|
||||
}
|
||||
} else {
|
||||
// iOS < 26: frosted glass with stroke + shadow (Figma spec)
|
||||
// iOS < 26: Telegram glass (CABackdropLayer + blur 2.0 + dark foreground)
|
||||
switch shape {
|
||||
case .capsule:
|
||||
Capsule().fill(.thinMaterial)
|
||||
.overlay { Capsule().strokeBorder(strokeColor.opacity(strokeOpacity), lineWidth: 0.5) }
|
||||
.shadow(color: .black.opacity(0.12), radius: 20, y: 8)
|
||||
TelegramGlassCapsule()
|
||||
case .circle:
|
||||
Circle().fill(.thinMaterial)
|
||||
.overlay { Circle().strokeBorder(strokeColor.opacity(strokeOpacity), lineWidth: 0.5) }
|
||||
.shadow(color: .black.opacity(0.12), radius: 20, y: 8)
|
||||
TelegramGlassCircle()
|
||||
case let .rounded(radius):
|
||||
let r = RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||
r.fill(.thinMaterial)
|
||||
.overlay { r.strokeBorder(strokeColor.opacity(strokeOpacity), lineWidth: 0.5) }
|
||||
.shadow(color: .black.opacity(0.12), radius: 20, y: 8)
|
||||
TelegramGlassRoundedRect(cornerRadius: radius)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1642,6 +1887,65 @@ private extension ChatDetailView {
|
||||
return actions
|
||||
}
|
||||
|
||||
/// Determines which attachment was tapped in a photo collage based on tap location.
|
||||
/// Mirrors the layout logic in PhotoCollageView (spacing=2, same proportions).
|
||||
func collageAttachmentId(at point: CGPoint, attachments: [MessageAttachment], maxWidth: CGFloat) -> String {
|
||||
let spacing: CGFloat = 2
|
||||
let count = attachments.count
|
||||
let x = point.x
|
||||
let y = point.y
|
||||
|
||||
switch count {
|
||||
case 2:
|
||||
let half = (maxWidth - spacing) / 2
|
||||
return attachments[x < half ? 0 : 1].id
|
||||
|
||||
case 3:
|
||||
let rightWidth = maxWidth * 0.34
|
||||
let leftWidth = maxWidth - spacing - rightWidth
|
||||
let totalHeight = min(leftWidth * 1.1, 300)
|
||||
let rightCellHeight = (totalHeight - spacing) / 2
|
||||
if x < leftWidth {
|
||||
return attachments[0].id
|
||||
} else {
|
||||
return attachments[y < rightCellHeight ? 1 : 2].id
|
||||
}
|
||||
|
||||
case 4:
|
||||
let half = (maxWidth - spacing) / 2
|
||||
let cellHeight = min(half * 0.85, 150)
|
||||
let row = y < cellHeight ? 0 : 1
|
||||
let col = x < half ? 0 : 1
|
||||
return attachments[row * 2 + col].id
|
||||
|
||||
case 5:
|
||||
let topCellWidth = (maxWidth - spacing) / 2
|
||||
let bottomCellWidth = (maxWidth - spacing * 2) / 3
|
||||
let topHeight = min(topCellWidth * 0.85, 165)
|
||||
if y < topHeight {
|
||||
return attachments[x < topCellWidth ? 0 : 1].id
|
||||
} else {
|
||||
let col = min(Int(x / (bottomCellWidth + spacing)), 2)
|
||||
return attachments[2 + col].id
|
||||
}
|
||||
|
||||
default:
|
||||
return attachments[0].id
|
||||
}
|
||||
}
|
||||
|
||||
/// Collects all image attachment IDs from the current chat and opens the gallery.
|
||||
func openImageViewer(attachmentId: String) {
|
||||
var allImageIds: [String] = []
|
||||
for message in messages {
|
||||
for attachment in message.attachments where attachment.type == .image {
|
||||
allImageIds.append(attachment.id)
|
||||
}
|
||||
}
|
||||
let index = allImageIds.firstIndex(of: attachmentId) ?? 0
|
||||
imageViewerState = ImageViewerState(attachmentIds: allImageIds, initialIndex: index)
|
||||
}
|
||||
|
||||
func retryMessage(_ message: ChatMessage) {
|
||||
let text = message.text
|
||||
let toKey = message.toPublicKey
|
||||
@@ -1661,26 +1965,44 @@ private extension ChatDetailView {
|
||||
|
||||
@ViewBuilder
|
||||
func replyBar(for message: ChatMessage) -> some View {
|
||||
// PERF: use route.title (non-observable) instead of dialog?.opponentTitle.
|
||||
// Reading `dialog` here creates @Observable tracking on DialogRepository in the
|
||||
// composer's render path, which is part of the main body.
|
||||
let senderName = message.isFromMe(myPublicKey: currentPublicKey)
|
||||
? "You"
|
||||
: (dialog?.opponentTitle ?? route.title)
|
||||
let previewText = message.text.isEmpty
|
||||
? (message.attachments.isEmpty ? "" : "Attachment")
|
||||
: message.text
|
||||
: (route.title.isEmpty ? String(route.publicKey.prefix(8)) + "…" : route.title)
|
||||
let previewText: String = {
|
||||
let trimmed = message.text.trimmingCharacters(in: .whitespaces)
|
||||
if !trimmed.isEmpty { return message.text }
|
||||
if message.attachments.contains(where: { $0.type == .image }) { return "Photo" }
|
||||
if let file = message.attachments.first(where: { $0.type == .file }) {
|
||||
return file.id.isEmpty ? "File" : file.id
|
||||
}
|
||||
if message.attachments.contains(where: { $0.type == .avatar }) { return "Avatar" }
|
||||
if message.attachments.contains(where: { $0.type == .messages }) { return "Forwarded message" }
|
||||
if !message.attachments.isEmpty { return "Attachment" }
|
||||
return ""
|
||||
}()
|
||||
|
||||
HStack(spacing: 0) {
|
||||
RoundedRectangle(cornerRadius: 1.5)
|
||||
.fill(RosettaColors.figmaBlue)
|
||||
.frame(width: 3, height: 36)
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(senderName)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.figmaBlue)
|
||||
.lineLimit(1)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 0) {
|
||||
Text("Reply to ")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
Text(senderName)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.figmaBlue)
|
||||
}
|
||||
.lineLimit(1)
|
||||
|
||||
Text(previewText)
|
||||
.font(.system(size: 14, weight: .regular))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.leading, 8)
|
||||
@@ -1693,12 +2015,15 @@ private extension ChatDetailView {
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.frame(width: 30, height: 30)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.leading, 6)
|
||||
.padding(.trailing, 4)
|
||||
.padding(.top, 6)
|
||||
.padding(.bottom, 4)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
|
||||
@@ -1755,7 +2080,11 @@ private extension ChatDetailView {
|
||||
func messageTime(_ timestamp: Int64) -> String {
|
||||
if let cached = Self.timeCache[timestamp] { return cached }
|
||||
let result = Self.timeFormatter.string(from: Date(timeIntervalSince1970: Double(timestamp) / 1000))
|
||||
if Self.timeCache.count > 200 { Self.timeCache.removeAll(keepingCapacity: true) }
|
||||
// PERF: evict half instead of clearing all — timestamps are reused during scroll.
|
||||
if Self.timeCache.count > 500 {
|
||||
let keysToRemove = Array(Self.timeCache.keys.prefix(250))
|
||||
for key in keysToRemove { Self.timeCache.removeValue(forKey: key) }
|
||||
}
|
||||
Self.timeCache[timestamp] = result
|
||||
return result
|
||||
}
|
||||
@@ -1823,8 +2152,6 @@ private extension ChatDetailView {
|
||||
// Must have either text or attachments
|
||||
guard !message.isEmpty || !attachments.isEmpty else { return }
|
||||
|
||||
// User is sending a message — reset idle timer.
|
||||
SessionManager.shared.recordUserInteraction()
|
||||
shouldScrollOnNextMessage = true
|
||||
messageText = ""
|
||||
pendingAttachments = []
|
||||
@@ -2030,6 +2357,137 @@ private struct DisableScrollEdgeEffectModifier: ViewModifier {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ForwardedImagePreviewCell
|
||||
|
||||
/// Proper View struct for forwarded image previews — uses `@State` + `.task` so the image
|
||||
/// updates when `AttachmentCache` is populated by `MessageImageView` downloading the original.
|
||||
/// Without this, a plain `let cachedImage = ...` in the parent body is a one-shot evaluation
|
||||
/// that never re-checks the cache.
|
||||
private struct ForwardedImagePreviewCell: View {
|
||||
let attachment: ReplyAttachmentData
|
||||
let width: CGFloat
|
||||
let outgoing: Bool
|
||||
let onTapCachedImage: () -> Void
|
||||
|
||||
@State private var cachedImage: UIImage?
|
||||
@State private var blurImage: UIImage?
|
||||
|
||||
private var imageHeight: CGFloat { min(width * 0.75, 200) }
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let image = cachedImage {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: width, height: imageHeight)
|
||||
.clipped()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { onTapCachedImage() }
|
||||
} else if let blur = blurImage {
|
||||
Image(uiImage: blur)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: width, height: imageHeight)
|
||||
.clipped()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
} else {
|
||||
// No image at all — show placeholder.
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(outgoing ? Color.white.opacity(0.08) : Color.white.opacity(0.06))
|
||||
.frame(width: width, height: imageHeight)
|
||||
.overlay {
|
||||
Image(systemName: "photo")
|
||||
.font(.system(size: 24))
|
||||
.foregroundStyle(
|
||||
outgoing
|
||||
? Color.white.opacity(0.3)
|
||||
: RosettaColors.Adaptive.textSecondary.opacity(0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
// Decode blurhash from preview field ("cdnTag::blurhash" or just "blurhash").
|
||||
if let hash = extractBlurHash(), !hash.isEmpty {
|
||||
blurImage = ChatDetailView.cachedBlurHash(hash, width: 64, height: 64)
|
||||
}
|
||||
|
||||
// Check cache immediately — image may already be there.
|
||||
if let img = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
|
||||
cachedImage = img
|
||||
return
|
||||
}
|
||||
|
||||
// Retry: the original MessageImageView may still be downloading.
|
||||
// Poll up to 5 times with 500ms intervals (2.5s total) — covers most download durations.
|
||||
for _ in 0..<5 {
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
if Task.isCancelled { return }
|
||||
if let img = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
|
||||
cachedImage = img
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func extractBlurHash() -> String? {
|
||||
guard !attachment.preview.isEmpty else { return nil }
|
||||
let parts = attachment.preview.components(separatedBy: "::")
|
||||
let hash = parts.count > 1 ? parts[1] : attachment.preview
|
||||
return hash.isEmpty ? nil : hash
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ReplyQuoteThumbnail
|
||||
|
||||
/// 32×32 thumbnail for reply quote views. Checks `AttachmentCache` first for the actual
|
||||
/// downloaded image, falling back to blurhash. Uses `@State` + `.task` with retry polling
|
||||
/// so the thumbnail updates when the original `MessageImageView` finishes downloading.
|
||||
private struct ReplyQuoteThumbnail: View {
|
||||
let attachment: ReplyAttachmentData
|
||||
let blurHash: String?
|
||||
|
||||
/// Actual cached image (from AttachmentCache). Overrides blurhash when available.
|
||||
@State private var cachedImage: UIImage?
|
||||
|
||||
var body: some View {
|
||||
// Blurhash is computed synchronously (static cache) so it shows on the first frame.
|
||||
// cachedImage overrides it when the real photo is available in AttachmentCache.
|
||||
let image = cachedImage ?? blurHash.flatMap {
|
||||
ChatDetailView.cachedBlurHash($0, width: 32, height: 32)
|
||||
}
|
||||
|
||||
Group {
|
||||
if let image {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 32, height: 32)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
}
|
||||
}
|
||||
.task {
|
||||
// Check AttachmentCache for the actual downloaded photo.
|
||||
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
|
||||
cachedImage = cached
|
||||
return
|
||||
}
|
||||
// Retry — image may be downloading in MessageImageView.
|
||||
for _ in 0..<5 {
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
if Task.isCancelled { return }
|
||||
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
|
||||
cachedImage = cached
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
|
||||
@@ -6,9 +6,11 @@ struct ForwardChatPickerView: View {
|
||||
let onSelect: (ChatRoute) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
/// Android parity: system accounts (Updates, Safe) excluded from forward picker.
|
||||
/// Saved Messages allowed (forward to self).
|
||||
private var dialogs: [Dialog] {
|
||||
DialogRepository.shared.sortedDialogs.filter {
|
||||
$0.iHaveSent || $0.isSavedMessages || SystemAccounts.isSystemAccount($0.opponentKey)
|
||||
($0.iHaveSent || $0.isSavedMessages) && !SystemAccounts.isSystemAccount($0.opponentKey)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
169
Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift
Normal file
169
Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift
Normal file
@@ -0,0 +1,169 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import Photos
|
||||
|
||||
// MARK: - Data Types
|
||||
|
||||
/// State for the image gallery viewer.
|
||||
struct ImageViewerState: Equatable {
|
||||
let attachmentIds: [String]
|
||||
let initialIndex: Int
|
||||
}
|
||||
|
||||
// MARK: - ImageGalleryViewer
|
||||
|
||||
/// Telegram-style multi-photo gallery viewer with horizontal paging.
|
||||
/// Android parity: `ImageViewerScreen.kt` — HorizontalPager, zoom-to-point,
|
||||
/// velocity dismiss, page counter, share/save.
|
||||
struct ImageGalleryViewer: View {
|
||||
|
||||
let state: ImageViewerState
|
||||
let onDismiss: () -> Void
|
||||
|
||||
@State private var currentPage: Int
|
||||
@State private var showControls = true
|
||||
@State private var currentZoomScale: CGFloat = 1.0
|
||||
|
||||
init(state: ImageViewerState, onDismiss: @escaping () -> Void) {
|
||||
self.state = state
|
||||
self.onDismiss = onDismiss
|
||||
self._currentPage = State(initialValue: state.initialIndex)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
|
||||
// Pager
|
||||
TabView(selection: $currentPage) {
|
||||
ForEach(Array(state.attachmentIds.enumerated()), id: \.element) { index, attachmentId in
|
||||
ZoomableImagePage(
|
||||
attachmentId: attachmentId,
|
||||
onDismiss: onDismiss,
|
||||
showControls: $showControls,
|
||||
currentScale: $currentZoomScale
|
||||
)
|
||||
.tag(index)
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
.disabled(currentZoomScale > 1.05)
|
||||
|
||||
// Controls overlay
|
||||
if showControls {
|
||||
controlsOverlay
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.statusBarHidden(true)
|
||||
.animation(.easeInOut(duration: 0.2), value: showControls)
|
||||
.onChange(of: currentPage) { _, newPage in
|
||||
prefetchAdjacentImages(around: newPage)
|
||||
}
|
||||
.onAppear {
|
||||
prefetchAdjacentImages(around: state.initialIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Controls Overlay
|
||||
|
||||
private var controlsOverlay: some View {
|
||||
VStack {
|
||||
// Top bar: close + counter — inside safe area to avoid notch/Dynamic Island overlap
|
||||
HStack {
|
||||
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(.leading, 16)
|
||||
|
||||
Spacer()
|
||||
|
||||
if state.attachmentIds.count > 1 {
|
||||
Text("\(currentPage + 1) / \(state.attachmentIds.count)")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Invisible spacer to balance the close button
|
||||
Color.clear.frame(width: 36, height: 36)
|
||||
.padding(.trailing, 16)
|
||||
}
|
||||
.padding(.top, 54)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Bottom bar: share + save
|
||||
HStack(spacing: 32) {
|
||||
Button { shareCurrentImage() } label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button { saveCurrentImage() } label: {
|
||||
Image(systemName: "square.and.arrow.down")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 34)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func shareCurrentImage() {
|
||||
guard currentPage < state.attachmentIds.count,
|
||||
let image = AttachmentCache.shared.loadImage(forAttachmentId: state.attachmentIds[currentPage])
|
||||
else { return }
|
||||
|
||||
let activityVC = UIActivityViewController(activityItems: [image], applicationActivities: nil)
|
||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let root = windowScene.keyWindow?.rootViewController {
|
||||
var presenter = root
|
||||
while let presented = presenter.presentedViewController {
|
||||
presenter = presented
|
||||
}
|
||||
activityVC.popoverPresentationController?.sourceView = presenter.view
|
||||
activityVC.popoverPresentationController?.sourceRect = CGRect(
|
||||
x: presenter.view.bounds.midX, y: presenter.view.bounds.maxY - 50,
|
||||
width: 0, height: 0
|
||||
)
|
||||
presenter.present(activityVC, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func saveCurrentImage() {
|
||||
guard currentPage < state.attachmentIds.count,
|
||||
let image = AttachmentCache.shared.loadImage(forAttachmentId: state.attachmentIds[currentPage])
|
||||
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 [-1, 1] {
|
||||
let i = index + offset
|
||||
guard i >= 0, i < state.attachmentIds.count else { continue }
|
||||
// Touch cache to warm it (loads from disk if needed)
|
||||
_ = AttachmentCache.shared.loadImage(forAttachmentId: state.attachmentIds[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,10 +72,6 @@ struct MessageAvatarView: View {
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(outgoing ? Color.white.opacity(0.15) : Color.white.opacity(0.1), lineWidth: 1)
|
||||
)
|
||||
.task {
|
||||
loadFromCache()
|
||||
if avatarImage == nil {
|
||||
|
||||
@@ -73,16 +73,20 @@ struct MessageFileView: View {
|
||||
.padding(.vertical, 8)
|
||||
.frame(width: 220)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if isDownloaded, let url = cachedFileURL {
|
||||
shareFile(url)
|
||||
} else if !isDownloading {
|
||||
downloadFile()
|
||||
}
|
||||
}
|
||||
.task {
|
||||
checkCache()
|
||||
}
|
||||
// Download/share triggered by BubbleContextMenuOverlay tap → notification.
|
||||
// Overlay UIView intercepts all taps; SwiftUI onTapGesture can't fire.
|
||||
.onReceive(NotificationCenter.default.publisher(for: .triggerAttachmentDownload)) { notif in
|
||||
if let id = notif.object as? String, id == attachment.id {
|
||||
if isDownloaded, let url = cachedFileURL {
|
||||
shareFile(url)
|
||||
} else if !isDownloading {
|
||||
downloadFile()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Metadata Parsing
|
||||
|
||||
@@ -62,14 +62,20 @@ struct MessageImageView: View {
|
||||
} else {
|
||||
placeholderView
|
||||
.overlay { downloadArrowOverlay }
|
||||
.onTapGesture { downloadImage() }
|
||||
}
|
||||
}
|
||||
.task {
|
||||
// Decode blurhash once (like Android's LaunchedEffect + Dispatchers.IO)
|
||||
decodeBlurHash()
|
||||
// PERF: load cached image FIRST — skip expensive BlurHash DCT decode
|
||||
// if the full image is already available.
|
||||
loadFromCache()
|
||||
if image == nil {
|
||||
decodeBlurHash()
|
||||
}
|
||||
}
|
||||
// Download triggered by BubbleContextMenuOverlay tap → notification.
|
||||
// Overlay UIView intercepts all taps; SwiftUI onTapGesture can't fire.
|
||||
.onReceive(NotificationCenter.default.publisher(for: .triggerAttachmentDownload)) { notif in
|
||||
if let id = notif.object as? String, id == attachment.id, image == nil {
|
||||
downloadImage()
|
||||
}
|
||||
}
|
||||
@@ -184,10 +190,23 @@ struct MessageImageView: View {
|
||||
|
||||
/// Decodes the blurhash from the attachment preview string once and caches in @State.
|
||||
/// Android parity: `LaunchedEffect(preview) { BlurHash.decode(preview, 200, 200) }`.
|
||||
/// PERF: static cache for decoded BlurHash images — shared across all instances.
|
||||
/// Avoids redundant DCT decode when the same attachment appears in multiple re-renders.
|
||||
@MainActor private static var blurHashCache: [String: UIImage] = [:]
|
||||
|
||||
private func decodeBlurHash() {
|
||||
let hash = extractBlurHash(from: attachment.preview)
|
||||
guard !hash.isEmpty else { return }
|
||||
if let cached = Self.blurHashCache[hash] {
|
||||
blurImage = cached
|
||||
return
|
||||
}
|
||||
if let result = UIImage.fromBlurHash(hash, width: 32, height: 32) {
|
||||
if Self.blurHashCache.count > 200 {
|
||||
let keysToRemove = Array(Self.blurHashCache.keys.prefix(100))
|
||||
for key in keysToRemove { Self.blurHashCache.removeValue(forKey: key) }
|
||||
}
|
||||
Self.blurHashCache[hash] = result
|
||||
blurImage = result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,9 +224,7 @@ struct OpponentProfileView: View {
|
||||
if #available(iOS 26.0, *) {
|
||||
Capsule().fill(.clear).glassEffect(.regular, in: .capsule)
|
||||
} else {
|
||||
Capsule().fill(.thinMaterial)
|
||||
.overlay { Capsule().strokeBorder(Color.white.opacity(0.22), lineWidth: 0.5) }
|
||||
.shadow(color: .black.opacity(0.12), radius: 20, y: 8)
|
||||
TelegramGlassCapsule()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,12 +238,7 @@ struct OpponentProfileView: View {
|
||||
in: RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
)
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.12), lineWidth: 0.5)
|
||||
}
|
||||
TelegramGlassRoundedRect(cornerRadius: 14)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,10 +228,7 @@ struct PhotoPreviewView: View {
|
||||
.fill(.clear)
|
||||
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: 21, style: .continuous))
|
||||
} else {
|
||||
let shape = RoundedRectangle(cornerRadius: 21, style: .continuous)
|
||||
shape.fill(.thinMaterial)
|
||||
.overlay { shape.strokeBorder(Color.white.opacity(0.18), lineWidth: 0.5) }
|
||||
.shadow(color: .black.opacity(0.12), radius: 20, y: 8)
|
||||
TelegramGlassRoundedRect(cornerRadius: 21)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,15 +17,19 @@ struct SwipeToReplyModifier: ViewModifier {
|
||||
@State private var offset: CGFloat = 0
|
||||
@State private var hasTriggeredHaptic = false
|
||||
@State private var lockedAxis: SwipeAxis?
|
||||
/// Start X in global coordinates — reject if near left screen edge (back gesture zone).
|
||||
@State private var gestureStartX: CGFloat?
|
||||
|
||||
private enum SwipeAxis { case horizontal, vertical }
|
||||
|
||||
/// Minimum drag distance to trigger reply action.
|
||||
private let threshold: CGFloat = 50
|
||||
private let threshold: CGFloat = 55
|
||||
/// Offset where elastic resistance begins.
|
||||
private let elasticCap: CGFloat = 80
|
||||
private let elasticCap: CGFloat = 85
|
||||
/// Reply icon circle diameter.
|
||||
private let iconSize: CGFloat = 34
|
||||
/// Ignore gestures starting within this distance from the left screen edge (iOS back gesture zone).
|
||||
private let backGestureEdge: CGFloat = 40
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
@@ -65,18 +69,28 @@ struct SwipeToReplyModifier: ViewModifier {
|
||||
// MARK: - Gesture
|
||||
|
||||
private var dragGesture: some Gesture {
|
||||
DragGesture(minimumDistance: 16, coordinateSpace: .local)
|
||||
DragGesture(minimumDistance: 20, coordinateSpace: .global)
|
||||
.onChanged { value in
|
||||
// Record start position on first event.
|
||||
if gestureStartX == nil {
|
||||
gestureStartX = value.startLocation.x
|
||||
}
|
||||
|
||||
// Reject gestures originating near the left screen edge (iOS back gesture zone).
|
||||
if let startX = gestureStartX, startX < backGestureEdge {
|
||||
return
|
||||
}
|
||||
|
||||
// Lock axis on first significant movement to avoid
|
||||
// interfering with vertical scroll or back-swipe navigation.
|
||||
if lockedAxis == nil {
|
||||
let dx = abs(value.translation.width)
|
||||
let dy = abs(value.translation.height)
|
||||
if dx > 16 || dy > 16 {
|
||||
// Require clear horizontal dominance (2:1 ratio)
|
||||
if dx > 20 || dy > 20 {
|
||||
// Require clear horizontal dominance (2.5:1 ratio)
|
||||
// AND must be leftward — right swipe is back navigation.
|
||||
let isLeftward = value.translation.width < 0
|
||||
lockedAxis = (dx > dy * 2 && isLeftward) ? .horizontal : .vertical
|
||||
lockedAxis = (dx > dy * 2.5 && isLeftward) ? .horizontal : .vertical
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +115,7 @@ struct SwipeToReplyModifier: ViewModifier {
|
||||
// Haptic at threshold (once per gesture)
|
||||
if abs(offset) >= threshold, !hasTriggeredHaptic {
|
||||
hasTriggeredHaptic = true
|
||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
|
||||
}
|
||||
}
|
||||
.onEnded { _ in
|
||||
@@ -111,6 +125,7 @@ struct SwipeToReplyModifier: ViewModifier {
|
||||
}
|
||||
lockedAxis = nil
|
||||
hasTriggeredHaptic = false
|
||||
gestureStartX = nil
|
||||
if shouldReply {
|
||||
onReply()
|
||||
}
|
||||
|
||||
198
Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift
Normal file
198
Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift
Normal file
@@ -0,0 +1,198 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
// MARK: - ZoomableImagePage
|
||||
|
||||
/// Single page in the image gallery viewer with centroid-based zoom.
|
||||
/// Android parity: `ZoomableImage` in `ImageViewerScreen.kt` — pinch zoom to centroid,
|
||||
/// double-tap to tap point, velocity-based dismiss, touch slop.
|
||||
struct ZoomableImagePage: View {
|
||||
|
||||
let attachmentId: String
|
||||
let onDismiss: () -> Void
|
||||
@Binding var showControls: Bool
|
||||
@Binding var currentScale: CGFloat
|
||||
|
||||
@State private var image: UIImage?
|
||||
@State private var scale: CGFloat = 1.0
|
||||
@State private var offset: CGSize = .zero
|
||||
@State private var dismissOffset: CGFloat = 0
|
||||
@State private var dismissStartTime: Date?
|
||||
|
||||
private let minScale: CGFloat = 1.0
|
||||
private let maxScale: CGFloat = 5.0
|
||||
private let doubleTapScale: CGFloat = 2.5
|
||||
private let dismissDistanceThreshold: CGFloat = 100
|
||||
private let dismissVelocityThreshold: CGFloat = 800
|
||||
private let touchSlop: CGFloat = 20
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
// Background fade during dismiss
|
||||
Color.black
|
||||
.opacity(backgroundOpacity)
|
||||
.ignoresSafeArea()
|
||||
|
||||
if let image {
|
||||
imageContent(image, in: geometry)
|
||||
} else {
|
||||
placeholder
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
image = AttachmentCache.shared.loadImage(forAttachmentId: attachmentId)
|
||||
}
|
||||
.onChange(of: scale) { _, newValue in
|
||||
currentScale = newValue
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Image Content
|
||||
|
||||
@ViewBuilder
|
||||
private func imageContent(_ image: UIImage, in geometry: GeometryProxy) -> some View {
|
||||
let size = geometry.size
|
||||
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.scaleEffect(scale)
|
||||
.offset(x: offset.width, y: offset.height + dismissOffset)
|
||||
.gesture(doubleTapGesture(in: size))
|
||||
.gesture(pinchGesture(in: size))
|
||||
.gesture(dragGesture(in: size))
|
||||
.onTapGesture {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showControls.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Placeholder
|
||||
|
||||
private var placeholder: some View {
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
Text("Loading...")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
}
|
||||
}
|
||||
|
||||
// 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 to tap point)
|
||||
|
||||
private func doubleTapGesture(in size: CGSize) -> some Gesture {
|
||||
SpatialTapGesture(count: 2)
|
||||
.onEnded { value in
|
||||
withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) {
|
||||
if scale > 1.05 {
|
||||
// Zoom out to 1x
|
||||
scale = 1.0
|
||||
offset = .zero
|
||||
} else {
|
||||
// Zoom in to tap point
|
||||
let tapPoint = value.location
|
||||
let viewCenter = CGPoint(x: size.width / 2, y: size.height / 2)
|
||||
scale = doubleTapScale
|
||||
// Shift image so tap point ends up at screen center
|
||||
offset = CGSize(
|
||||
width: (viewCenter.x - tapPoint.x) * (doubleTapScale - 1),
|
||||
height: (viewCenter.y - tapPoint.y) * (doubleTapScale - 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pinch Gesture (zoom to centroid)
|
||||
|
||||
private func pinchGesture(in size: CGSize) -> some Gesture {
|
||||
MagnificationGesture()
|
||||
.onChanged { value in
|
||||
let newScale = min(max(value * (scale > 0.01 ? 1.0 : scale), minScale * 0.5), maxScale)
|
||||
// MagnificationGesture doesn't provide centroid, so zoom to center.
|
||||
// For true centroid zoom, we'd need UIKit gesture recognizers.
|
||||
// This is acceptable — most users don't notice centroid vs center on mobile.
|
||||
scale = newScale
|
||||
}
|
||||
.onEnded { _ in
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
|
||||
if scale < minScale {
|
||||
scale = minScale
|
||||
offset = .zero
|
||||
}
|
||||
clampOffset(in: size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Drag Gesture (pan when zoomed, dismiss when not)
|
||||
|
||||
private func dragGesture(in size: CGSize) -> some Gesture {
|
||||
DragGesture(minimumDistance: touchSlop)
|
||||
.onChanged { value in
|
||||
if scale > 1.05 {
|
||||
// Zoomed: pan image
|
||||
offset = CGSize(
|
||||
width: value.translation.width,
|
||||
height: value.translation.height
|
||||
)
|
||||
} else {
|
||||
// Not zoomed: check if vertical dominant (dismiss) or horizontal (page swipe)
|
||||
let dx = abs(value.translation.width)
|
||||
let dy = abs(value.translation.height)
|
||||
if dy > dx * 1.2 {
|
||||
if dismissStartTime == nil {
|
||||
dismissStartTime = Date()
|
||||
}
|
||||
dismissOffset = value.translation.height
|
||||
}
|
||||
}
|
||||
}
|
||||
.onEnded { value in
|
||||
if scale > 1.05 {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
|
||||
clampOffset(in: size)
|
||||
}
|
||||
} else {
|
||||
// Calculate velocity for dismiss
|
||||
let elapsed = dismissStartTime.map { Date().timeIntervalSince($0) } ?? 0.3
|
||||
let velocityY = abs(dismissOffset) / max(elapsed, 0.01)
|
||||
|
||||
if abs(dismissOffset) > dismissDistanceThreshold || velocityY > dismissVelocityThreshold {
|
||||
onDismiss()
|
||||
} else {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
|
||||
dismissOffset = 0
|
||||
}
|
||||
}
|
||||
dismissStartTime = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Offset Clamping
|
||||
|
||||
private func clampOffset(in size: CGSize) {
|
||||
guard scale > 1.0 else {
|
||||
offset = .zero
|
||||
return
|
||||
}
|
||||
let maxOffsetX = size.width * (scale - 1) / 2
|
||||
let maxOffsetY = size.height * (scale - 1) / 2
|
||||
offset = CGSize(
|
||||
width: min(max(offset.width, -maxOffsetX), maxOffsetX),
|
||||
height: min(max(offset.height, -maxOffsetY), maxOffsetY)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -325,7 +325,8 @@ private struct FavoriteContactsRowSearch: View {
|
||||
colorIndex: dialog.avatarColorIndex,
|
||||
size: 62,
|
||||
isOnline: dialog.isOnline,
|
||||
isSavedMessages: dialog.isSavedMessages
|
||||
isSavedMessages: dialog.isSavedMessages,
|
||||
image: dialog.isSavedMessages ? nil : AvatarRepository.shared.loadAvatar(publicKey: dialog.opponentKey)
|
||||
)
|
||||
|
||||
Text(dialog.isSavedMessages ? "Saved" : dialog.opponentTitle.components(separatedBy: " ").first ?? "")
|
||||
|
||||
@@ -564,14 +564,13 @@ private struct ChatListDialogContent: View {
|
||||
@State private var typingDialogs: Set<String> = []
|
||||
|
||||
var body: some View {
|
||||
// Compute once — avoids 3× filter (allModeDialogs → allModePinned → allModeUnpinned).
|
||||
let allDialogs = viewModel.allModeDialogs
|
||||
let pinned = allDialogs.filter(\.isPinned)
|
||||
let unpinned = allDialogs.filter { !$0.isPinned }
|
||||
// Use pre-partitioned arrays from ViewModel (single-pass O(n) instead of 3× filter).
|
||||
let pinned = viewModel.allModePinned
|
||||
let unpinned = viewModel.allModeUnpinned
|
||||
let requestsCount = viewModel.requestsCount
|
||||
|
||||
Group {
|
||||
if allDialogs.isEmpty && !viewModel.isLoading {
|
||||
if pinned.isEmpty && unpinned.isEmpty && !viewModel.isLoading {
|
||||
SyncAwareEmptyState()
|
||||
} else {
|
||||
dialogList(
|
||||
@@ -719,52 +718,44 @@ struct SyncAwareChatRow: View {
|
||||
|
||||
// MARK: - Device Approval Banner
|
||||
|
||||
/// Shown on primary device when another device is requesting access.
|
||||
/// Desktop parity: clean banner with "New login from {device} ({os})" and Accept/Decline.
|
||||
/// Desktop: DeviceVerify.tsx — height 65px, centered text (dimmed), two transparent buttons.
|
||||
private struct DeviceApprovalBanner: View {
|
||||
let device: DeviceEntry
|
||||
let onAccept: () -> Void
|
||||
let onDecline: () -> Void
|
||||
|
||||
@State private var showAcceptConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "exclamationmark.shield")
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(RosettaColors.error)
|
||||
VStack(spacing: 8) {
|
||||
Text("New login from \(device.deviceName) (\(device.deviceOs))")
|
||||
.font(.system(size: 13, weight: .regular))
|
||||
.foregroundStyle(.white.opacity(0.45))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("New device login detected")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
|
||||
Text("\(device.deviceName) (\(device.deviceOs))")
|
||||
.font(.system(size: 12, weight: .regular))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
HStack(spacing: 24) {
|
||||
Button("Accept") {
|
||||
showAcceptConfirmation = true
|
||||
}
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.primaryBlue)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
Button("Decline") {
|
||||
onDecline()
|
||||
}
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.error.opacity(0.8))
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button(action: onAccept) {
|
||||
Text("Yes, it's me")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.primaryBlue)
|
||||
}
|
||||
|
||||
Button(action: onDecline) {
|
||||
Text("No, it's not me!")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.error)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.leading, 34)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(RosettaColors.error.opacity(0.08))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.alert("Accept new device", isPresented: $showAcceptConfirmation) {
|
||||
Button("Accept") { onAccept() }
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Are you sure you want to accept this device? This will allow it to access your account.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,65 +39,70 @@ final class ChatListViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Computed (dialog list for ChatListDialogContent)
|
||||
// MARK: - Dialog partitions (single pass, cached per observation cycle)
|
||||
|
||||
private struct DialogPartition {
|
||||
var allPinned: [Dialog] = []
|
||||
var allUnpinned: [Dialog] = []
|
||||
var requests: [Dialog] = []
|
||||
var totalUnread: Int = 0
|
||||
}
|
||||
|
||||
/// Cached partition — computed once, reused by all properties until dialogs change.
|
||||
private var _cachedPartition: DialogPartition?
|
||||
private var _cachedPartitionVersion: Int = -1
|
||||
|
||||
private var partition: DialogPartition {
|
||||
let repo = DialogRepository.shared
|
||||
let currentVersion = repo.dialogsVersion
|
||||
if let cached = _cachedPartition, _cachedPartitionVersion == currentVersion {
|
||||
return cached
|
||||
}
|
||||
var result = DialogPartition()
|
||||
for dialog in repo.sortedDialogs {
|
||||
let isChat = dialog.iHaveSent || dialog.isSavedMessages || SystemAccounts.isSystemAccount(dialog.opponentKey)
|
||||
if isChat {
|
||||
if dialog.isPinned {
|
||||
result.allPinned.append(dialog)
|
||||
} else {
|
||||
result.allUnpinned.append(dialog)
|
||||
}
|
||||
} else {
|
||||
result.requests.append(dialog)
|
||||
}
|
||||
if !dialog.isMuted {
|
||||
result.totalUnread += dialog.unreadCount
|
||||
}
|
||||
}
|
||||
_cachedPartition = result
|
||||
_cachedPartitionVersion = currentVersion
|
||||
return result
|
||||
}
|
||||
|
||||
/// Filtered dialog list based on `dialogsMode`.
|
||||
/// - `all`: dialogs where I have sent (+ Saved Messages + system accounts)
|
||||
/// - `requests`: dialogs where only opponent has messaged me
|
||||
var filteredDialogs: [Dialog] {
|
||||
let all = DialogRepository.shared.sortedDialogs
|
||||
let p = partition
|
||||
switch dialogsMode {
|
||||
case .all:
|
||||
return all.filter {
|
||||
$0.iHaveSent || $0.isSavedMessages || SystemAccounts.isSystemAccount($0.opponentKey)
|
||||
}
|
||||
case .requests:
|
||||
return all.filter {
|
||||
!$0.iHaveSent && !$0.isSavedMessages && !SystemAccounts.isSystemAccount($0.opponentKey)
|
||||
}
|
||||
case .all: return p.allPinned + p.allUnpinned
|
||||
case .requests: return p.requests
|
||||
}
|
||||
}
|
||||
|
||||
var pinnedDialogs: [Dialog] { filteredDialogs.filter(\.isPinned) }
|
||||
var unpinnedDialogs: [Dialog] { filteredDialogs.filter { !$0.isPinned } }
|
||||
|
||||
/// Number of request dialogs (incoming-only, not system, not self-chat).
|
||||
var requestsCount: Int {
|
||||
DialogRepository.shared.sortedDialogs.filter {
|
||||
!$0.iHaveSent && !$0.isSavedMessages && !SystemAccounts.isSystemAccount($0.opponentKey)
|
||||
}.count
|
||||
}
|
||||
var pinnedDialogs: [Dialog] { partition.allPinned }
|
||||
var unpinnedDialogs: [Dialog] { partition.allUnpinned }
|
||||
|
||||
var requestsCount: Int { partition.requests.count }
|
||||
var hasRequests: Bool { requestsCount > 0 }
|
||||
|
||||
var totalUnreadCount: Int {
|
||||
DialogRepository.shared.dialogs.values
|
||||
.lazy.filter { !$0.isMuted }
|
||||
.reduce(0) { $0 + $1.unreadCount }
|
||||
}
|
||||
|
||||
var totalUnreadCount: Int { partition.totalUnread }
|
||||
var hasUnread: Bool { totalUnreadCount > 0 }
|
||||
|
||||
// MARK: - Per-mode dialogs (for TabView pages)
|
||||
|
||||
/// "All" dialogs — conversations where I have sent (+ Saved Messages + system accounts).
|
||||
/// Used by the All page in the swipeable TabView.
|
||||
var allModeDialogs: [Dialog] {
|
||||
DialogRepository.shared.sortedDialogs.filter {
|
||||
$0.iHaveSent || $0.isSavedMessages || SystemAccounts.isSystemAccount($0.opponentKey)
|
||||
}
|
||||
}
|
||||
var allModeDialogs: [Dialog] { partition.allPinned + partition.allUnpinned }
|
||||
var allModePinned: [Dialog] { partition.allPinned }
|
||||
var allModeUnpinned: [Dialog] { partition.allUnpinned }
|
||||
|
||||
var allModePinned: [Dialog] { allModeDialogs.filter(\.isPinned) }
|
||||
var allModeUnpinned: [Dialog] { allModeDialogs.filter { !$0.isPinned } }
|
||||
|
||||
/// "Requests" dialogs — conversations where only opponent has messaged me.
|
||||
/// Used by the Requests page in the swipeable TabView.
|
||||
var requestsModeDialogs: [Dialog] {
|
||||
DialogRepository.shared.sortedDialogs.filter {
|
||||
!$0.iHaveSent && !$0.isSavedMessages && !SystemAccounts.isSystemAccount($0.opponentKey)
|
||||
}
|
||||
}
|
||||
var requestsModeDialogs: [Dialog] { partition.requests }
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ private extension ChatRowView {
|
||||
size: 62,
|
||||
isOnline: dialog.isOnline,
|
||||
isSavedMessages: dialog.isSavedMessages,
|
||||
image: AvatarRepository.shared.loadAvatar(publicKey: dialog.opponentKey)
|
||||
image: dialog.isSavedMessages ? nil : AvatarRepository.shared.loadAvatar(publicKey: dialog.opponentKey)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,9 @@ private extension ChatRowView {
|
||||
.frame(height: 41, alignment: .topLeading)
|
||||
}
|
||||
|
||||
/// Static cache for emoji-parsed message text (avoids regex per row per render).
|
||||
private static var messageTextCache: [String: String] = [:]
|
||||
|
||||
var messageText: String {
|
||||
// Desktop parity: show "typing..." in chat list row when opponent is typing.
|
||||
if isTyping && !dialog.isSavedMessages {
|
||||
@@ -140,9 +143,18 @@ private extension ChatRowView {
|
||||
if dialog.lastMessage.isEmpty {
|
||||
return "No messages yet"
|
||||
}
|
||||
if let cached = Self.messageTextCache[dialog.lastMessage] {
|
||||
return cached
|
||||
}
|
||||
// Strip inline markdown markers and convert emoji shortcodes for clean preview.
|
||||
let cleaned = dialog.lastMessage.replacingOccurrences(of: "**", with: "")
|
||||
return EmojiParser.replaceShortcodes(in: cleaned)
|
||||
let result = EmojiParser.replaceShortcodes(in: cleaned)
|
||||
if Self.messageTextCache.count > 500 {
|
||||
let keysToRemove = Array(Self.messageTextCache.keys.prefix(250))
|
||||
for key in keysToRemove { Self.messageTextCache.removeValue(forKey: key) }
|
||||
}
|
||||
Self.messageTextCache[dialog.lastMessage] = result
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,22 +294,37 @@ private extension ChatRowView {
|
||||
let f = DateFormatter(); f.dateFormat = "dd.MM.yy"; return f
|
||||
}()
|
||||
|
||||
/// Static cache for formatted time strings (avoids Date/Calendar per row per render).
|
||||
private static var timeStringCache: [Int64: String] = [:]
|
||||
|
||||
var formattedTime: String {
|
||||
guard dialog.lastMessageTimestamp > 0 else { return "" }
|
||||
|
||||
if let cached = Self.timeStringCache[dialog.lastMessageTimestamp] {
|
||||
return cached
|
||||
}
|
||||
|
||||
let date = Date(timeIntervalSince1970: Double(dialog.lastMessageTimestamp) / 1000)
|
||||
let now = Date()
|
||||
let calendar = Calendar.current
|
||||
|
||||
let result: String
|
||||
if calendar.isDateInToday(date) {
|
||||
return Self.timeFormatter.string(from: date)
|
||||
result = Self.timeFormatter.string(from: date)
|
||||
} else if calendar.isDateInYesterday(date) {
|
||||
return "Yesterday"
|
||||
result = "Yesterday"
|
||||
} else if let days = calendar.dateComponents([.day], from: date, to: now).day, days < 7 {
|
||||
return Self.dayFormatter.string(from: date)
|
||||
result = Self.dayFormatter.string(from: date)
|
||||
} else {
|
||||
return Self.dateFormatter.string(from: date)
|
||||
result = Self.dateFormatter.string(from: date)
|
||||
}
|
||||
|
||||
if Self.timeStringCache.count > 500 {
|
||||
let keysToRemove = Array(Self.timeStringCache.keys.prefix(250))
|
||||
for key in keysToRemove { Self.timeStringCache.removeValue(forKey: key) }
|
||||
}
|
||||
Self.timeStringCache[dialog.lastMessageTimestamp] = result
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,9 +76,7 @@ struct RequestChatsView: View {
|
||||
if #available(iOS 26.0, *) {
|
||||
Capsule().fill(.clear).glassEffect(.regular, in: .capsule)
|
||||
} else {
|
||||
Capsule().fill(.thinMaterial)
|
||||
.overlay { Capsule().strokeBorder(strokeColor.opacity(strokeOpacity), lineWidth: 0.5) }
|
||||
.shadow(color: .black.opacity(0.12), radius: 20, y: 8)
|
||||
TelegramGlassCapsule()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user