Уведомления, Real-time синхронизация, фотки, reply and forward
This commit is contained in:
187
Rosetta/Features/Chats/ChatDetail/BubbleContextMenuOverlay.swift
Normal file
187
Rosetta/Features/Chats/ChatDetail/BubbleContextMenuOverlay.swift
Normal file
@@ -0,0 +1,187 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// Action model for context menu buttons.
|
||||
struct BubbleContextAction {
|
||||
let title: String
|
||||
let image: UIImage?
|
||||
let role: UIMenuElement.Attributes
|
||||
let handler: () -> Void
|
||||
}
|
||||
|
||||
/// Transparent overlay that attaches UIContextMenuInteraction to a message bubble.
|
||||
///
|
||||
/// Uses a **window snapshot** approach instead of UIHostingController preview:
|
||||
/// 1. On long-press, captures a pixel-perfect screenshot of the bubble from the window
|
||||
/// 2. Uses this snapshot as `UITargetedPreview` with `previewProvider: nil`
|
||||
/// 3. UIKit lifts the snapshot in-place — no horizontal shift, no re-rendering issues
|
||||
///
|
||||
/// Also supports an optional `onTap` callback that fires on single tap.
|
||||
/// This is needed because the overlay UIView intercepts all touch events,
|
||||
/// preventing SwiftUI `onTapGesture` on content below from firing.
|
||||
struct BubbleContextMenuOverlay: UIViewRepresentable {
|
||||
let actions: [BubbleContextAction]
|
||||
let previewShape: MessageBubbleShape
|
||||
let readStatusText: String?
|
||||
|
||||
/// Called when user single-taps the bubble (e.g., to open fullscreen image).
|
||||
var onTap: (() -> 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`.
|
||||
var replyQuoteHeight: CGFloat = 0
|
||||
|
||||
/// Called when user taps the reply quote area at the top of the bubble.
|
||||
var onReplyQuoteTap: (() -> Void)?
|
||||
|
||||
func makeUIView(context: Context) -> UIView {
|
||||
let view = UIView()
|
||||
view.backgroundColor = .clear
|
||||
let interaction = UIContextMenuInteraction(delegate: context.coordinator)
|
||||
view.addInteraction(interaction)
|
||||
|
||||
// Single tap recognizer — coexists with context menu's long press.
|
||||
let tap = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap(_:)))
|
||||
view.addGestureRecognizer(tap)
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIView, context: Context) {
|
||||
context.coordinator.actions = actions
|
||||
context.coordinator.previewShape = previewShape
|
||||
context.coordinator.readStatusText = readStatusText
|
||||
context.coordinator.onTap = onTap
|
||||
context.coordinator.replyQuoteHeight = replyQuoteHeight
|
||||
context.coordinator.onReplyQuoteTap = onReplyQuoteTap
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(overlay: self) }
|
||||
|
||||
final class Coordinator: NSObject, UIContextMenuInteractionDelegate {
|
||||
var actions: [BubbleContextAction]
|
||||
var previewShape: MessageBubbleShape
|
||||
var readStatusText: String?
|
||||
var onTap: (() -> Void)?
|
||||
var replyQuoteHeight: CGFloat = 0
|
||||
var onReplyQuoteTap: (() -> Void)?
|
||||
private var snapshotView: UIImageView?
|
||||
|
||||
init(overlay: BubbleContextMenuOverlay) {
|
||||
self.actions = overlay.actions
|
||||
self.previewShape = overlay.previewShape
|
||||
self.readStatusText = overlay.readStatusText
|
||||
self.onTap = overlay.onTap
|
||||
self.replyQuoteHeight = overlay.replyQuoteHeight
|
||||
self.onReplyQuoteTap = overlay.onReplyQuoteTap
|
||||
}
|
||||
|
||||
@objc func handleTap(_ recognizer: UITapGestureRecognizer) {
|
||||
// Route taps in the reply quote region to the reply handler.
|
||||
if replyQuoteHeight > 0, let view = recognizer.view {
|
||||
let location = recognizer.location(in: view)
|
||||
if location.y < replyQuoteHeight {
|
||||
onReplyQuoteTap?()
|
||||
return
|
||||
}
|
||||
}
|
||||
onTap?()
|
||||
}
|
||||
|
||||
func contextMenuInteraction(
|
||||
_ interaction: UIContextMenuInteraction,
|
||||
configurationForMenuAtLocation location: CGPoint
|
||||
) -> UIContextMenuConfiguration? {
|
||||
captureSnapshot(for: interaction)
|
||||
|
||||
return UIContextMenuConfiguration(
|
||||
identifier: nil,
|
||||
previewProvider: nil,
|
||||
actionProvider: { [weak self] _ in
|
||||
self?.buildMenu()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Snapshot
|
||||
|
||||
private func captureSnapshot(for interaction: UIContextMenuInteraction) {
|
||||
guard let view = interaction.view, let window = view.window else { return }
|
||||
let frameInWindow = view.convert(view.bounds, to: window)
|
||||
let renderer = UIGraphicsImageRenderer(size: view.bounds.size)
|
||||
let image = renderer.image { ctx in
|
||||
ctx.cgContext.translateBy(x: -frameInWindow.origin.x, y: -frameInWindow.origin.y)
|
||||
window.drawHierarchy(in: window.bounds, afterScreenUpdates: false)
|
||||
}
|
||||
let sv = UIImageView(image: image)
|
||||
sv.frame = view.bounds
|
||||
view.addSubview(sv)
|
||||
self.snapshotView = sv
|
||||
}
|
||||
|
||||
// MARK: - Menu
|
||||
|
||||
private func buildMenu() -> UIMenu {
|
||||
var sections: [UIMenuElement] = []
|
||||
|
||||
if let readStatus = readStatusText {
|
||||
let readAction = UIAction(
|
||||
title: readStatus,
|
||||
image: UIImage(systemName: "checkmark"),
|
||||
attributes: .disabled
|
||||
) { _ in }
|
||||
sections.append(UIMenu(options: .displayInline, children: [readAction]))
|
||||
}
|
||||
|
||||
let menuActions = actions.map { action in
|
||||
UIAction(
|
||||
title: action.title,
|
||||
image: action.image,
|
||||
attributes: action.role
|
||||
) { _ in
|
||||
action.handler()
|
||||
}
|
||||
}
|
||||
sections.append(UIMenu(options: .displayInline, children: menuActions))
|
||||
|
||||
return UIMenu(children: sections)
|
||||
}
|
||||
|
||||
// MARK: - Targeted Preview (lift & dismiss)
|
||||
|
||||
func contextMenuInteraction(
|
||||
_ interaction: UIContextMenuInteraction,
|
||||
previewForHighlightingMenuWithConfiguration configuration: UIContextMenuConfiguration
|
||||
) -> UITargetedPreview? {
|
||||
guard let sv = snapshotView else { return nil }
|
||||
return makeTargetedPreview(for: sv)
|
||||
}
|
||||
|
||||
func contextMenuInteraction(
|
||||
_ interaction: UIContextMenuInteraction,
|
||||
previewForDismissingMenuWithConfiguration configuration: UIContextMenuConfiguration
|
||||
) -> UITargetedPreview? {
|
||||
guard let sv = snapshotView else { return nil }
|
||||
return makeTargetedPreview(for: sv)
|
||||
}
|
||||
|
||||
func contextMenuInteraction(
|
||||
_ interaction: UIContextMenuInteraction,
|
||||
willEndFor configuration: UIContextMenuConfiguration,
|
||||
animator: (any UIContextMenuInteractionAnimating)?
|
||||
) {
|
||||
animator?.addCompletion { [weak self] in
|
||||
self?.snapshotView?.removeFromSuperview()
|
||||
self?.snapshotView = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func makeTargetedPreview(for view: UIView) -> UITargetedPreview {
|
||||
let params = UIPreviewParameters()
|
||||
let shapePath = previewShape.path(in: view.bounds)
|
||||
params.visiblePath = UIBezierPath(cgPath: shapePath.cgPath)
|
||||
params.backgroundColor = .clear
|
||||
return UITargetedPreview(view: view, parameters: params)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,10 +82,20 @@ struct ChatDetailView: View {
|
||||
@State private var showAttachmentPanel = 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?
|
||||
/// 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.
|
||||
@State private var highlightedMessageId: String?
|
||||
|
||||
private var currentPublicKey: String {
|
||||
SessionManager.shared.currentPublicKey
|
||||
}
|
||||
/// Cached at view init — never changes during a session. Avoids @Observable
|
||||
/// observation on SessionManager that would re-render all cells on any state change.
|
||||
private let currentPublicKey: String = SessionManager.shared.currentPublicKey
|
||||
|
||||
private var dialog: Dialog? {
|
||||
DialogRepository.shared.dialogs[route.publicKey]
|
||||
@@ -262,6 +272,39 @@ struct ChatDetailView: View {
|
||||
.navigationDestination(isPresented: $showOpponentProfile) {
|
||||
OpponentProfileView(route: route)
|
||||
}
|
||||
.sheet(isPresented: $showForwardPicker) {
|
||||
ForwardChatPickerView { targetRoute in
|
||||
showForwardPicker = false
|
||||
guard let message = forwardingMessage else { return }
|
||||
forwardingMessage = nil
|
||||
forwardMessage(message, to: targetRoute)
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: Binding(
|
||||
get: { fullScreenAttachmentId != nil },
|
||||
set: { if !$0 { fullScreenAttachmentId = nil } }
|
||||
)) {
|
||||
FullScreenImageFromCache(
|
||||
attachmentId: fullScreenAttachmentId ?? "",
|
||||
onDismiss: { fullScreenAttachmentId = nil }
|
||||
)
|
||||
}
|
||||
.alert("Delete Message", isPresented: Binding(
|
||||
get: { messageToDelete != nil },
|
||||
set: { if !$0 { messageToDelete = nil } }
|
||||
)) {
|
||||
Button("Delete", role: .destructive) {
|
||||
if let message = messageToDelete {
|
||||
removeMessage(message)
|
||||
messageToDelete = nil
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
messageToDelete = nil
|
||||
}
|
||||
} message: {
|
||||
Text("Are you sure you want to delete this message? This action cannot be undone.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -630,15 +673,16 @@ private extension ChatDetailView {
|
||||
.onAppear { withAnimation(.easeOut(duration: 0.2)) { isAtBottom = true } }
|
||||
.onDisappear { withAnimation(.easeOut(duration: 0.2)) { isAtBottom = false } }
|
||||
|
||||
ForEach(messages.indices.reversed(), id: \.self) { index in
|
||||
let message = messages[index]
|
||||
// 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
|
||||
let position = bubblePosition(for: index)
|
||||
messageRow(
|
||||
message,
|
||||
maxBubbleWidth: maxBubbleWidth,
|
||||
position: bubblePosition(for: index)
|
||||
position: position
|
||||
)
|
||||
.scaleEffect(x: 1, y: -1) // flip each row back to normal
|
||||
.id(message.id)
|
||||
|
||||
// Unread Messages separator (Telegram style).
|
||||
// In inverted scroll, "above" visually = after in code.
|
||||
@@ -652,6 +696,9 @@ private extension ChatDetailView {
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.bottom, messagesTopInset) // visual top (near nav bar)
|
||||
}
|
||||
// iOS 26: disable default scroll edge blur — in inverted scroll the top+bottom
|
||||
// effects overlap and blur the entire screen.
|
||||
.modifier(DisableScrollEdgeEffectModifier())
|
||||
.scaleEffect(x: 1, y: -1) // INVERTED SCROLL — bottom-anchored by nature
|
||||
// Parent .ignoresSafeArea(.keyboard) handles keyboard — no scroll-level ignore needed.
|
||||
// Composer is overlay (not safeAreaInset), so no .container ignore needed either.
|
||||
@@ -675,6 +722,25 @@ private extension ChatDetailView {
|
||||
guard focused else { return }
|
||||
SessionManager.shared.recordUserInteraction()
|
||||
}
|
||||
// Scroll-to-reply: navigate to the original message and highlight it briefly.
|
||||
.onChange(of: scrollToMessageId) { _, targetId in
|
||||
guard let targetId else { return }
|
||||
scrollToMessageId = nil
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
proxy.scrollTo(targetId, anchor: .center)
|
||||
}
|
||||
// Brief highlight glow after scroll completes.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||
withAnimation(.easeIn(duration: 0.2)) {
|
||||
highlightedMessageId = targetId
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
|
||||
withAnimation(.easeOut(duration: 0.5)) {
|
||||
highlightedMessageId = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// No keyboard scroll handlers needed — inverted scroll keeps bottom anchored.
|
||||
scroll
|
||||
.scrollIndicators(.hidden)
|
||||
@@ -723,50 +789,185 @@ private extension ChatDetailView {
|
||||
// Desktop parity: render image, file, and avatar attachments in the bubble.
|
||||
let visibleAttachments = message.attachments.filter { $0.type == .image || $0.type == .file || $0.type == .avatar }
|
||||
|
||||
if visibleAttachments.isEmpty {
|
||||
// Text-only message (original path)
|
||||
textOnlyBubble(message: message, outgoing: outgoing, hasTail: hasTail, maxBubbleWidth: maxBubbleWidth, position: position)
|
||||
} else {
|
||||
// Attachment message: images/files + optional caption
|
||||
attachmentBubble(message: message, attachments: visibleAttachments, outgoing: outgoing, hasTail: hasTail, maxBubbleWidth: maxBubbleWidth, position: position)
|
||||
Group {
|
||||
if visibleAttachments.isEmpty {
|
||||
// Text-only message (original path)
|
||||
textOnlyBubble(message: message, outgoing: outgoing, hasTail: hasTail, maxBubbleWidth: maxBubbleWidth, position: position)
|
||||
} else {
|
||||
// Attachment message: images/files + optional caption
|
||||
attachmentBubble(message: message, attachments: visibleAttachments, outgoing: outgoing, hasTail: hasTail, maxBubbleWidth: maxBubbleWidth, position: position)
|
||||
}
|
||||
}
|
||||
// Telegram-style swipe-to-reply: skip gesture entirely for system chats.
|
||||
.modifier(ConditionalSwipeToReply(
|
||||
enabled: !route.isSavedMessages && !route.isSystemAccount,
|
||||
onReply: {
|
||||
self.replyingToMessage = message
|
||||
self.isInputFocused = true
|
||||
}
|
||||
))
|
||||
// Highlight overlay for scroll-to-reply navigation.
|
||||
.overlay {
|
||||
if highlightedMessageId == message.id {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color.white.opacity(0.12))
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: outgoing ? .trailing : .leading)
|
||||
.padding(.trailing, outgoing ? (hasTail ? 2 : MessageBubbleShape.tailProtrusion + 2) : 0)
|
||||
.padding(.leading, !outgoing ? (hasTail ? 2 : MessageBubbleShape.tailProtrusion + 2) : 0)
|
||||
.padding(.top, (position == .single || position == .top) ? 6 : 2)
|
||||
.padding(.bottom, 0)
|
||||
}
|
||||
|
||||
/// Text-only message bubble (original design).
|
||||
/// If the message has a MESSAGES attachment (reply/forward), shows the quoted message above text.
|
||||
@ViewBuilder
|
||||
private func textOnlyBubble(message: ChatMessage, outgoing: Bool, hasTail: Bool, maxBubbleWidth: CGFloat, position: BubblePosition) -> some View {
|
||||
let messageText = message.text.isEmpty ? " " : message.text
|
||||
let replyAttachment = message.attachments.first(where: { $0.type == .messages })
|
||||
let replyData = replyAttachment.flatMap { parseReplyBlob($0.blob) }?.first
|
||||
|
||||
// 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)
|
||||
.frame(minWidth: outgoing ? 86 : 66, alignment: .leading)
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
timestampOverlay(message: message, outgoing: outgoing)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Reply/forward quote (if present)
|
||||
if let reply = replyData {
|
||||
replyQuoteView(reply: reply, 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) }
|
||||
.frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading)
|
||||
.frame(maxWidth: .infinity, alignment: outgoing ? .trailing : .leading)
|
||||
.padding(.trailing, outgoing ? (hasTail ? 2 : MessageBubbleShape.tailProtrusion + 2) : 0)
|
||||
.padding(.leading, !outgoing ? (hasTail ? 2 : MessageBubbleShape.tailProtrusion + 2) : 0)
|
||||
.padding(.top, (position == .single || position == .top) ? 6 : 2)
|
||||
.padding(.bottom, 0)
|
||||
|
||||
// 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)
|
||||
}
|
||||
.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 }
|
||||
}
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading)
|
||||
}
|
||||
|
||||
/// PERF: static cache for decoded reply blobs — avoids JSON decode on every re-render.
|
||||
@MainActor private static var replyBlobCache: [String: [ReplyMessageData]] = [:]
|
||||
|
||||
/// Parses a decrypted MESSAGES blob into `ReplyMessageData` array.
|
||||
private func parseReplyBlob(_ blob: String) -> [ReplyMessageData]? {
|
||||
guard !blob.isEmpty else { return nil }
|
||||
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) }
|
||||
Self.replyBlobCache[blob] = result
|
||||
return result
|
||||
}
|
||||
|
||||
/// Telegram-style reply quote rendered above message text inside the bubble.
|
||||
/// Matches Figma spec: 4px corners, 3px accent bar, 15pt font, semi-transparent bg.
|
||||
/// Tapping scrolls to the original message and briefly highlights it.
|
||||
@ViewBuilder
|
||||
private func replyQuoteView(reply: ReplyMessageData, outgoing: Bool) -> some View {
|
||||
let senderName = senderDisplayName(for: reply.publicKey)
|
||||
let previewText = reply.message.isEmpty ? "Attachment" : reply.message
|
||||
let accentColor = outgoing ? Color.white.opacity(0.5) : RosettaColors.figmaBlue
|
||||
// Check for image attachment to show thumbnail
|
||||
let imageAttachment = reply.attachments.first(where: { $0.type == 0 })
|
||||
let blurHash: String? = {
|
||||
guard let att = imageAttachment, !att.preview.isEmpty else { return nil }
|
||||
let parts = att.preview.components(separatedBy: "::")
|
||||
let hash = parts.count > 1 ? parts[1] : att.preview
|
||||
return hash.isEmpty ? nil : hash
|
||||
}()
|
||||
|
||||
// Tap is handled at UIKit level via BubbleContextMenuOverlay.onReplyQuoteTap.
|
||||
HStack(spacing: 0) {
|
||||
// 3px accent bar
|
||||
RoundedRectangle(cornerRadius: 1.5)
|
||||
.fill(accentColor)
|
||||
.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))
|
||||
.padding(.leading, 6)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(senderName)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.tracking(-0.23)
|
||||
.foregroundStyle(outgoing ? Color.white.opacity(0.85) : RosettaColors.figmaBlue)
|
||||
.lineLimit(1)
|
||||
Text(previewText)
|
||||
.font(.system(size: 15, weight: .regular))
|
||||
.tracking(-0.23)
|
||||
.foregroundStyle(outgoing ? Color.white.opacity(0.7) : RosettaColors.Adaptive.textSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.leading, 6)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(height: 41)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(outgoing ? Color.white.opacity(0.08) : Color.white.opacity(0.06))
|
||||
)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.top, 5)
|
||||
.padding(.bottom, 0)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
private func senderDisplayName(for publicKey: String) -> String {
|
||||
if publicKey == currentPublicKey {
|
||||
return "You"
|
||||
}
|
||||
// Current chat opponent — use route (non-observable, stable).
|
||||
if publicKey == route.publicKey {
|
||||
return route.title.isEmpty ? String(publicKey.prefix(8)) + "…" : route.title
|
||||
}
|
||||
return String(publicKey.prefix(8)) + "…"
|
||||
}
|
||||
|
||||
/// Attachment message bubble: images/files with optional text caption.
|
||||
///
|
||||
/// Telegram-style layout:
|
||||
/// - **Image-only**: image fills bubble edge-to-edge, timestamp overlaid as dark pill
|
||||
/// - **Image + text**: image at top, caption below, normal timestamp in caption area
|
||||
/// - **File/Avatar**: padded inside bubble, normal timestamp
|
||||
@ViewBuilder
|
||||
private func attachmentBubble(
|
||||
message: ChatMessage,
|
||||
@@ -777,26 +978,32 @@ 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 }
|
||||
let isImageOnly = !imageAttachments.isEmpty && otherAttachments.isEmpty && !hasCaption
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Attachment views
|
||||
ForEach(attachments, id: \.id) { attachment in
|
||||
// Image attachments — Telegram-style collage layout
|
||||
if !imageAttachments.isEmpty {
|
||||
PhotoCollageView(
|
||||
attachments: imageAttachments,
|
||||
message: message,
|
||||
outgoing: outgoing,
|
||||
maxWidth: maxBubbleWidth - (hasTail ? MessageBubbleShape.tailProtrusion : 0),
|
||||
position: position
|
||||
)
|
||||
}
|
||||
|
||||
// Non-image attachments (file, avatar) — padded
|
||||
ForEach(otherAttachments, id: \.id) { attachment in
|
||||
switch attachment.type {
|
||||
case .image:
|
||||
MessageImageView(
|
||||
attachment: attachment,
|
||||
message: message,
|
||||
outgoing: outgoing,
|
||||
maxWidth: maxBubbleWidth
|
||||
)
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.top, 4)
|
||||
case .file:
|
||||
MessageFileView(
|
||||
attachment: attachment,
|
||||
message: message,
|
||||
outgoing: outgoing
|
||||
)
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.top, 4)
|
||||
case .avatar:
|
||||
MessageAvatarView(
|
||||
@@ -811,7 +1018,7 @@ private extension ChatDetailView {
|
||||
}
|
||||
}
|
||||
|
||||
// Caption text (if any)
|
||||
// Caption text below image
|
||||
if hasCaption {
|
||||
Text(parsedMarkdown(message.text))
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
@@ -822,23 +1029,37 @@ private extension ChatDetailView {
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.leading, 11)
|
||||
.padding(.trailing, outgoing ? 64 : 48)
|
||||
.padding(.top, 4)
|
||||
.padding(.top, 6)
|
||||
.padding(.bottom, 5)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: outgoing ? 86 : 66, alignment: .leading)
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
timestampOverlay(message: message, outgoing: outgoing)
|
||||
if isImageOnly {
|
||||
// Telegram-style: dark pill overlay on image
|
||||
mediaTimestampOverlay(message: message, outgoing: outgoing)
|
||||
} else {
|
||||
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) }
|
||||
.clipShape(MessageBubbleShape(position: position, outgoing: outgoing))
|
||||
.overlay {
|
||||
BubbleContextMenuOverlay(
|
||||
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
|
||||
}
|
||||
} : nil
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading)
|
||||
.frame(maxWidth: .infinity, alignment: outgoing ? .trailing : .leading)
|
||||
.padding(.trailing, outgoing ? (hasTail ? 2 : MessageBubbleShape.tailProtrusion + 2) : 0)
|
||||
.padding(.leading, !outgoing ? (hasTail ? 2 : MessageBubbleShape.tailProtrusion + 2) : 0)
|
||||
.padding(.top, (position == .single || position == .top) ? 6 : 2)
|
||||
.padding(.bottom, 0)
|
||||
}
|
||||
|
||||
/// Timestamp + delivery status overlay for both text and attachment bubbles.
|
||||
@@ -865,6 +1086,46 @@ private extension ChatDetailView {
|
||||
.padding(.bottom, 5)
|
||||
}
|
||||
|
||||
/// Figma "Media=True" timestamp: dark semi-transparent pill overlaid on images.
|
||||
@ViewBuilder
|
||||
private func mediaTimestampOverlay(message: ChatMessage, outgoing: Bool) -> some View {
|
||||
HStack(spacing: 3) {
|
||||
Text(messageTime(message.timestamp))
|
||||
.font(.system(size: 11, weight: .regular))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
if outgoing {
|
||||
if message.deliveryStatus == .error {
|
||||
errorMenu(for: message)
|
||||
} else {
|
||||
mediaDeliveryIndicator(message.deliveryStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color.black.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
.padding(.trailing, 6)
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
|
||||
// MARK: - BlurHash Cache
|
||||
|
||||
/// PERF: static cache for decoded BlurHash images. Hash strings are immutable,
|
||||
/// so results never need invalidation. Avoids DCT decode on every re-render.
|
||||
@MainActor private static var blurHashCache: [String: UIImage] = [:]
|
||||
|
||||
@MainActor
|
||||
private 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) }
|
||||
blurHashCache[key] = image
|
||||
return image
|
||||
}
|
||||
|
||||
// MARK: - Text Parsing (Markdown + Emoji)
|
||||
|
||||
/// Static cache for parsed markdown + emoji. Message text is immutable,
|
||||
@@ -919,6 +1180,11 @@ 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)
|
||||
@@ -1200,6 +1466,29 @@ private extension ChatDetailView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Delivery indicator with white tint for on-image media overlay.
|
||||
@ViewBuilder
|
||||
func mediaDeliveryIndicator(_ status: DeliveryStatus) -> some View {
|
||||
switch status {
|
||||
case .read:
|
||||
DoubleCheckmarkShape()
|
||||
.fill(Color.white)
|
||||
.frame(width: 16, height: 8.7)
|
||||
case .delivered:
|
||||
SingleCheckmarkShape()
|
||||
.fill(Color.white.opacity(0.8))
|
||||
.frame(width: 12, height: 8.8)
|
||||
case .waiting:
|
||||
Image(systemName: "clock")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
case .error:
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.error)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func errorMenu(for message: ChatMessage) -> some View {
|
||||
Menu {
|
||||
@@ -1220,6 +1509,139 @@ private extension ChatDetailView {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Context Menu
|
||||
|
||||
/// Clean bubble preview for context menu — no `.frame(maxWidth: .infinity)`, no outer paddings.
|
||||
@ViewBuilder
|
||||
func bubblePreview(message: ChatMessage, maxBubbleWidth: CGFloat, position: BubblePosition) -> some View {
|
||||
let outgoing = message.isFromMe(myPublicKey: currentPublicKey)
|
||||
let hasTail = position == .single || position == .bottom
|
||||
let visibleAttachments = message.attachments.filter { $0.type == .image || $0.type == .file || $0.type == .avatar }
|
||||
|
||||
if visibleAttachments.isEmpty {
|
||||
let messageText = message.text.isEmpty ? " " : message.text
|
||||
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) }
|
||||
.contentShape(.contextMenuPreview, MessageBubbleShape(position: position, outgoing: outgoing))
|
||||
.frame(maxWidth: maxBubbleWidth)
|
||||
} else {
|
||||
// Attachment preview — reuse full bubble, clip to shape
|
||||
let hasCaption = !message.text.trimmingCharacters(in: .whitespaces).isEmpty && message.text != " "
|
||||
let imageAttachments = visibleAttachments.filter { $0.type == .image }
|
||||
let otherAttachments = visibleAttachments.filter { $0.type != .image }
|
||||
let isImageOnly = !imageAttachments.isEmpty && otherAttachments.isEmpty && !hasCaption
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
if !imageAttachments.isEmpty {
|
||||
PhotoCollageView(
|
||||
attachments: imageAttachments,
|
||||
message: message,
|
||||
outgoing: outgoing,
|
||||
maxWidth: maxBubbleWidth - (hasTail ? MessageBubbleShape.tailProtrusion : 0),
|
||||
position: position
|
||||
)
|
||||
}
|
||||
ForEach(otherAttachments, id: \.id) { attachment in
|
||||
switch attachment.type {
|
||||
case .file:
|
||||
MessageFileView(attachment: attachment, message: message, outgoing: outgoing).padding(.horizontal, 4).padding(.top, 4)
|
||||
case .avatar:
|
||||
MessageAvatarView(attachment: attachment, message: message, outgoing: outgoing).padding(.horizontal, 6).padding(.top, 4)
|
||||
default: EmptyView()
|
||||
}
|
||||
}
|
||||
if hasCaption {
|
||||
Text(parsedMarkdown(message.text))
|
||||
.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, 6)
|
||||
.padding(.bottom, 5)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: outgoing ? 86 : 66, alignment: .leading)
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
if isImageOnly {
|
||||
mediaTimestampOverlay(message: message, outgoing: outgoing)
|
||||
} else {
|
||||
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) }
|
||||
.clipShape(MessageBubbleShape(position: position, outgoing: outgoing))
|
||||
.contentShape(.contextMenuPreview, MessageBubbleShape(position: position, outgoing: outgoing))
|
||||
.frame(maxWidth: maxBubbleWidth)
|
||||
}
|
||||
}
|
||||
|
||||
private func contextMenuReadStatus(for message: ChatMessage) -> String? {
|
||||
let outgoing = message.isFromMe(myPublicKey: currentPublicKey)
|
||||
guard outgoing, message.deliveryStatus == .read else { return nil }
|
||||
return "Read"
|
||||
}
|
||||
|
||||
func bubbleActions(for message: ChatMessage) -> [BubbleContextAction] {
|
||||
var actions: [BubbleContextAction] = []
|
||||
|
||||
actions.append(BubbleContextAction(
|
||||
title: "Reply",
|
||||
image: UIImage(systemName: "arrowshape.turn.up.left"),
|
||||
role: []
|
||||
) {
|
||||
self.replyingToMessage = message
|
||||
self.isInputFocused = true
|
||||
})
|
||||
|
||||
actions.append(BubbleContextAction(
|
||||
title: "Copy",
|
||||
image: UIImage(systemName: "doc.on.doc"),
|
||||
role: []
|
||||
) {
|
||||
UIPasteboard.general.string = message.text
|
||||
})
|
||||
|
||||
actions.append(BubbleContextAction(
|
||||
title: "Forward",
|
||||
image: UIImage(systemName: "arrowshape.turn.up.right"),
|
||||
role: []
|
||||
) {
|
||||
self.forwardingMessage = message
|
||||
self.showForwardPicker = true
|
||||
})
|
||||
|
||||
actions.append(BubbleContextAction(
|
||||
title: "Delete",
|
||||
image: UIImage(systemName: "trash"),
|
||||
role: .destructive
|
||||
) {
|
||||
self.messageToDelete = message
|
||||
})
|
||||
|
||||
return actions
|
||||
}
|
||||
|
||||
func retryMessage(_ message: ChatMessage) {
|
||||
let text = message.text
|
||||
let toKey = message.toPublicKey
|
||||
@@ -1235,8 +1657,107 @@ private extension ChatDetailView {
|
||||
DialogRepository.shared.reconcileAfterMessageDelete(opponentKey: message.toPublicKey)
|
||||
}
|
||||
|
||||
// MARK: - Reply Bar
|
||||
|
||||
@ViewBuilder
|
||||
func replyBar(for message: ChatMessage) -> some View {
|
||||
let senderName = message.isFromMe(myPublicKey: currentPublicKey)
|
||||
? "You"
|
||||
: (dialog?.opponentTitle ?? route.title)
|
||||
let previewText = message.text.isEmpty
|
||||
? (message.attachments.isEmpty ? "" : "Attachment")
|
||||
: message.text
|
||||
|
||||
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)
|
||||
Text(previewText)
|
||||
.font(.system(size: 14, weight: .regular))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.leading, 8)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
withAnimation(.easeOut(duration: 0.15)) {
|
||||
replyingToMessage = nil
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.frame(width: 30, height: 30)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
|
||||
// MARK: - Forward
|
||||
|
||||
func forwardMessage(_ message: ChatMessage, to targetRoute: ChatRoute) {
|
||||
// Desktop parity: forward uses same MESSAGES attachment as reply.
|
||||
// The forwarded message is encoded as a ReplyMessageData JSON blob.
|
||||
let forwardData = buildReplyData(from: message)
|
||||
let targetKey = targetRoute.publicKey
|
||||
Task { @MainActor in
|
||||
do {
|
||||
// Forward sends a space as text with the forwarded message as MESSAGES attachment
|
||||
try await SessionManager.shared.sendMessageWithReply(
|
||||
text: " ",
|
||||
replyMessages: [forwardData],
|
||||
toPublicKey: targetKey,
|
||||
opponentTitle: targetRoute.title,
|
||||
opponentUsername: targetRoute.username
|
||||
)
|
||||
} catch {
|
||||
sendError = "Failed to forward message"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a `ReplyMessageData` from a `ChatMessage` for reply/forward encoding.
|
||||
/// Desktop parity: `MessageReply` in `useReplyMessages.ts`.
|
||||
private func buildReplyData(from message: ChatMessage) -> ReplyMessageData {
|
||||
// Convert ChatMessage attachments to ReplyAttachmentData (text-only for now)
|
||||
let replyAttachments: [ReplyAttachmentData] = message.attachments.compactMap { att in
|
||||
// Skip MESSAGES attachments in nested replies (don't nest replies recursively)
|
||||
guard att.type != .messages else { return nil }
|
||||
return ReplyAttachmentData(
|
||||
id: att.id,
|
||||
type: att.type.rawValue,
|
||||
preview: att.preview,
|
||||
blob: "" // Blob cleared for reply (desktop parity)
|
||||
)
|
||||
}
|
||||
|
||||
return ReplyMessageData(
|
||||
message_id: message.id,
|
||||
publicKey: message.fromPublicKey,
|
||||
message: message.text,
|
||||
timestamp: message.timestamp,
|
||||
attachments: replyAttachments
|
||||
)
|
||||
}
|
||||
|
||||
/// PERF: static cache for formatted timestamps — avoids Date + DateFormatter per cell per render.
|
||||
@MainActor private static var timeCache: [Int64: String] = [:]
|
||||
|
||||
func messageTime(_ timestamp: Int64) -> String {
|
||||
Self.timeFormatter.string(from: Date(timeIntervalSince1970: Double(timestamp) / 1000))
|
||||
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) }
|
||||
Self.timeCache[timestamp] = result
|
||||
return result
|
||||
}
|
||||
|
||||
func scrollToBottom(proxy: ScrollViewProxy, animated: Bool) {
|
||||
@@ -1297,6 +1818,7 @@ private extension ChatDetailView {
|
||||
func sendCurrentMessage() {
|
||||
let message = trimmedMessage
|
||||
let attachments = pendingAttachments
|
||||
let replyMessage = replyingToMessage
|
||||
|
||||
// Must have either text or attachments
|
||||
guard !message.isEmpty || !attachments.isEmpty else { return }
|
||||
@@ -1306,6 +1828,7 @@ private extension ChatDetailView {
|
||||
shouldScrollOnNextMessage = true
|
||||
messageText = ""
|
||||
pendingAttachments = []
|
||||
replyingToMessage = nil
|
||||
sendError = nil
|
||||
// Desktop parity: delete draft after sending.
|
||||
DraftManager.shared.deleteDraft(for: route.publicKey)
|
||||
@@ -1321,6 +1844,16 @@ private extension ChatDetailView {
|
||||
opponentTitle: route.title,
|
||||
opponentUsername: route.username
|
||||
)
|
||||
} else if let replyMsg = replyMessage {
|
||||
// Desktop parity: reply sends MESSAGES attachment with quoted message JSON
|
||||
let replyData = buildReplyData(from: replyMsg)
|
||||
try await SessionManager.shared.sendMessageWithReply(
|
||||
text: message,
|
||||
replyMessages: [replyData],
|
||||
toPublicKey: route.publicKey,
|
||||
opponentTitle: route.title,
|
||||
opponentUsername: route.username
|
||||
)
|
||||
} else {
|
||||
// Text-only message (existing path)
|
||||
try await SessionManager.shared.sendMessage(
|
||||
@@ -1465,6 +1998,8 @@ enum TelegramIconPath {
|
||||
|
||||
static let chevronDown = #"M11.8854 11.6408C11.3964 12.1197 10.6036 12.1197 10.1145 11.6408L0.366765 2.09366C-0.122255 1.61471 -0.122255 0.838169 0.366765 0.359215C0.855786 -0.119739 1.64864 -0.119739 2.13767 0.359215L11 9.03912L19.8623 0.359215C20.3514 -0.119739 21.1442 -0.119739 21.6332 0.359215C22.1223 0.838169 22.1223 1.61471 21.6332 2.09366L11.8854 11.6408Z"#
|
||||
|
||||
static let replyArrow = #"M7.73438 12.6367C7.8125 12.5586 7.87109 12.4674 7.91016 12.3633C7.94922 12.2721 7.96875 12.168 7.96875 12.0508V9.375C9.375 9.375 10.5599 9.58333 11.5234 10C12.6172 10.4948 13.4635 11.276 14.0625 12.3438C14.1536 12.513 14.2773 12.6432 14.4336 12.7344C14.5768 12.8255 14.7526 12.8711 14.9609 12.8711C15.1172 12.8711 15.2604 12.819 15.3906 12.7148C15.4948 12.6237 15.5729 12.5065 15.625 12.3633C15.6771 12.2201 15.7031 12.0768 15.7031 11.9336C15.7031 10.6185 15.5599 9.45312 15.2734 8.4375C14.974 7.39583 14.5182 6.51042 13.9062 5.78125C13.6068 5.41667 13.2617 5.09115 12.8711 4.80469C12.4805 4.53125 12.0508 4.29688 11.582 4.10156C11.0482 3.88021 10.4557 3.72396 9.80469 3.63281C9.27083 3.55469 8.65885 3.51562 7.96875 3.51562V0.859375C7.96875 0.703125 7.92969 0.559896 7.85156 0.429688C7.78646 0.299479 7.68229 0.195312 7.53906 0.117188C7.40885 0.0390625 7.26562 0 7.10938 0C6.92708 0 6.74479 0.0520833 6.5625 0.15625C6.43229 0.234375 6.28255 0.351562 6.11328 0.507812L0.371094 5.68359C0.292969 5.7487 0.234375 5.8138 0.195312 5.87891C0.143229 5.94401 0.104167 6.00911 0.078125 6.07422C0.0520833 6.13932 0.0325521 6.19792 0.0195312 6.25C0.00651042 6.3151 0 6.3737 0 6.42578C0 6.49089 0.00651042 6.55599 0.0195312 6.62109C0.0325521 6.67318 0.0520833 6.73177 0.078125 6.79688C0.104167 6.86198 0.143229 6.92708 0.195312 6.99219C0.234375 7.05729 0.292969 7.1224 0.371094 7.1875L6.11328 12.4023C6.29557 12.5716 6.46484 12.6888 6.62109 12.7539C6.69922 12.793 6.77734 12.819 6.85547 12.832C6.94661 12.8581 7.03125 12.8711 7.10938 12.8711C7.22656 12.8711 7.33724 12.8516 7.44141 12.8125C7.55859 12.7734 7.65625 12.7148 7.73438 12.6367Z"#
|
||||
|
||||
static let microphone = #"M3.69141 5.09766C3.69141 4.16016 3.91602 3.30078 4.36523 2.51953C4.79492 1.75781 5.38086 1.14258 6.12305 0.673828C6.88477 0.224609 7.70508 0 8.58398 0C9.44336 0 10.2441 0.214844 10.9863 0.644531C11.7285 1.07422 12.3145 1.66016 12.7441 2.40234C13.1934 3.16406 13.4375 3.98438 13.4766 4.86328V5.09766V10.8105C13.4766 11.748 13.252 12.6074 12.8027 13.3887C12.373 14.1504 11.7871 14.7559 11.0449 15.2051C10.2832 15.6738 9.46289 15.9082 8.58398 15.9082C7.72461 15.9082 6.92383 15.6934 6.18164 15.2637C5.43945 14.834 4.85352 14.248 4.42383 13.5059C3.97461 12.7441 3.73047 11.9238 3.69141 11.0449V10.8105V5.09766ZM8.58398 1.58203C7.99805 1.58203 7.45117 1.72852 6.94336 2.02148C6.43555 2.31445 6.03516 2.71484 5.74219 3.22266C5.42969 3.73047 5.25391 4.28711 5.21484 4.89258V5.09766V10.8105C5.21484 11.4551 5.37109 12.0508 5.68359 12.5977C5.97656 13.125 6.37695 13.5449 6.88477 13.8574C7.41211 14.1699 7.97852 14.3262 8.58398 14.3262C9.16992 14.3262 9.7168 14.1797 10.2246 13.8867C10.7324 13.5938 11.1328 13.1934 11.4258 12.6855C11.7383 12.1777 11.9141 11.6211 11.9531 11.0156V10.8105V5.09766C11.9531 4.45312 11.7969 3.85742 11.4844 3.31055C11.1914 2.7832 10.791 2.36328 10.2832 2.05078C9.75586 1.73828 9.18945 1.58203 8.58398 1.58203ZM9.3457 19.7168V22.7637C9.3457 22.9785 9.26758 23.1641 9.11133 23.3203C8.97461 23.4766 8.79883 23.5547 8.58398 23.5547C8.38867 23.5547 8.22266 23.4863 8.08594 23.3496C7.92969 23.2324 7.8418 23.0762 7.82227 22.8809V22.7637V19.7168C6.74805 19.5996 5.72266 19.2969 4.74609 18.8086C3.80859 18.3203 2.98828 17.666 2.28516 16.8457C1.5625 16.0449 1.00586 15.1367 0.615234 14.1211C0.205078 13.0664 0 11.9629 0 10.8105C0 10.5957 0.078125 10.4102 0.234375 10.2539C0.390625 10.0977 0.566406 10.0195 0.761719 10.0195C0.976562 10.0195 1.16211 10.0977 1.31836 10.2539C1.45508 10.4102 1.52344 10.5957 1.52344 10.8105C1.52344 11.8066 1.70898 12.7637 2.08008 13.6816C2.45117 14.5605 2.95898 15.332 3.60352 15.9961C4.24805 16.6797 4.99023 17.207 5.83008 17.5781C6.70898 17.9688 7.62695 18.1641 8.58398 18.1641C9.54102 18.1641 10.459 17.9688 11.3379 17.5781C12.1777 17.207 12.9199 16.6797 13.5645 15.9961C14.209 15.332 14.7168 14.5605 15.0879 13.6816C15.459 12.7637 15.6445 11.8066 15.6445 10.8105C15.6445 10.5957 15.7129 10.4102 15.8496 10.2539C16.0059 10.0977 16.1914 10.0195 16.4062 10.0195C16.6016 10.0195 16.7773 10.0977 16.9336 10.2539C17.0898 10.4102 17.168 10.5957 17.168 10.8105C17.168 11.9629 16.9629 13.0664 16.5527 14.1211C16.1621 15.1367 15.6055 16.0449 14.8828 16.8457C14.1797 17.666 13.3594 18.3203 12.4219 18.8086C11.4453 19.2969 10.4199 19.5996 9.3457 19.7168Z"#
|
||||
}
|
||||
|
||||
@@ -1480,6 +2015,21 @@ private struct IgnoreKeyboardSafeAreaLegacy: ViewModifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS 26: scroll edge blur is on by default — in inverted scroll (scaleEffect y: -1)
|
||||
/// both top+bottom edge effects overlap and blur the entire screen.
|
||||
/// Hide only the ScrollView's top edge (= visual bottom after inversion, near composer).
|
||||
/// Keep ScrollView's bottom edge (= visual top after inversion, near nav bar) for a
|
||||
/// nice fade effect when scrolling through older messages.
|
||||
private struct DisableScrollEdgeEffectModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 26, *) {
|
||||
content.scrollEdgeEffectHidden(true, for: .top)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Sheet for picking a chat to forward a message to.
|
||||
/// Shows all existing dialogs sorted by last message time.
|
||||
struct ForwardChatPickerView: View {
|
||||
let onSelect: (ChatRoute) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private var dialogs: [Dialog] {
|
||||
DialogRepository.shared.sortedDialogs.filter {
|
||||
$0.iHaveSent || $0.isSavedMessages || SystemAccounts.isSystemAccount($0.opponentKey)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let _ = print("[ForwardPicker] body — dialogs count: \(dialogs.count)")
|
||||
NavigationStack {
|
||||
List(dialogs) { dialog in
|
||||
Button {
|
||||
onSelect(ChatRoute(dialog: dialog))
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
AvatarView(
|
||||
initials: dialog.initials,
|
||||
colorIndex: dialog.avatarColorIndex,
|
||||
size: 42,
|
||||
isSavedMessages: dialog.isSavedMessages
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 4) {
|
||||
Text(dialog.isSavedMessages ? "Saved Messages" : dialog.opponentTitle)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.lineLimit(1)
|
||||
|
||||
if dialog.effectiveVerified > 0 && !dialog.isSavedMessages {
|
||||
VerifiedBadge(verified: dialog.effectiveVerified, size: 14)
|
||||
}
|
||||
}
|
||||
|
||||
if !dialog.opponentUsername.isEmpty && !dialog.isSavedMessages {
|
||||
Text("@\(dialog.opponentUsername)")
|
||||
.font(.system(size: 13, weight: .regular))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.listRowBackground(RosettaColors.Dark.surface)
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(RosettaColors.Dark.background)
|
||||
.navigationTitle("Forward to...")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
}
|
||||
204
Rosetta/Features/Chats/ChatDetail/FullScreenImageViewer.swift
Normal file
204
Rosetta/Features/Chats/ChatDetail/FullScreenImageViewer.swift
Normal file
@@ -0,0 +1,204 @@
|
||||
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
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.scaleEffect(scale)
|
||||
.offset(x: offset.width, y: offset.height + dismissOffset)
|
||||
.gesture(dragGesture)
|
||||
.gesture(pinchGesture)
|
||||
.onTapGesture(count: 2) {
|
||||
doubleTap()
|
||||
}
|
||||
.onTapGesture(count: 1) {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showControls.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
// Close button
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
var body: some View {
|
||||
if let image = AttachmentCache.shared.loadImage(forAttachmentId: attachmentId) {
|
||||
FullScreenImageViewer(image: image, onDismiss: onDismiss)
|
||||
} else {
|
||||
// Cache miss — show error with close button
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,13 +130,11 @@ struct MessageAvatarView: View {
|
||||
return
|
||||
}
|
||||
|
||||
guard let password = message.attachmentPassword, !password.isEmpty else {
|
||||
print("🎭 [AvatarView] NO password for attachment \(attachment.id)")
|
||||
guard let storedPassword = message.attachmentPassword, !storedPassword.isEmpty else {
|
||||
downloadError = true
|
||||
return
|
||||
}
|
||||
|
||||
print("🎭 [AvatarView] Downloading avatar \(attachment.id), tag=\(tag.prefix(20))…")
|
||||
isDownloading = true
|
||||
downloadError = false
|
||||
|
||||
@@ -145,32 +143,11 @@ struct MessageAvatarView: View {
|
||||
let encryptedData = try await TransportManager.shared.downloadFile(tag: tag)
|
||||
let encryptedString = String(decoding: encryptedData, as: UTF8.self)
|
||||
|
||||
let decryptedData = try CryptoManager.shared.decryptWithPassword(
|
||||
encryptedString, password: password
|
||||
let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword)
|
||||
let downloadedImage = decryptAndParseImage(
|
||||
encryptedString: encryptedString, passwords: passwords
|
||||
)
|
||||
|
||||
guard let decryptedString = String(data: decryptedData, encoding: .utf8) else {
|
||||
throw TransportError.invalidResponse
|
||||
}
|
||||
|
||||
let downloadedImage: UIImage?
|
||||
if decryptedString.hasPrefix("data:") {
|
||||
if let commaIndex = decryptedString.firstIndex(of: ",") {
|
||||
let base64Part = String(decryptedString[decryptedString.index(after: commaIndex)...])
|
||||
if let imageData = Data(base64Encoded: base64Part) {
|
||||
downloadedImage = UIImage(data: imageData)
|
||||
} else {
|
||||
downloadedImage = nil
|
||||
}
|
||||
} else {
|
||||
downloadedImage = nil
|
||||
}
|
||||
} else if let imageData = Data(base64Encoded: decryptedString) {
|
||||
downloadedImage = UIImage(data: imageData)
|
||||
} else {
|
||||
downloadedImage = UIImage(data: decryptedData)
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
if let downloadedImage {
|
||||
avatarImage = downloadedImage
|
||||
@@ -189,6 +166,43 @@ struct MessageAvatarView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tries each password candidate and validates the decrypted content is a real image.
|
||||
private func decryptAndParseImage(encryptedString: String, passwords: [String]) -> UIImage? {
|
||||
let crypto = CryptoManager.shared
|
||||
for password in passwords {
|
||||
guard let data = try? crypto.decryptWithPassword(
|
||||
encryptedString, password: password, requireCompression: true
|
||||
) else { continue }
|
||||
if let img = parseImageData(data) { return img }
|
||||
}
|
||||
// Fallback: try without requireCompression (legacy uncompressed payloads)
|
||||
for password in passwords {
|
||||
guard let data = try? crypto.decryptWithPassword(
|
||||
encryptedString, password: password
|
||||
) else { continue }
|
||||
if let img = parseImageData(data) { return img }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Parses decrypted data as an image: data URI, plain base64, or raw image bytes.
|
||||
private func parseImageData(_ data: Data) -> UIImage? {
|
||||
if let str = String(data: data, encoding: .utf8) {
|
||||
if str.hasPrefix("data:"),
|
||||
let commaIndex = str.firstIndex(of: ",") {
|
||||
let base64Part = String(str[str.index(after: commaIndex)...])
|
||||
if let imageData = Data(base64Encoded: base64Part),
|
||||
let img = UIImage(data: imageData) {
|
||||
return img
|
||||
}
|
||||
} else if let imageData = Data(base64Encoded: str),
|
||||
let img = UIImage(data: imageData) {
|
||||
return img
|
||||
}
|
||||
}
|
||||
return UIImage(data: data)
|
||||
}
|
||||
|
||||
/// Extracts the server tag from preview string.
|
||||
/// Format: "tag::blurhash" → returns "tag".
|
||||
private func extractTag(from preview: String) -> String {
|
||||
|
||||
@@ -130,7 +130,7 @@ struct MessageFileView: View {
|
||||
|
||||
private func downloadFile() {
|
||||
guard !isDownloading, !fileTag.isEmpty else { return }
|
||||
guard let password = message.attachmentPassword, !password.isEmpty else {
|
||||
guard let storedPassword = message.attachmentPassword, !storedPassword.isEmpty else {
|
||||
downloadError = true
|
||||
return
|
||||
}
|
||||
@@ -142,9 +142,12 @@ struct MessageFileView: View {
|
||||
do {
|
||||
let encryptedData = try await TransportManager.shared.downloadFile(tag: fileTag)
|
||||
let encryptedString = String(decoding: encryptedData, as: UTF8.self)
|
||||
let decryptedData = try CryptoManager.shared.decryptWithPassword(
|
||||
encryptedString, password: password
|
||||
|
||||
let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword)
|
||||
let decryptedData = decryptFileData(
|
||||
encryptedString: encryptedString, passwords: passwords
|
||||
)
|
||||
guard let decryptedData else { throw TransportError.invalidResponse }
|
||||
|
||||
// Parse data URI if present, otherwise use raw data
|
||||
let fileData: Data
|
||||
@@ -175,6 +178,27 @@ struct MessageFileView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tries each password candidate with requireCompression to avoid wrong-key garbage.
|
||||
private func decryptFileData(encryptedString: String, passwords: [String]) -> Data? {
|
||||
let crypto = CryptoManager.shared
|
||||
for password in passwords {
|
||||
if let data = try? crypto.decryptWithPassword(
|
||||
encryptedString, password: password, requireCompression: true
|
||||
) {
|
||||
return data
|
||||
}
|
||||
}
|
||||
// Fallback: try without requireCompression (legacy uncompressed payloads)
|
||||
for password in passwords {
|
||||
if let data = try? crypto.decryptWithPassword(
|
||||
encryptedString, password: password
|
||||
) {
|
||||
return data
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Share
|
||||
|
||||
private func shareFile(_ url: URL) {
|
||||
|
||||
@@ -7,9 +7,13 @@ import SwiftUI
|
||||
/// Desktop parity: `MessageImage.tsx` — shows blur placeholder while downloading,
|
||||
/// full image after download, "Image expired" on error.
|
||||
///
|
||||
/// Modes:
|
||||
/// - **Standalone** (`collageSize == nil`): uses own min/max constraints + aspect ratio.
|
||||
/// - **Collage cell** (`collageSize != nil`): fills the given frame (parent controls size).
|
||||
///
|
||||
/// States:
|
||||
/// 1. **Cached** — image already in AttachmentCache, display immediately
|
||||
/// 2. **Downloading** — show placeholder + spinner
|
||||
/// 2. **Downloading** — show blurhash placeholder + spinner
|
||||
/// 3. **Downloaded** — display image, tap for full-screen (future)
|
||||
/// 4. **Error** — "Image expired" or download error
|
||||
struct MessageImageView: View {
|
||||
@@ -17,51 +21,53 @@ struct MessageImageView: View {
|
||||
let attachment: MessageAttachment
|
||||
let message: ChatMessage
|
||||
let outgoing: Bool
|
||||
|
||||
/// When set, the image fills this exact frame (used inside PhotoCollageView).
|
||||
/// When nil, standalone mode with own size constraints.
|
||||
var collageSize: CGSize? = nil
|
||||
|
||||
let maxWidth: CGFloat
|
||||
|
||||
/// Called when user taps a loaded image (opens full-screen viewer).
|
||||
var onImageTap: ((UIImage) -> Void)?
|
||||
|
||||
@State private var image: UIImage?
|
||||
@State private var blurImage: UIImage?
|
||||
@State private var isDownloading = false
|
||||
@State private var downloadError = false
|
||||
|
||||
/// Desktop parity: image bubble max dimensions.
|
||||
private let maxImageWidth: CGFloat = 240
|
||||
private let maxImageHeight: CGFloat = 280
|
||||
/// Whether this image is inside a collage (fills parent frame).
|
||||
private var isCollageCell: Bool { collageSize != nil }
|
||||
|
||||
/// Telegram-style image constraints (standalone mode only).
|
||||
private let maxImageWidth: CGFloat = 270
|
||||
private let maxImageHeight: CGFloat = 320
|
||||
private let minImageWidth: CGFloat = 140
|
||||
private let minImageHeight: CGFloat = 100
|
||||
|
||||
/// Default placeholder size (standalone mode).
|
||||
private let placeholderWidth: CGFloat = 200
|
||||
private let placeholderHeight: CGFloat = 200
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let image {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxWidth: min(maxImageWidth, maxWidth - 20))
|
||||
.frame(maxHeight: maxImageHeight)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
imageContent(image)
|
||||
} else if isDownloading {
|
||||
placeholder
|
||||
.overlay { ProgressView().tint(.white) }
|
||||
placeholderView
|
||||
.overlay { downloadingOverlay }
|
||||
} else if downloadError {
|
||||
placeholder
|
||||
.overlay {
|
||||
VStack(spacing: 4) {
|
||||
Image(systemName: "flame.fill")
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
Text("Image expired")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.white.opacity(0.4))
|
||||
}
|
||||
}
|
||||
placeholderView
|
||||
.overlay { errorOverlay }
|
||||
} else {
|
||||
placeholder
|
||||
.overlay {
|
||||
Image(systemName: "arrow.down.circle")
|
||||
.font(.system(size: 24))
|
||||
.foregroundStyle(.white.opacity(0.6))
|
||||
}
|
||||
placeholderView
|
||||
.overlay { downloadArrowOverlay }
|
||||
.onTapGesture { downloadImage() }
|
||||
}
|
||||
}
|
||||
.task {
|
||||
// Decode blurhash once (like Android's LaunchedEffect + Dispatchers.IO)
|
||||
decodeBlurHash()
|
||||
loadFromCache()
|
||||
if image == nil {
|
||||
downloadImage()
|
||||
@@ -69,12 +75,121 @@ struct MessageImageView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Overlay States (Desktop parity: MessageImage.tsx)
|
||||
|
||||
/// Desktop: dark 40x40 circle with ProgressView spinner.
|
||||
private var downloadingOverlay: some View {
|
||||
Circle()
|
||||
.fill(Color.black.opacity(0.3))
|
||||
.frame(width: 40, height: 40)
|
||||
.overlay {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
.scaleEffect(0.9)
|
||||
}
|
||||
}
|
||||
|
||||
/// Desktop: dark rounded pill with "Image expired" + flame icon.
|
||||
private var errorOverlay: some View {
|
||||
HStack(spacing: 4) {
|
||||
Text("Image expired")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.white)
|
||||
Image(systemName: "flame.fill")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.black.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
/// Desktop: dark 40x40 circle with download arrow icon.
|
||||
private var downloadArrowOverlay: some View {
|
||||
Circle()
|
||||
.fill(Color.black.opacity(0.3))
|
||||
.frame(width: 40, height: 40)
|
||||
.overlay {
|
||||
Image(systemName: "arrow.down")
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Image Content
|
||||
|
||||
@ViewBuilder
|
||||
private func imageContent(_ img: UIImage) -> some View {
|
||||
if let size = collageSize {
|
||||
// Collage mode: fill the given cell frame
|
||||
Image(uiImage: img)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: size.width, height: size.height)
|
||||
.clipped()
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { onImageTap?(img) }
|
||||
} else {
|
||||
// Standalone mode: respect aspect ratio constraints
|
||||
let size = constrainedSize(for: img)
|
||||
Image(uiImage: img)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: size.width, height: size.height)
|
||||
.clipped()
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { onImageTap?(img) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculates display size respecting min/max constraints and aspect ratio (standalone mode).
|
||||
private func constrainedSize(for img: UIImage) -> CGSize {
|
||||
let constrainedWidth = min(maxImageWidth, maxWidth)
|
||||
let aspectRatio = img.size.width / max(img.size.height, 1)
|
||||
let displayWidth = min(constrainedWidth, max(minImageWidth, img.size.width))
|
||||
let displayHeight = min(maxImageHeight, max(minImageHeight, displayWidth / aspectRatio))
|
||||
let finalWidth = min(constrainedWidth, displayHeight * aspectRatio)
|
||||
return CGSize(width: finalWidth, height: displayHeight)
|
||||
}
|
||||
|
||||
// MARK: - Placeholder
|
||||
|
||||
private var placeholder: some View {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color.white.opacity(0.08))
|
||||
.frame(width: 200, height: 150)
|
||||
@ViewBuilder
|
||||
private var placeholderView: some View {
|
||||
let size = resolvedPlaceholderSize
|
||||
if let blurImage {
|
||||
Image(uiImage: blurImage)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: size.width, height: size.height)
|
||||
.clipped()
|
||||
} else {
|
||||
Rectangle()
|
||||
.fill(Color.white.opacity(0.08))
|
||||
.frame(width: size.width, height: size.height)
|
||||
}
|
||||
}
|
||||
|
||||
/// Placeholder size: collage cell size if in collage, otherwise square default.
|
||||
private var resolvedPlaceholderSize: CGSize {
|
||||
if let size = collageSize {
|
||||
return size
|
||||
}
|
||||
let w = min(placeholderWidth, min(maxImageWidth, maxWidth))
|
||||
return CGSize(width: w, height: w)
|
||||
}
|
||||
|
||||
// MARK: - BlurHash Decoding
|
||||
|
||||
/// Decodes the blurhash from the attachment preview string once and caches in @State.
|
||||
/// Android parity: `LaunchedEffect(preview) { BlurHash.decode(preview, 200, 200) }`.
|
||||
private func decodeBlurHash() {
|
||||
let hash = extractBlurHash(from: attachment.preview)
|
||||
guard !hash.isEmpty else { return }
|
||||
if let result = UIImage.fromBlurHash(hash, width: 32, height: 32) {
|
||||
blurImage = result
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Download
|
||||
@@ -88,80 +203,42 @@ struct MessageImageView: View {
|
||||
private func downloadImage() {
|
||||
guard !isDownloading, image == nil else { return }
|
||||
|
||||
// Extract tag from preview ("tag::blurhash" → tag)
|
||||
let tag = extractTag(from: attachment.preview)
|
||||
guard !tag.isEmpty else {
|
||||
print("🖼️ [ImageView] tag is empty for attachment \(attachment.id)")
|
||||
downloadError = true
|
||||
return
|
||||
}
|
||||
|
||||
guard let password = message.attachmentPassword, !password.isEmpty else {
|
||||
print("🖼️ [ImageView] NO password for attachment \(attachment.id), preview=\(attachment.preview.prefix(40))")
|
||||
guard let storedPassword = message.attachmentPassword, !storedPassword.isEmpty else {
|
||||
downloadError = true
|
||||
return
|
||||
}
|
||||
|
||||
print("🖼️ [ImageView] Downloading attachment \(attachment.id), tag=\(tag.prefix(20))…, passwordLen=\(password.count)")
|
||||
|
||||
isDownloading = true
|
||||
downloadError = false
|
||||
|
||||
Task {
|
||||
do {
|
||||
// Download encrypted blob from transport server
|
||||
let encryptedData = try await TransportManager.shared.downloadFile(tag: tag)
|
||||
let encryptedString = String(decoding: encryptedData, as: UTF8.self)
|
||||
print("🖼️ [ImageView] Downloaded \(encryptedData.count) bytes, encryptedString.prefix=\(encryptedString.prefix(80))…")
|
||||
print("🖼️ [ImageView] Password UTF-8 bytes: \(Array(password.utf8).prefix(20).map { String(format: "%02x", $0) }.joined(separator: " "))")
|
||||
|
||||
// Decrypt with attachment password
|
||||
let decryptedData = try CryptoManager.shared.decryptWithPassword(
|
||||
encryptedString, password: password
|
||||
// Try each password candidate; validate decrypted content to avoid false positives
|
||||
// from wrong-key AES-CBC that randomly produces valid PKCS7 + passable inflate.
|
||||
let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword)
|
||||
let downloadedImage = decryptAndParseImage(
|
||||
encryptedString: encryptedString, passwords: passwords
|
||||
)
|
||||
|
||||
print("🖼️ [ImageView] Decrypted \(decryptedData.count) bytes, first20hex=\(decryptedData.prefix(20).map { String(format: "%02x", $0) }.joined(separator: " "))")
|
||||
|
||||
// Parse data URI → extract base64 → UIImage
|
||||
guard let decryptedString = String(data: decryptedData, encoding: .utf8) else {
|
||||
print("🖼️ [ImageView] ❌ Decrypted data is NOT valid UTF-8! first50hex=\(decryptedData.prefix(50).map { String(format: "%02x", $0) }.joined(separator: " "))")
|
||||
throw TransportError.invalidResponse
|
||||
}
|
||||
|
||||
let downloadedImage: UIImage?
|
||||
if decryptedString.hasPrefix("data:") {
|
||||
// Data URI format: "data:image/jpeg;base64,..."
|
||||
if let commaIndex = decryptedString.firstIndex(of: ",") {
|
||||
let base64Part = String(decryptedString[decryptedString.index(after: commaIndex)...])
|
||||
if let imageData = Data(base64Encoded: base64Part) {
|
||||
downloadedImage = UIImage(data: imageData)
|
||||
} else {
|
||||
downloadedImage = nil
|
||||
}
|
||||
} else {
|
||||
downloadedImage = nil
|
||||
}
|
||||
} else if let imageData = Data(base64Encoded: decryptedString) {
|
||||
// Plain base64 (fallback)
|
||||
downloadedImage = UIImage(data: imageData)
|
||||
} else {
|
||||
// Raw image data
|
||||
downloadedImage = UIImage(data: decryptedData)
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
if let downloadedImage {
|
||||
print("🖼️ [ImageView] ✅ Image decoded successfully for \(attachment.id)")
|
||||
image = downloadedImage
|
||||
AttachmentCache.shared.saveImage(downloadedImage, forAttachmentId: attachment.id)
|
||||
} else {
|
||||
print("🖼️ [ImageView] ❌ Failed to decode image data for \(attachment.id)")
|
||||
downloadError = true
|
||||
}
|
||||
isDownloading = false
|
||||
}
|
||||
} catch {
|
||||
print("🖼️ [ImageView] ❌ Error for \(attachment.id): \(error.localizedDescription)")
|
||||
await MainActor.run {
|
||||
downloadError = true
|
||||
isDownloading = false
|
||||
@@ -170,10 +247,59 @@ struct MessageImageView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tries each password candidate and validates the decrypted content is a real image.
|
||||
private func decryptAndParseImage(encryptedString: String, passwords: [String]) -> UIImage? {
|
||||
let crypto = CryptoManager.shared
|
||||
for password in passwords {
|
||||
guard let data = try? crypto.decryptWithPassword(
|
||||
encryptedString, password: password, requireCompression: true
|
||||
) else { continue }
|
||||
|
||||
if let img = parseImageData(data) { return img }
|
||||
}
|
||||
// Fallback: try without requireCompression (legacy uncompressed payloads)
|
||||
for password in passwords {
|
||||
guard let data = try? crypto.decryptWithPassword(
|
||||
encryptedString, password: password
|
||||
) else { continue }
|
||||
|
||||
if let img = parseImageData(data) { return img }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Parses decrypted data as an image: data URI, plain base64, or raw image bytes.
|
||||
private func parseImageData(_ data: Data) -> UIImage? {
|
||||
if let str = String(data: data, encoding: .utf8) {
|
||||
if str.hasPrefix("data:"),
|
||||
let commaIndex = str.firstIndex(of: ",") {
|
||||
let base64Part = String(str[str.index(after: commaIndex)...])
|
||||
if let imageData = Data(base64Encoded: base64Part),
|
||||
let img = UIImage(data: imageData) {
|
||||
return img
|
||||
}
|
||||
} else if let imageData = Data(base64Encoded: str),
|
||||
let img = UIImage(data: imageData) {
|
||||
return img
|
||||
}
|
||||
}
|
||||
// Raw image data
|
||||
return UIImage(data: data)
|
||||
}
|
||||
|
||||
// MARK: - Preview Parsing
|
||||
|
||||
/// Extracts the server tag from preview string.
|
||||
/// Format: "tag::blurhash" or "tag::" → returns "tag".
|
||||
private func extractTag(from preview: String) -> String {
|
||||
let parts = preview.components(separatedBy: "::")
|
||||
return parts.first ?? preview
|
||||
}
|
||||
|
||||
/// Extracts the blurhash from preview string.
|
||||
/// Format: "tag::blurhash" → returns "blurhash".
|
||||
private func extractBlurHash(from preview: String) -> String {
|
||||
let parts = preview.components(separatedBy: "::")
|
||||
return parts.count > 1 ? parts[1] : ""
|
||||
}
|
||||
}
|
||||
|
||||
243
Rosetta/Features/Chats/ChatDetail/PhotoCollageView.swift
Normal file
243
Rosetta/Features/Chats/ChatDetail/PhotoCollageView.swift
Normal file
@@ -0,0 +1,243 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - PhotoCollageView
|
||||
|
||||
/// Telegram-style photo collage layout for 1–5 image attachments.
|
||||
///
|
||||
/// Patterns:
|
||||
/// - 1 photo: full width, aspect ratio preserved
|
||||
/// - 2 photos: side by side, equal width, same height
|
||||
/// - 3 photos: large left (2/3) + two stacked right (1/3)
|
||||
/// - 4 photos: 2×2 grid
|
||||
/// - 5 photos: 2 top + 3 bottom
|
||||
///
|
||||
/// A thin `borderWidth` padding lets the parent bubble's background color
|
||||
/// show through as a colored border around the images (Telegram-style).
|
||||
/// Inner corners match the outer `MessageBubbleShape` radii minus `borderWidth`.
|
||||
struct PhotoCollageView: View {
|
||||
|
||||
let attachments: [MessageAttachment]
|
||||
let message: ChatMessage
|
||||
let outgoing: Bool
|
||||
let maxWidth: CGFloat
|
||||
let position: BubblePosition
|
||||
|
||||
/// Called when user taps a loaded image.
|
||||
var onImageTap: ((UIImage) -> Void)?
|
||||
|
||||
/// Padding between images and bubble edge — bubble background shows through.
|
||||
private let borderWidth: CGFloat = 2
|
||||
|
||||
/// Gap between images in a multi-image grid.
|
||||
private let spacing: CGFloat = 2
|
||||
|
||||
/// Bubble fill color — used as gap color between collage cells.
|
||||
private var bubbleColor: Color {
|
||||
outgoing ? RosettaColors.figmaBlue : Color(hex: 0x2C2C2E)
|
||||
}
|
||||
|
||||
/// Maximum collage height.
|
||||
private let maxCollageHeight: CGFloat = 320
|
||||
|
||||
var body: some View {
|
||||
let contentWidth = maxWidth - borderWidth * 2
|
||||
|
||||
collageContent(contentWidth: contentWidth)
|
||||
.clipShape(InnerBubbleClipShape(position: position, outgoing: outgoing, inset: borderWidth))
|
||||
.padding(borderWidth)
|
||||
}
|
||||
|
||||
// MARK: - Content Router
|
||||
|
||||
@ViewBuilder
|
||||
private func collageContent(contentWidth: CGFloat) -> some View {
|
||||
switch attachments.count {
|
||||
case 0:
|
||||
EmptyView()
|
||||
case 1:
|
||||
singleImage(contentWidth: contentWidth)
|
||||
case 2:
|
||||
twoImages(contentWidth: contentWidth)
|
||||
case 3:
|
||||
threeImages(contentWidth: contentWidth)
|
||||
case 4:
|
||||
fourImages(contentWidth: contentWidth)
|
||||
default:
|
||||
fiveImages(contentWidth: contentWidth)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 1 Photo: Full Width
|
||||
|
||||
private func singleImage(contentWidth: CGFloat) -> some View {
|
||||
MessageImageView(
|
||||
attachment: attachments[0],
|
||||
message: message,
|
||||
outgoing: outgoing,
|
||||
maxWidth: contentWidth,
|
||||
onImageTap: onImageTap
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 2 Photos: Side by Side
|
||||
|
||||
private func twoImages(contentWidth: CGFloat) -> some View {
|
||||
let cellWidth = (contentWidth - spacing) / 2
|
||||
let cellHeight = min(cellWidth * 1.2, maxCollageHeight)
|
||||
|
||||
return HStack(spacing: spacing) {
|
||||
collageCell(attachments[0], width: cellWidth, height: cellHeight)
|
||||
collageCell(attachments[1], width: cellWidth, height: cellHeight)
|
||||
}
|
||||
.frame(width: contentWidth, height: cellHeight)
|
||||
.background(bubbleColor)
|
||||
}
|
||||
|
||||
// MARK: - 3 Photos: 1 Large Left + 2 Stacked Right
|
||||
|
||||
private func threeImages(contentWidth: CGFloat) -> some View {
|
||||
let rightWidth = contentWidth * 0.34
|
||||
let leftWidth = contentWidth - spacing - rightWidth
|
||||
let totalHeight = min(leftWidth * 1.1, maxCollageHeight)
|
||||
let rightCellHeight = (totalHeight - spacing) / 2
|
||||
|
||||
return HStack(spacing: spacing) {
|
||||
collageCell(attachments[0], width: leftWidth, height: totalHeight)
|
||||
VStack(spacing: spacing) {
|
||||
collageCell(attachments[1], width: rightWidth, height: rightCellHeight)
|
||||
collageCell(attachments[2], width: rightWidth, height: rightCellHeight)
|
||||
}
|
||||
}
|
||||
.frame(width: contentWidth, height: totalHeight)
|
||||
.background(bubbleColor)
|
||||
}
|
||||
|
||||
// MARK: - 4 Photos: 2×2 Grid
|
||||
|
||||
private func fourImages(contentWidth: CGFloat) -> some View {
|
||||
let cellWidth = (contentWidth - spacing) / 2
|
||||
let cellHeight = min(cellWidth * 0.85, maxCollageHeight / 2)
|
||||
let totalHeight = cellHeight * 2 + spacing
|
||||
|
||||
return VStack(spacing: spacing) {
|
||||
HStack(spacing: spacing) {
|
||||
collageCell(attachments[0], width: cellWidth, height: cellHeight)
|
||||
collageCell(attachments[1], width: cellWidth, height: cellHeight)
|
||||
}
|
||||
HStack(spacing: spacing) {
|
||||
collageCell(attachments[2], width: cellWidth, height: cellHeight)
|
||||
collageCell(attachments[3], width: cellWidth, height: cellHeight)
|
||||
}
|
||||
}
|
||||
.frame(width: contentWidth, height: totalHeight)
|
||||
.background(bubbleColor)
|
||||
}
|
||||
|
||||
// MARK: - 5 Photos: 2 Top + 3 Bottom
|
||||
|
||||
private func fiveImages(contentWidth: CGFloat) -> some View {
|
||||
let topCellWidth = (contentWidth - spacing) / 2
|
||||
let bottomCellWidth = (contentWidth - spacing * 2) / 3
|
||||
let topHeight = min(topCellWidth * 0.85, maxCollageHeight * 0.55)
|
||||
let bottomHeight = min(bottomCellWidth * 0.85, maxCollageHeight * 0.45)
|
||||
let totalHeight = topHeight + spacing + bottomHeight
|
||||
|
||||
return VStack(spacing: spacing) {
|
||||
HStack(spacing: spacing) {
|
||||
collageCell(attachments[0], width: topCellWidth, height: topHeight)
|
||||
collageCell(attachments[1], width: topCellWidth, height: topHeight)
|
||||
}
|
||||
HStack(spacing: spacing) {
|
||||
collageCell(attachments[2], width: bottomCellWidth, height: bottomHeight)
|
||||
collageCell(attachments[3], width: bottomCellWidth, height: bottomHeight)
|
||||
collageCell(attachments[4], width: bottomCellWidth, height: bottomHeight)
|
||||
}
|
||||
}
|
||||
.frame(width: contentWidth, height: totalHeight)
|
||||
.background(bubbleColor)
|
||||
}
|
||||
|
||||
// MARK: - Collage Cell
|
||||
|
||||
@ViewBuilder
|
||||
private func collageCell(
|
||||
_ attachment: MessageAttachment,
|
||||
width: CGFloat,
|
||||
height: CGFloat
|
||||
) -> some View {
|
||||
MessageImageView(
|
||||
attachment: attachment,
|
||||
message: message,
|
||||
outgoing: outgoing,
|
||||
collageSize: CGSize(width: width, height: height),
|
||||
maxWidth: width,
|
||||
onImageTap: onImageTap
|
||||
)
|
||||
.frame(width: width, height: height)
|
||||
.clipped()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Inner Bubble Clip Shape
|
||||
|
||||
/// Rounded rect that mirrors `MessageBubbleShape` corner radii but inset by `inset`.
|
||||
/// Used to clip images inside the bubble so the border gap has matching corners.
|
||||
struct InnerBubbleClipShape: Shape {
|
||||
let position: BubblePosition
|
||||
let outgoing: Bool
|
||||
let inset: CGFloat
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let r: CGFloat = max(18 - inset, 0)
|
||||
let s: CGFloat = max(8 - inset, 0)
|
||||
let (tl, tr, bl, br) = cornerRadii(r: r, s: s)
|
||||
|
||||
let maxR = min(rect.width, rect.height) / 2
|
||||
let cTL = min(tl, maxR)
|
||||
let cTR = min(tr, maxR)
|
||||
let cBL = min(bl, maxR)
|
||||
let cBR = min(br, maxR)
|
||||
|
||||
var p = Path()
|
||||
p.move(to: CGPoint(x: rect.minX + cTL, y: rect.minY))
|
||||
|
||||
p.addLine(to: CGPoint(x: rect.maxX - cTR, y: rect.minY))
|
||||
p.addArc(tangent1End: CGPoint(x: rect.maxX, y: rect.minY),
|
||||
tangent2End: CGPoint(x: rect.maxX, y: rect.minY + cTR),
|
||||
radius: cTR)
|
||||
|
||||
p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - cBR))
|
||||
p.addArc(tangent1End: CGPoint(x: rect.maxX, y: rect.maxY),
|
||||
tangent2End: CGPoint(x: rect.maxX - cBR, y: rect.maxY),
|
||||
radius: cBR)
|
||||
|
||||
p.addLine(to: CGPoint(x: rect.minX + cBL, y: rect.maxY))
|
||||
p.addArc(tangent1End: CGPoint(x: rect.minX, y: rect.maxY),
|
||||
tangent2End: CGPoint(x: rect.minX, y: rect.maxY - cBL),
|
||||
radius: cBL)
|
||||
|
||||
p.addLine(to: CGPoint(x: rect.minX, y: rect.minY + cTL))
|
||||
p.addArc(tangent1End: CGPoint(x: rect.minX, y: rect.minY),
|
||||
tangent2End: CGPoint(x: rect.minX + cTL, y: rect.minY),
|
||||
radius: cTL)
|
||||
|
||||
p.closeSubpath()
|
||||
return p
|
||||
}
|
||||
|
||||
/// Same logic as `MessageBubbleShape.cornerRadii` but with inset-adjusted radii.
|
||||
private func cornerRadii(r: CGFloat, s: CGFloat)
|
||||
-> (topLeading: CGFloat, topTrailing: CGFloat,
|
||||
bottomLeading: CGFloat, bottomTrailing: CGFloat) {
|
||||
switch position {
|
||||
case .single:
|
||||
return (r, r, r, r)
|
||||
case .top:
|
||||
return outgoing ? (r, r, r, s) : (r, r, s, r)
|
||||
case .mid:
|
||||
return outgoing ? (r, s, r, s) : (s, r, s, r)
|
||||
case .bottom:
|
||||
return outgoing ? (r, s, r, r) : (s, r, r, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
143
Rosetta/Features/Chats/ChatDetail/SwipeToReplyModifier.swift
Normal file
143
Rosetta/Features/Chats/ChatDetail/SwipeToReplyModifier.swift
Normal file
@@ -0,0 +1,143 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// Telegram-style swipe-to-reply modifier for message bubbles.
|
||||
/// Adds a left-swipe gesture that offsets the bubble and reveals a reply arrow icon.
|
||||
/// On threshold crossing: light haptic feedback. On release past threshold: triggers reply.
|
||||
///
|
||||
/// Architecture: applied BETWEEN the inner bubble (`.frame(maxWidth: maxBubbleWidth)`)
|
||||
/// and the outer full-width frame (`.frame(maxWidth: .infinity)`). The `.offset(x:)`
|
||||
/// is visual-only (does not affect layout), so the `.overlay(alignment: .trailing)`
|
||||
/// added after it is positioned at the bubble's ORIGINAL trailing edge. As the bubble
|
||||
/// shifts left, the icon is revealed in the gap between the shifted bubble and the
|
||||
/// original position.
|
||||
struct SwipeToReplyModifier: ViewModifier {
|
||||
let onReply: () -> Void
|
||||
|
||||
@State private var offset: CGFloat = 0
|
||||
@State private var hasTriggeredHaptic = false
|
||||
@State private var lockedAxis: SwipeAxis?
|
||||
|
||||
private enum SwipeAxis { case horizontal, vertical }
|
||||
|
||||
/// Minimum drag distance to trigger reply action.
|
||||
private let threshold: CGFloat = 50
|
||||
/// Offset where elastic resistance begins.
|
||||
private let elasticCap: CGFloat = 80
|
||||
/// Reply icon circle diameter.
|
||||
private let iconSize: CGFloat = 34
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.offset(x: offset)
|
||||
.overlay(alignment: .trailing) {
|
||||
replyIndicator
|
||||
}
|
||||
.simultaneousGesture(dragGesture)
|
||||
}
|
||||
|
||||
// MARK: - Icon
|
||||
|
||||
/// Progress from 0 (hidden) to 1 (fully visible) based on drag offset.
|
||||
private var iconProgress: CGFloat {
|
||||
let absOffset = abs(offset)
|
||||
guard absOffset > 4 else { return 0 }
|
||||
return min((absOffset - 4) / (threshold - 4), 1)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var replyIndicator: some View {
|
||||
Circle()
|
||||
.fill(Color.white.opacity(0.12))
|
||||
.frame(width: iconSize, height: iconSize)
|
||||
.overlay {
|
||||
TelegramVectorIcon(
|
||||
pathData: TelegramIconPath.replyArrow,
|
||||
viewBox: CGSize(width: 16, height: 13),
|
||||
color: .white
|
||||
)
|
||||
.frame(width: 14, height: 11)
|
||||
}
|
||||
.scaleEffect(iconProgress)
|
||||
.opacity(iconProgress)
|
||||
}
|
||||
|
||||
// MARK: - Gesture
|
||||
|
||||
private var dragGesture: some Gesture {
|
||||
DragGesture(minimumDistance: 16, coordinateSpace: .local)
|
||||
.onChanged { value in
|
||||
// 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)
|
||||
// AND must be leftward — right swipe is back navigation.
|
||||
let isLeftward = value.translation.width < 0
|
||||
lockedAxis = (dx > dy * 2 && isLeftward) ? .horizontal : .vertical
|
||||
}
|
||||
}
|
||||
|
||||
guard lockedAxis == .horizontal else { return }
|
||||
|
||||
// Only left swipe (negative)
|
||||
let raw = min(value.translation.width, 0)
|
||||
guard raw < 0 else {
|
||||
if offset != 0 { offset = 0 }
|
||||
return
|
||||
}
|
||||
|
||||
// Elastic resistance past cap
|
||||
let absRaw = abs(raw)
|
||||
if absRaw > elasticCap {
|
||||
let excess = absRaw - elasticCap
|
||||
offset = -(elasticCap + excess * 0.15)
|
||||
} else {
|
||||
offset = raw
|
||||
}
|
||||
|
||||
// Haptic at threshold (once per gesture)
|
||||
if abs(offset) >= threshold, !hasTriggeredHaptic {
|
||||
hasTriggeredHaptic = true
|
||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||
}
|
||||
}
|
||||
.onEnded { _ in
|
||||
let shouldReply = abs(offset) >= threshold
|
||||
withAnimation(.spring(response: 0.32, dampingFraction: 0.7)) {
|
||||
offset = 0
|
||||
}
|
||||
lockedAxis = nil
|
||||
hasTriggeredHaptic = false
|
||||
if shouldReply {
|
||||
onReply()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Conditionally applies swipe-to-reply. When disabled, passes content through unchanged
|
||||
/// (no gesture, no icon, no animation).
|
||||
struct ConditionalSwipeToReply: ViewModifier {
|
||||
let enabled: Bool
|
||||
let onReply: () -> Void
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if enabled {
|
||||
content.modifier(SwipeToReplyModifier(onReply: onReply))
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - View Extension
|
||||
|
||||
extension View {
|
||||
/// Adds Telegram-style swipe-to-reply gesture to a message bubble.
|
||||
func swipeToReply(onReply: @escaping () -> Void) -> some View {
|
||||
modifier(SwipeToReplyModifier(onReply: onReply))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user