Кросс-платформенное шифрование фото/аватаров, профиль собеседника, вложения в чате

This commit is contained in:
2026-03-16 05:57:07 +05:00
parent dd4642f251
commit 624038915d
43 changed files with 5212 additions and 656 deletions

View File

@@ -0,0 +1,716 @@
import SwiftUI
import Photos
import PhotosUI
// MARK: - AttachmentPanelView
/// Figma-style bottom sheet for selecting photos and files to attach to a message.
///
/// Figma nodes: 3994:39103 (panel), 4758:50706 (tab bar).
///
/// Structure:
/// - Toolbar: X button (left) + "Recents" title (centered)
/// - Content: Photo grid (Gallery) or File picker (File)
/// - Tab bar: [Gallery] [File] [Avatar] glass capsule background
/// - Send button: appears when items selected, shows count
///
/// Desktop parity: `DialogInput.tsx` attachment menu (paperclip) + file dialog.
struct AttachmentPanelView: View {
let onSend: ([PendingAttachment], String) -> Void
let onSendAvatar: () -> Void
@Environment(\.dismiss) private var dismiss
@State private var selectedTab: AttachmentTab = .gallery
@State private var selectedAssets: [PHAsset] = []
@State private var showCamera = false
@State private var showFilePicker = false
@State private var capturedImage: UIImage?
@State private var captionText: String = ""
@State private var previewAsset: IdentifiableAsset?
private var hasSelection: Bool { !selectedAssets.isEmpty }
var body: some View {
ZStack(alignment: .bottom) {
// Dark surface background (#1C1C1E NOT pure black, so sheet rounded
// corners are visible against the app's black background behind)
Color(hex: 0x1C1C1E).ignoresSafeArea()
VStack(spacing: 0) {
// Grabber + Toolbar
toolbar
// Content
switch selectedTab {
case .gallery:
PhotoGridView(
selectedAssets: $selectedAssets,
maxSelection: PendingAttachment.maxAttachmentsPerMessage,
onCameraTap: { showCamera = true },
onPhotoPreview: { asset in
previewAsset = IdentifiableAsset(asset: asset)
}
)
case .file:
fileTabContent
case .avatar:
// Avatar is an action tab handled in tabButton tap
Spacer()
}
Spacer(minLength: 0)
}
// Bottom: Tab bar + Send button
bottomBar
}
.sheet(isPresented: $showCamera) {
CameraPickerView { image in
capturedImage = image
handleCapturedImage(image)
}
.ignoresSafeArea()
}
.sheet(isPresented: $showFilePicker) {
DocumentPickerView { urls in
handlePickedFiles(urls)
}
}
.fullScreenCover(item: $previewAsset) { item in
PhotoPreviewView(
asset: item.asset,
isSelected: selectedAssets.contains(where: { $0.localIdentifier == item.id }),
selectionNumber: selectedAssets.firstIndex(where: { $0.localIdentifier == item.id }).map { $0 + 1 },
captionText: $captionText,
onSend: { image in
let caption = captionText
let attachment = PendingAttachment.fromImage(image)
onSend([attachment], caption)
dismiss()
},
onToggleSelect: {
if let idx = selectedAssets.firstIndex(where: { $0.localIdentifier == item.id }) {
selectedAssets.remove(at: idx)
} else if selectedAssets.count < PendingAttachment.maxAttachmentsPerMessage {
selectedAssets.append(item.asset)
}
}
)
.background(TransparentFullScreenBackground())
}
.presentationDetents([.medium, .large])
.presentationDragIndicator(.hidden)
.attachmentCornerRadius(20)
.preferredColorScheme(.dark)
}
// MARK: - Toolbar (Telegram-style: dark surface header)
/// Dark surface header matching Telegram attachment panel.
/// Custom grabber + 44pt close button (Figma: node 4764-50752) + centered title.
private var toolbar: some View {
VStack(spacing: 0) {
// Custom drag indicator (replaces system .presentationDragIndicator)
Capsule()
.fill(Color.white.opacity(0.3))
.frame(width: 36, height: 5)
.padding(.top, 5)
.padding(.bottom, 14)
// Close button + centered title
HStack {
// Close button (same glass style as GlassBackButton, but with X icon)
Button {
dismiss()
} label: {
CloseIconShape()
.fill(.white)
.frame(width: 14, height: 14)
.frame(width: 44, height: 44)
}
.background { closeButtonGlass }
.clipShape(Circle())
Spacer()
// Title with dropdown chevron (Telegram-style, ~20pt semibold)
HStack(spacing: 5) {
Text(tabTitle)
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(.white)
Image(systemName: "chevron.down")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(.white.opacity(0.45))
}
Spacer()
// Right side: selection badge (deselects all) or invisible spacer
if hasSelection {
Button {
withAnimation(.easeInOut(duration: 0.25)) {
selectedAssets.removeAll()
}
} label: {
selectionBadge
}
.transition(.scale.combined(with: .opacity))
} else {
// Invisible spacer to balance close button width (keeps title centered)
Color.clear
.frame(width: 44, height: 44)
}
}
.padding(.horizontal, 8)
.padding(.bottom, 10)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: hasSelection)
}
}
/// Selection count badge blue capsule with + count. Tapping deselects all.
private var selectionBadge: some View {
HStack(spacing: 2) {
Image(systemName: "checkmark")
.font(.system(size: 11, weight: .bold))
Text("\(selectedAssets.count)")
.font(.system(size: 12, weight: .bold))
}
.foregroundStyle(.white)
.frame(width: 44, height: 28)
.background(Color(hex: 0x008BFF), in: Capsule())
}
/// Glass circle background matching GlassBackButton (ButtonStyles.swift lines 2234).
@ViewBuilder
private var closeButtonGlass: some View {
if #available(iOS 26, *) {
Circle()
.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)
}
}
/// Title text changes based on selected tab.
private var tabTitle: String {
switch selectedTab {
case .gallery: return "Recents"
case .file: return "File"
case .avatar: return "Avatar"
}
}
// MARK: - File Tab Content
private var fileTabContent: some View {
VStack(spacing: 20) {
Spacer()
Image(systemName: "doc.fill")
.font(.system(size: 48))
.foregroundStyle(.white.opacity(0.3))
Text("Select a file to send")
.font(.system(size: 16))
.foregroundStyle(.white.opacity(0.5))
Button {
showFilePicker = true
} label: {
Text("Browse Files")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white)
.padding(.horizontal, 24)
.padding(.vertical, 10)
.background(Color(hex: 0x008BFF), in: Capsule())
}
Spacer()
}
.frame(maxWidth: .infinity)
.task {
// Auto-open file picker when File tab is selected
try? await Task.sleep(for: .milliseconds(300))
showFilePicker = true
}
}
// MARK: - Bottom Bar
private var bottomBar: some View {
VStack(spacing: 0) {
if hasSelection {
// Caption input bar (replaces tab bar when photos selected)
captionInputBar
.padding(.horizontal, 16)
.padding(.bottom, 12)
.transition(.opacity.combined(with: .move(edge: .bottom)))
} else {
// Tab bar (Figma: node 4758:50706 glass capsule)
tabBar
.padding(.horizontal, 25)
.padding(.bottom, 12)
.transition(.opacity.combined(with: .move(edge: .bottom)))
}
}
.animation(.easeInOut(duration: 0.25), value: hasSelection)
.background(
LinearGradient(
stops: [
.init(color: .clear, location: 0),
.init(color: .black.opacity(0.6), location: 0.3),
.init(color: .black, location: 0.8),
],
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea(edges: .bottom)
)
}
// MARK: - Caption Input Bar (matches ChatDetail composer style)
/// Caption input bar shown when photos are selected.
/// Matches ChatDetailView's composer capsule: `.thinMaterial` glass rounded rect.
/// Layout: [text field] [emoji] [send button]
private var captionInputBar: some View {
HStack(spacing: 0) {
// Caption text field
TextField("Add a caption...", text: $captionText)
.font(.system(size: 16))
.foregroundStyle(.white)
.tint(Color(hex: 0x008BFF))
.padding(.leading, 6)
// Emoji icon (exact ChatDetail match: TelegramVectorIcon emojiMoon)
TelegramVectorIcon(
pathData: TelegramIconPath.emojiMoon,
viewBox: CGSize(width: 19, height: 19),
color: RosettaColors.Adaptive.textSecondary
)
.frame(width: 19, height: 19)
.frame(width: 20, height: 36)
.padding(.trailing, 8)
// Send button (exact ChatDetail match: 38×36 capsule with sendPlane icon)
Button {
sendSelectedPhotos()
} label: {
TelegramVectorIcon(
pathData: TelegramIconPath.sendPlane,
viewBox: CGSize(width: 22, height: 19),
color: .white
)
.frame(width: 22, height: 19)
.frame(width: 38, height: 36)
.background { Capsule().fill(Color(hex: 0x008BFF)) }
}
}
.padding(3)
.frame(minHeight: 42, alignment: .bottom)
.background { captionBarBackground }
}
/// Glass background matching ChatDetailView's composer (`.thinMaterial` + stroke + shadow).
@ViewBuilder
private var captionBarBackground: some View {
if #available(iOS 26, *) {
RoundedRectangle(cornerRadius: 21, style: .continuous)
.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)
}
}
// MARK: - Tab Bar (Figma: glass capsule, 3 tabs)
/// Glass capsule tab bar matching RosettaTabBar pattern.
/// Tabs: Gallery | File | Avatar.
/// Colors from RosettaTabBar: selected=#008BFF, unselected=white.
/// Background: .regularMaterial (iOS < 26) / .glassEffect (iOS 26+).
private var tabBar: some View {
HStack(spacing: 0) {
tabButton(.gallery, icon: "photo.fill", label: "Gallery")
tabButton(.file, icon: "doc.fill", label: "File")
tabButton(.avatar, icon: "person.crop.circle.fill", label: "Avatar")
}
.padding(4)
.background { tabBarBackground }
.clipShape(Capsule())
.tabBarShadow()
}
/// Glass background matching RosettaTabBar (lines 136149).
@ViewBuilder
private var tabBarBackground: some View {
if #available(iOS 26, *) {
// iOS 26+ native liquid glass
Capsule()
.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)
)
}
}
/// Individual tab button matching RosettaTabBar dimensions exactly.
/// Icon: 22pt regular (frame height 28), Label: 10pt, VStack spacing: 2, padding: 6pt.
private func tabButton(_ tab: AttachmentTab, icon: String, label: String) -> some View {
let isSelected = selectedTab == tab
return Button {
if tab == .avatar {
// Avatar is an action tab immediately sends avatar + dismisses
onSendAvatar()
dismiss()
} else {
withAnimation(.easeInOut(duration: 0.2)) {
selectedTab = tab
}
}
} label: {
VStack(spacing: 2) {
Image(systemName: icon)
.font(.system(size: 22, weight: .regular))
.frame(height: 28)
Text(label)
.font(.system(size: 10, weight: isSelected ? .bold : .medium))
}
// RosettaTabBar colors: selected=#008BFF, unselected=white
.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()
)
}
.buttonStyle(.plain)
}
// MARK: - Actions
private func sendSelectedPhotos() {
let assets = selectedAssets
let caption = captionText
let manager = PHImageManager.default()
Task {
var attachments: [PendingAttachment] = []
for asset in assets {
if let image = await loadFullImage(asset: asset, manager: manager) {
attachments.append(PendingAttachment.fromImage(image))
}
}
await MainActor.run {
onSend(attachments, caption)
dismiss()
}
}
}
private func handleCapturedImage(_ image: UIImage) {
let attachment = PendingAttachment.fromImage(image)
onSend([attachment], "")
dismiss()
}
private func handlePickedFiles(_ urls: [URL]) {
var attachments: [PendingAttachment] = []
for url in urls.prefix(PendingAttachment.maxAttachmentsPerMessage) {
guard url.startAccessingSecurityScopedResource() else { continue }
defer { url.stopAccessingSecurityScopedResource() }
if let data = try? Data(contentsOf: url) {
attachments.append(PendingAttachment.fromFile(
data: data,
fileName: url.lastPathComponent
))
}
}
if !attachments.isEmpty {
onSend(attachments, "")
dismiss()
}
}
private func loadFullImage(asset: PHAsset, manager: PHImageManager) async -> UIImage? {
await withCheckedContinuation { continuation in
let options = PHImageRequestOptions()
options.deliveryMode = .highQualityFormat
options.isNetworkAccessAllowed = true
options.isSynchronous = false
manager.requestImage(
for: asset,
targetSize: PHImageManagerMaximumSize,
contentMode: .default,
options: options
) { image, info in
let isDegraded = (info?[PHImageResultIsDegradedKey] as? Bool) ?? false
if !isDegraded {
continuation.resume(returning: image)
}
}
}
}
}
// MARK: - AttachmentTab
private enum AttachmentTab: Hashable {
case gallery
case file
case avatar
}
// MARK: - IdentifiableAsset
/// Wrapper to make PHAsset usable with SwiftUI `.fullScreenCover(item:)`.
struct IdentifiableAsset: Identifiable {
let id: String
let asset: PHAsset
init(asset: PHAsset) {
self.id = asset.localIdentifier
self.asset = asset
}
}
// MARK: - Close Icon (Figma SVG: node 4764-50752)
/// Custom X icon matching Figma close button design.
/// SVG source: 20×20 viewport, fill path. Rendered at ~14pt inside 44pt circle.
private struct CloseIconShape: Shape {
func path(in rect: CGRect) -> Path {
let sx = rect.width / 19.6289
let sy = rect.height / 19.6289
func pt(_ x: CGFloat, _ y: CGFloat) -> CGPoint {
CGPoint(x: x * sx, y: y * sy)
}
var p = Path()
p.move(to: pt(17.7734, 0.3174))
p.addCurve(to: pt(18.1396, 0.0732), control1: pt(17.8711, 0.2035), control2: pt(17.9932, 0.1221))
p.addCurve(to: pt(18.5547, 0), control1: pt(18.2699, 0.0244), control2: pt(18.4082, 0))
p.addCurve(to: pt(18.9453, 0.0732), control1: pt(18.6849, 0), control2: pt(18.8151, 0.0244))
p.addCurve(to: pt(19.3115, 0.3174), control1: pt(19.0918, 0.1221), control2: pt(19.2139, 0.2035))
p.addCurve(to: pt(19.5557, 0.6836), control1: pt(19.4255, 0.415), control2: pt(19.5068, 0.5371))
p.addCurve(to: pt(19.6289, 1.0742), control1: pt(19.6045, 0.8138), control2: pt(19.6289, 0.944))
p.addCurve(to: pt(19.5557, 1.4893), control1: pt(19.6289, 1.2207), control2: pt(19.6045, 1.3591))
p.addCurve(to: pt(19.3115, 1.8555), control1: pt(19.5068, 1.6357), control2: pt(19.4255, 1.7578))
p.addLine(to: pt(11.3525, 9.8145))
p.addLine(to: pt(19.3115, 17.7734))
p.addCurve(to: pt(19.5557, 18.1396), control1: pt(19.4255, 17.8711), control2: pt(19.5068, 17.9932))
p.addCurve(to: pt(19.6289, 18.5547), control1: pt(19.6045, 18.2699), control2: pt(19.6289, 18.4082))
p.addCurve(to: pt(19.5557, 18.9453), control1: pt(19.6289, 18.6849), control2: pt(19.6045, 18.8151))
p.addCurve(to: pt(19.3115, 19.3115), control1: pt(19.5068, 19.0918), control2: pt(19.4255, 19.2139))
p.addCurve(to: pt(18.9453, 19.5557), control1: pt(19.2139, 19.4255), control2: pt(19.0918, 19.5068))
p.addCurve(to: pt(18.5547, 19.6289), control1: pt(18.8151, 19.6045), control2: pt(18.6849, 19.6289))
p.addCurve(to: pt(18.1396, 19.5557), control1: pt(18.4082, 19.6289), control2: pt(18.2699, 19.6045))
p.addCurve(to: pt(17.7734, 19.3115), control1: pt(17.9932, 19.5068), control2: pt(17.8711, 19.4255))
p.addLine(to: pt(9.8145, 11.3525))
p.addLine(to: pt(1.8555, 19.3115))
p.addCurve(to: pt(1.4893, 19.5557), control1: pt(1.7578, 19.4255), control2: pt(1.6357, 19.5068))
p.addCurve(to: pt(1.0742, 19.6289), control1: pt(1.3591, 19.6045), control2: pt(1.2207, 19.6289))
p.addCurve(to: pt(0.6836, 19.5557), control1: pt(0.944, 19.6289), control2: pt(0.8138, 19.6045))
p.addCurve(to: pt(0.3174, 19.3115), control1: pt(0.5371, 19.5068), control2: pt(0.415, 19.4255))
p.addCurve(to: pt(0.0732, 18.9453), control1: pt(0.2035, 19.2139), control2: pt(0.1221, 19.0918))
p.addCurve(to: pt(0, 18.5547), control1: pt(0.0244, 18.8151), control2: pt(0, 18.6849))
p.addCurve(to: pt(0.0732, 18.1396), control1: pt(0, 18.4082), control2: pt(0.0244, 18.2699))
p.addCurve(to: pt(0.3174, 17.7734), control1: pt(0.1221, 17.9932), control2: pt(0.2035, 17.8711))
p.addLine(to: pt(8.2764, 9.8145))
p.addLine(to: pt(0.3174, 1.8555))
p.addCurve(to: pt(0.0732, 1.4893), control1: pt(0.2035, 1.7578), control2: pt(0.1221, 1.6357))
p.addCurve(to: pt(0, 1.0742), control1: pt(0.0244, 1.3591), control2: pt(0, 1.2207))
p.addCurve(to: pt(0.0732, 0.6836), control1: pt(0, 0.944), control2: pt(0.0244, 0.8138))
p.addCurve(to: pt(0.3174, 0.3174), control1: pt(0.1221, 0.5371), control2: pt(0.2035, 0.415))
p.addCurve(to: pt(0.6836, 0.0732), control1: pt(0.415, 0.2035), control2: pt(0.5371, 0.1221))
p.addCurve(to: pt(1.0742, 0), control1: pt(0.8138, 0.0244), control2: pt(0.944, 0))
p.addCurve(to: pt(1.4893, 0.0732), control1: pt(1.2207, 0), control2: pt(1.3591, 0.0244))
p.addCurve(to: pt(1.8555, 0.3174), control1: pt(1.6357, 0.1221), control2: pt(1.7578, 0.2035))
p.addLine(to: pt(9.8145, 8.2764))
p.addLine(to: pt(17.7734, 0.3174))
p.closeSubpath()
return p
}
}
// MARK: - Presentation Corner Radius (iOS 16.4+)
/// Sets a custom corner radius on the sheet presentation.
/// `.presentationCornerRadius` is iOS 16.4+, so this modifier guards availability.
private struct AttachmentCornerRadiusModifier: ViewModifier {
let radius: CGFloat
func body(content: Content) -> some View {
if #available(iOS 16.4, *) {
content.presentationCornerRadius(radius)
} else {
content
}
}
}
private extension View {
func attachmentCornerRadius(_ radius: CGFloat) -> some View {
modifier(AttachmentCornerRadiusModifier(radius: radius))
}
}
// MARK: - Tab Bar Shadow Modifier
/// Shadow for iOS < 26 tab bar (matches RosettaTabBar's TabBarShadowModifier).
private struct AttachmentTabBarShadowModifier: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 26, *) {
content
} else {
content
.shadow(color: .black.opacity(0.12), radius: 20, y: 8)
}
}
}
private extension View {
func tabBarShadow() -> some View {
modifier(AttachmentTabBarShadowModifier())
}
}
// MARK: - Preview Background Modifier (iOS 16.4+)
/// Sets a custom background on the sheet presentation.
/// `.presentationBackground` is iOS 16.4+, so this modifier guards availability.
private struct PreviewBackgroundModifier<S: ShapeStyle>: ViewModifier {
let style: S
func body(content: Content) -> some View {
if #available(iOS 16.4, *) {
content.presentationBackground(style)
} else {
content
}
}
}
private extension View {
func previewBackground<S: ShapeStyle>(_ style: S) -> some View {
modifier(PreviewBackgroundModifier(style: style))
}
}
// MARK: - CameraPickerView
/// UIKit camera wrapper for taking photos.
struct CameraPickerView: UIViewControllerRepresentable {
let onCapture: (UIImage) -> Void
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.sourceType = .camera
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(onCapture: onCapture)
}
final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let onCapture: (UIImage) -> Void
init(onCapture: @escaping (UIImage) -> Void) {
self.onCapture = onCapture
}
func imagePickerController(
_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
) {
if let image = info[.originalImage] as? UIImage {
onCapture(image)
}
picker.dismiss(animated: true)
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
picker.dismiss(animated: true)
}
}
}
// MARK: - DocumentPickerView
/// UIKit document picker wrapper for selecting files.
struct DocumentPickerView: UIViewControllerRepresentable {
let onPick: ([URL]) -> Void
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.item])
picker.allowsMultipleSelection = true
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(onPick: onPick)
}
final class Coordinator: NSObject, UIDocumentPickerDelegate {
let onPick: ([URL]) -> Void
init(onPick: @escaping ([URL]) -> Void) {
self.onPick = onPick
}
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
onPick(urls)
}
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
// No-op user cancelled
}
}
}
// MARK: - Transparent FullScreen Background
/// UIKit hack to make `.fullScreenCover` background transparent.
/// Walks the view hierarchy to find the presentation container and sets
/// its background to clear, allowing the presenting view to show through.
struct TransparentFullScreenBackground: UIViewRepresentable {
func makeUIView(context: Context) -> UIView {
let view = UIView()
view.backgroundColor = .clear
DispatchQueue.main.async {
view.superview?.superview?.backgroundColor = .clear
}
return view
}
func updateUIView(_ uiView: UIView, context: Context) {}
}

View File

@@ -0,0 +1,123 @@
import SwiftUI
// MARK: - AttachmentPreviewStrip
/// Horizontal strip above the compositor showing selected attachments before send.
///
/// Desktop parity: `DialogInput.tsx` renders `DialogAttachment` components in a
/// flex row above the input field when `attachments.length > 0`.
///
/// Each item shows a thumbnail (images) or file card (files) with a red X remove button.
struct AttachmentPreviewStrip: View {
@Binding var pendingAttachments: [PendingAttachment]
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(pendingAttachments) { attachment in
attachmentPreview(attachment)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 6)
}
}
@ViewBuilder
private func attachmentPreview(_ attachment: PendingAttachment) -> some View {
ZStack(alignment: .topTrailing) {
switch attachment.type {
case .image:
imagePreview(attachment)
case .file:
filePreview(attachment)
default:
EmptyView()
}
// Remove button (Figma: red circle with X)
Button {
withAnimation(.easeInOut(duration: 0.2)) {
pendingAttachments.removeAll { $0.id == attachment.id }
}
} label: {
Image(systemName: "xmark")
.font(.system(size: 9, weight: .bold))
.foregroundStyle(.white)
.frame(width: 18, height: 18)
.background(Color.red, in: Circle())
}
.offset(x: 4, y: -4)
}
}
@ViewBuilder
private func imagePreview(_ attachment: PendingAttachment) -> some View {
if let thumbnail = attachment.thumbnail {
Image(uiImage: thumbnail)
.resizable()
.scaledToFill()
.frame(width: 70, height: 70)
.clipShape(RoundedRectangle(cornerRadius: 8))
} else {
RoundedRectangle(cornerRadius: 8)
.fill(Color.white.opacity(0.1))
.frame(width: 70, height: 70)
.overlay {
Image(systemName: "photo")
.foregroundStyle(.white.opacity(0.4))
}
}
}
@ViewBuilder
private func filePreview(_ attachment: PendingAttachment) -> some View {
VStack(spacing: 4) {
Image(systemName: fileIcon(for: attachment.fileName ?? "file"))
.font(.system(size: 22))
.foregroundStyle(Color(hex: 0x008BFF))
Text(attachment.fileName ?? "file")
.font(.system(size: 9))
.foregroundStyle(.white.opacity(0.8))
.lineLimit(2)
.multilineTextAlignment(.center)
Text(formatFileSize(attachment.fileSize ?? 0))
.font(.system(size: 8))
.foregroundStyle(.white.opacity(0.4))
}
.frame(width: 70, height: 70)
.background(Color.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 8))
}
// MARK: - Helpers
/// Returns an appropriate SF Symbol name for the file extension.
private func fileIcon(for fileName: String) -> String {
let ext = (fileName as NSString).pathExtension.lowercased()
switch ext {
case "pdf": return "doc.fill"
case "zip", "rar", "7z": return "doc.zipper"
case "jpg", "jpeg", "png", "gif", "webp": return "photo.fill"
case "mp4", "mov", "avi": return "film.fill"
case "mp3", "wav", "aac": return "waveform"
case "doc", "docx": return "doc.text.fill"
case "xls", "xlsx": return "tablecells.fill"
case "txt": return "doc.plaintext.fill"
default: return "doc.fill"
}
}
/// Formats byte count to human-readable string.
private func formatFileSize(_ bytes: Int) -> String {
if bytes < 1024 {
return "\(bytes) B"
} else if bytes < 1024 * 1024 {
return String(format: "%.1f KB", Double(bytes) / 1024)
} else {
return String(format: "%.1f MB", Double(bytes) / (1024 * 1024))
}
}
}

View File

@@ -0,0 +1,195 @@
import AVFoundation
import SwiftUI
// MARK: - CameraPreviewView
/// Live camera preview using AVCaptureSession, wrapped for SwiftUI.
///
/// Figma: Camera tile in attachment panel shows a real-time rear camera feed.
/// Uses `.medium` preset for minimal memory/battery usage (it's just a preview).
///
/// Graceful fallback: on Simulator or when camera unavailable, shows a dark
/// placeholder with a camera icon.
struct CameraPreviewView: UIViewRepresentable {
func makeUIView(context: Context) -> CameraPreviewUIView {
let view = CameraPreviewUIView()
return view
}
func updateUIView(_ uiView: CameraPreviewUIView, context: Context) {}
static func dismantleUIView(_ uiView: CameraPreviewUIView, coordinator: ()) {
uiView.stopSession()
}
}
// MARK: - CameraPreviewUIView
/// UIKit view hosting AVCaptureVideoPreviewLayer for live camera feed.
///
/// Permission request is deferred to `didMoveToWindow()` the system dialog
/// needs an active window to present on. Requesting in `init()` is too early.
final class CameraPreviewUIView: UIView {
private let captureSession = AVCaptureSession()
private var previewLayer: AVCaptureVideoPreviewLayer?
private var isSessionRunning = false
private var isSetUp = false
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor(white: 0.08, alpha: 1)
clipsToBounds = true
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
previewLayer?.frame = bounds
}
override func didMoveToWindow() {
super.didMoveToWindow()
if window != nil {
// First time: request permission + set up session
// Subsequent times: just resume
if !isSetUp {
isSetUp = true
requestAccessAndSetup()
} else {
startSession()
}
} else {
stopSession()
}
}
// MARK: - Permission + Session Setup
private func requestAccessAndSetup() {
#if targetEnvironment(simulator)
addPlaceholder()
return
#else
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
configureSession()
case .notDetermined:
// System dialog will present over the current window
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
DispatchQueue.main.async {
if granted {
self?.configureSession()
} else {
self?.addDeniedPlaceholder()
}
}
}
default:
addDeniedPlaceholder()
}
#endif
}
/// Configures AVCaptureSession with rear camera input + preview layer.
/// Called only after camera authorization is confirmed.
private func configureSession() {
guard let device = AVCaptureDevice.default(
.builtInWideAngleCamera, for: .video, position: .back
) else {
addPlaceholder()
return
}
do {
let input = try AVCaptureDeviceInput(device: device)
captureSession.beginConfiguration()
captureSession.sessionPreset = .medium
if captureSession.canAddInput(input) {
captureSession.addInput(input)
}
captureSession.commitConfiguration()
let preview = AVCaptureVideoPreviewLayer(session: captureSession)
preview.videoGravity = .resizeAspectFill
preview.frame = bounds
layer.addSublayer(preview)
previewLayer = preview
startSession()
} catch {
addPlaceholder()
}
}
func startSession() {
guard !isSessionRunning, !captureSession.inputs.isEmpty else { return }
isSessionRunning = true
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.captureSession.startRunning()
}
}
func stopSession() {
guard isSessionRunning else { return }
isSessionRunning = false
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.captureSession.stopRunning()
}
}
// MARK: - Placeholders
/// Generic placeholder (simulator / no camera hardware).
private func addPlaceholder() {
let iconConfig = UIImage.SymbolConfiguration(pointSize: 32, weight: .regular)
let icon = UIImageView(image: UIImage(systemName: "camera.fill", withConfiguration: iconConfig))
icon.tintColor = UIColor.white.withAlphaComponent(0.5)
icon.translatesAutoresizingMaskIntoConstraints = false
addSubview(icon)
NSLayoutConstraint.activate([
icon.centerXAnchor.constraint(equalTo: centerXAnchor),
icon.centerYAnchor.constraint(equalTo: centerYAnchor),
])
}
/// Placeholder shown when camera access is denied includes "Tap to enable" hint.
private func addDeniedPlaceholder() {
let stack = UIStackView()
stack.axis = .vertical
stack.alignment = .center
stack.spacing = 4
stack.translatesAutoresizingMaskIntoConstraints = false
let iconConfig = UIImage.SymbolConfiguration(pointSize: 24, weight: .regular)
let icon = UIImageView(image: UIImage(systemName: "camera.fill", withConfiguration: iconConfig))
icon.tintColor = UIColor.white.withAlphaComponent(0.4)
let label = UILabel()
label.text = "Enable Camera"
label.font = .systemFont(ofSize: 10, weight: .medium)
label.textColor = UIColor.white.withAlphaComponent(0.4)
stack.addArrangedSubview(icon)
stack.addArrangedSubview(label)
addSubview(stack)
NSLayoutConstraint.activate([
stack.centerXAnchor.constraint(equalTo: centerXAnchor),
stack.centerYAnchor.constraint(equalTo: centerYAnchor),
])
// Tap opens Settings
let tap = UITapGestureRecognizer(target: self, action: #selector(openSettings))
addGestureRecognizer(tap)
}
@objc private func openSettings() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url)
}
}

View File

@@ -38,6 +38,23 @@ private struct KeyboardPaddedView<Content: View>: View {
}
}
/// Shifts empty state content up by half the keyboard height to keep it
/// centered in the visible area above keyboard. Uses `.offset` (visual-only)
/// instead of frame height changes that would leak layout to the compositor overlay.
/// Observation-isolated: keyboard changes re-render only this wrapper.
private struct EmptyStateKeyboardOffset<Content: View>: View {
@ObservedObject private var keyboard = KeyboardTracker.shared
let content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
content.offset(y: -keyboard.keyboardPadding / 2)
}
}
struct ChatDetailView: View {
let route: ChatRoute
var onPresentedChange: ((Bool) -> Void)? = nil
@@ -62,6 +79,9 @@ struct ChatDetailView: View {
/// Captured on chat open ID of the first unread incoming message (for separator).
@State private var firstUnreadMessageId: String?
@State private var isSendingAvatar = false
@State private var showAttachmentPanel = false
@State private var pendingAttachments: [PendingAttachment] = []
@State private var showOpponentProfile = false
private var currentPublicKey: String {
SessionManager.shared.currentPublicKey
@@ -108,11 +128,11 @@ struct ChatDetailView: View {
}
private var canSend: Bool {
!trimmedMessage.isEmpty
!trimmedMessage.isEmpty || !pendingAttachments.isEmpty
}
private var shouldShowSendButton: Bool {
!messageText.isEmpty
!messageText.isEmpty || !pendingAttachments.isEmpty
}
private var sendButtonProgress: CGFloat {
@@ -239,6 +259,117 @@ struct ChatDetailView: View {
var body: some View {
content
.navigationDestination(isPresented: $showOpponentProfile) {
OpponentProfileView(route: route)
}
}
}
// MARK: - Toolbar Content (observation-isolated)
/// Reads `DialogRepository` in its own observation scope for title/subtitle/verified.
/// Dialog mutations (from ANY chat) no longer cascade to ChatDetailView body,
/// preventing all visible message cells from re-evaluating.
private struct ChatDetailPrincipal: View {
let route: ChatRoute
@ObservedObject var viewModel: ChatDetailViewModel
private var dialog: Dialog? {
DialogRepository.shared.dialogs[route.publicKey]
}
private var badgeSpacing: CGFloat {
if #available(iOS 26, *) { return 3 } else { return 4 }
}
private var badgeSize: CGFloat {
if #available(iOS 26, *) { return 12 } else { return 14 }
}
private var titleText: String {
if route.isSavedMessages { return "Saved Messages" }
if let dialog, !dialog.opponentTitle.isEmpty { return dialog.opponentTitle }
if !route.title.isEmpty { return route.title }
if let dialog, !dialog.opponentUsername.isEmpty { return "@\(dialog.opponentUsername)" }
if !route.username.isEmpty { return "@\(route.username)" }
return String(route.publicKey.prefix(12))
}
private var effectiveVerified: Int {
if let dialog { return dialog.effectiveVerified }
if route.verified > 0 { return route.verified }
return 0
}
private var subtitleText: String {
if route.isSavedMessages { return "" }
if route.isSystemAccount { return "official account" }
if viewModel.isTyping { return "typing..." }
if let dialog, dialog.isOnline { return "online" }
return "offline"
}
private var subtitleColor: Color {
if viewModel.isTyping { return RosettaColors.primaryBlue }
if dialog?.isOnline == true { return RosettaColors.online }
return RosettaColors.Adaptive.textSecondary
}
var body: some View {
VStack(spacing: 1) {
HStack(spacing: badgeSpacing) {
Text(titleText)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1)
if !route.isSavedMessages && effectiveVerified > 0 {
VerifiedBadge(verified: effectiveVerified, size: badgeSize)
}
}
if !subtitleText.isEmpty {
Text(subtitleText)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(subtitleColor)
.lineLimit(1)
}
}
}
}
/// Reads `DialogRepository` and `AvatarRepository` in its own observation scope
/// for avatar initials/color/image. Isolated from ChatDetailView body.
private struct ChatDetailToolbarAvatar: View {
let route: ChatRoute
let size: CGFloat
private var dialog: Dialog? {
DialogRepository.shared.dialogs[route.publicKey]
}
private var titleText: String {
if route.isSavedMessages { return "Saved Messages" }
if let dialog, !dialog.opponentTitle.isEmpty { return dialog.opponentTitle }
if !route.title.isEmpty { return route.title }
if let dialog, !dialog.opponentUsername.isEmpty { return "@\(dialog.opponentUsername)" }
if !route.username.isEmpty { return "@\(route.username)" }
return String(route.publicKey.prefix(12))
}
var body: some View {
let avatar = AvatarRepository.shared.loadAvatar(publicKey: route.publicKey)
let initials = route.isSavedMessages ? "S" : RosettaColors.initials(name: titleText, publicKey: route.publicKey)
let colorIndex = RosettaColors.avatarColorIndex(for: titleText, publicKey: route.publicKey)
AvatarView(
initials: initials,
colorIndex: colorIndex,
size: size,
isOnline: false,
isSavedMessages: route.isSavedMessages,
image: avatar
)
}
}
@@ -265,52 +396,25 @@ private extension ChatDetailView {
}
ToolbarItem(placement: .principal) {
Button { dismiss() } label: {
VStack(spacing: 1) {
HStack(spacing: 3) {
Text(titleText)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1)
if !route.isSavedMessages && effectiveVerified > 0 {
VerifiedBadge(verified: effectiveVerified, size: 12)
}
Button { openProfile() } label: {
ChatDetailPrincipal(route: route, viewModel: viewModel)
.padding(.horizontal, 12)
.frame(minWidth: 120)
.frame(height: 44)
.background {
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
}
if !subtitleText.isEmpty {
Text(subtitleText)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(
isTyping
? RosettaColors.primaryBlue
: (dialog?.isOnline == true)
? RosettaColors.online
: RosettaColors.Adaptive.textSecondary
)
.lineLimit(1)
}
}
.padding(.horizontal, 12)
.frame(minWidth: 120)
.frame(height: 44)
.background {
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
}
}
.buttonStyle(.plain)
}
ToolbarItem(placement: .navigationBarTrailing) {
AvatarView(
initials: avatarInitials,
colorIndex: avatarColorIndex,
size: 35,
isOnline: false,
isSavedMessages: route.isSavedMessages,
image: opponentAvatar
)
.frame(width: 36, height: 36)
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
Button { openProfile() } label: {
ChatDetailToolbarAvatar(route: route, size: 35)
.frame(width: 36, height: 36)
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
}
.buttonStyle(.plain)
}
} else {
// iOS < 26 capsule back button, larger avatar, .thinMaterial
@@ -321,53 +425,25 @@ private extension ChatDetailView {
}
ToolbarItem(placement: .principal) {
Button { dismiss() } label: {
VStack(spacing: 1) {
HStack(spacing: 4) {
Text(titleText)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1)
if !route.isSavedMessages && effectiveVerified > 0 {
VerifiedBadge(verified: effectiveVerified, size: 14)
}
Button { openProfile() } label: {
ChatDetailPrincipal(route: route, viewModel: viewModel)
.padding(.horizontal, 16)
.frame(minWidth: 120)
.frame(height: 44)
.background {
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
}
if !subtitleText.isEmpty {
Text(subtitleText)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(
isTyping
? RosettaColors.primaryBlue
: (dialog?.isOnline == true)
? RosettaColors.online
: RosettaColors.Adaptive.textSecondary
)
.lineLimit(1)
}
}
.padding(.horizontal, 16)
.frame(minWidth: 120)
.frame(height: 44)
.background {
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
}
}
.buttonStyle(.plain)
}
ToolbarItem(placement: .navigationBarTrailing) {
AvatarView(
initials: avatarInitials,
colorIndex: avatarColorIndex,
size: 38,
isOnline: false,
isSavedMessages: route.isSavedMessages,
image: opponentAvatar
)
.frame(width: 44, height: 44)
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
Button { openProfile() } label: {
ChatDetailToolbarAvatar(route: route, size: 38)
.frame(width: 44, height: 44)
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
}
.buttonStyle(.plain)
}
}
}
@@ -478,38 +554,52 @@ private extension ChatDetailView {
}
private var emptyStateView: some View {
VStack(spacing: 16) {
AvatarView(
initials: avatarInitials,
colorIndex: avatarColorIndex,
size: 80,
isOnline: dialog?.isOnline ?? false,
isSavedMessages: route.isSavedMessages,
image: opponentAvatar
)
// EmptyStateKeyboardOffset applies offset(y: -keyboardPadding/2) which is
// visual-only does NOT affect layout. Keeps content centered in the visible
// area above keyboard without leaking layout changes to the compositor overlay.
EmptyStateKeyboardOffset {
VStack(spacing: 0) {
Spacer()
VStack(spacing: 4) {
Text(titleText)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
VStack(spacing: 16) {
AvatarView(
initials: avatarInitials,
colorIndex: avatarColorIndex,
size: 80,
isOnline: dialog?.isOnline ?? false,
isSavedMessages: route.isSavedMessages,
image: opponentAvatar
)
if !route.isSavedMessages {
Text(subtitleText)
.font(.system(size: 14, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
VStack(spacing: 4) {
Text(titleText)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
if !route.isSavedMessages {
Text(subtitleText)
.font(.system(size: 14, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
}
}
Text(route.isSavedMessages
? "Save messages here for quick access"
: "No messages yet")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.7))
.padding(.top, 4)
}
}
Text(route.isSavedMessages
? "Save messages here for quick access"
: "No messages yet")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.7))
.padding(.top, 4)
Spacer()
// Reserve space for compositor so content centers above it.
Color.clear.frame(height: composerHeight)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
.onTapGesture { isInputFocused = false }
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
.onTapGesture { isInputFocused = false }
}
@ViewBuilder
@@ -628,9 +718,25 @@ private extension ChatDetailView {
@ViewBuilder
func messageRow(_ message: ChatMessage, maxBubbleWidth: CGFloat, position: BubblePosition) -> some View {
let outgoing = message.isFromMe(myPublicKey: currentPublicKey)
let messageText = message.text.isEmpty ? " " : message.text
let hasTail = position == .single || position == .bottom
// 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)
}
}
/// Text-only message bubble (original design).
@ViewBuilder
private func textOnlyBubble(message: ChatMessage, outgoing: Bool, hasTail: Bool, maxBubbleWidth: CGFloat, position: BubblePosition) -> some View {
let messageText = message.text.isEmpty ? " " : message.text
// 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))
@@ -645,25 +751,7 @@ private extension ChatDetailView {
.padding(.vertical, 5)
.frame(minWidth: outgoing ? 86 : 66, alignment: .leading)
.overlay(alignment: .bottomTrailing) {
HStack(spacing: 3) {
Text(messageTime(message.timestamp))
.font(.system(size: 11, weight: .regular))
.foregroundStyle(
outgoing
? Color.white.opacity(0.55)
: RosettaColors.Adaptive.textSecondary.opacity(0.6)
)
if outgoing {
if message.deliveryStatus == .error {
errorMenu(for: message)
} else {
deliveryIndicator(message.deliveryStatus)
}
}
}
.padding(.trailing, 11)
.padding(.bottom, 5)
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)
@@ -678,18 +766,139 @@ private extension ChatDetailView {
.padding(.bottom, 0)
}
// MARK: - Markdown Parsing
/// Attachment message bubble: images/files with optional text caption.
@ViewBuilder
private func attachmentBubble(
message: ChatMessage,
attachments: [MessageAttachment],
outgoing: Bool,
hasTail: Bool,
maxBubbleWidth: CGFloat,
position: BubblePosition
) -> some View {
let hasCaption = !message.text.trimmingCharacters(in: .whitespaces).isEmpty && message.text != " "
/// Parses inline markdown (`**bold**`) from runtime strings.
/// Falls back to plain `AttributedString` if parsing fails.
VStack(alignment: .leading, spacing: 0) {
// Attachment views
ForEach(attachments, 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(.top, 4)
case .avatar:
MessageAvatarView(
attachment: attachment,
message: message,
outgoing: outgoing
)
.padding(.horizontal, 6)
.padding(.top, 4)
default:
EmptyView()
}
}
// Caption text (if any)
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, 4)
.padding(.bottom, 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) }
.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.
@ViewBuilder
private func timestampOverlay(message: ChatMessage, outgoing: Bool) -> some View {
HStack(spacing: 3) {
Text(messageTime(message.timestamp))
.font(.system(size: 11, weight: .regular))
.foregroundStyle(
outgoing
? Color.white.opacity(0.55)
: RosettaColors.Adaptive.textSecondary.opacity(0.6)
)
if outgoing {
if message.deliveryStatus == .error {
errorMenu(for: message)
} else {
deliveryIndicator(message.deliveryStatus)
}
}
}
.padding(.trailing, 11)
.padding(.bottom, 5)
}
// MARK: - Text Parsing (Markdown + Emoji)
/// Static cache for parsed markdown + emoji. Message text is immutable,
/// so results never need invalidation. Bounded at 200 entries (~5 chats).
/// Without cache, regex + markdown parser runs on EVERY body evaluation
/// for EVERY visible cell expensive at 120Hz scroll.
@MainActor private static var markdownCache: [String: AttributedString] = [:]
/// Parses inline markdown (`**bold**`) and emoji shortcodes (`:emoji_CODE:`)
/// from runtime strings. Emoji shortcodes are replaced BEFORE markdown parsing
/// so that emoji characters render inline with formatted text.
///
/// Desktop parity: `TextParser.tsx` pattern `/:emoji_([a-zA-Z0-9_-]+):/`
/// Android parity: `unifiedToEmoji()` in `AppleEmojiPicker.kt`
private func parsedMarkdown(_ text: String) -> AttributedString {
if let cached = Self.markdownCache[text] { return cached }
// Cross-platform: replace :emoji_CODE: shortcodes with native Unicode emoji.
let withEmoji = EmojiParser.replaceShortcodes(in: text)
let result: AttributedString
if let parsed = try? AttributedString(
markdown: text,
markdown: withEmoji,
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
) {
return parsed
result = parsed
} else {
result = AttributedString(withEmoji)
}
return AttributedString(text)
if Self.markdownCache.count > 200 {
Self.markdownCache.removeAll(keepingCapacity: true)
}
Self.markdownCache[text] = result
return result
}
// MARK: - Unread Separator
@@ -710,6 +919,11 @@ private extension ChatDetailView {
var composer: some View {
VStack(spacing: 6) {
// Attachment preview strip shows selected images/files before send
if !pendingAttachments.isEmpty {
AttachmentPreviewStrip(pendingAttachments: $pendingAttachments)
}
if let sendError {
Text(sendError)
.font(.system(size: 12))
@@ -719,15 +933,9 @@ private extension ChatDetailView {
}
HStack(alignment: .bottom, spacing: 0) {
// Desktop parity: paperclip opens attachment menu with camera option.
// Camera sends current user's avatar to this chat.
Menu {
Button {
sendAvatarToChat()
} label: {
Label("Send Avatar", systemImage: "camera.fill")
}
.disabled(isSendingAvatar)
// Desktop parity: paperclip opens attachment panel (photo gallery + file picker).
Button {
showAttachmentPanel = true
} label: {
TelegramVectorIcon(
pathData: TelegramIconPath.paperclip,
@@ -740,6 +948,21 @@ 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(
@@ -923,6 +1146,13 @@ private extension ChatDetailView {
// MARK: - Actions / utils
/// Opens the opponent profile sheet.
/// For Saved Messages and system accounts no profile to show.
func openProfile() {
guard !route.isSavedMessages, !route.isSystemAccount else { return }
showOpponentProfile = true
}
func trailingAction() {
if canSend { sendCurrentMessage() }
else { isInputFocused = true }
@@ -942,34 +1172,29 @@ private extension ChatDetailView {
func deliveryTint(_ status: DeliveryStatus) -> Color {
switch status {
case .read: return Color(hex: 0xA4E2FF)
case .delivered: return Color.white.opacity(0.94)
case .delivered: return Color.white.opacity(0.5)
case .error: return RosettaColors.error
default: return Color.white.opacity(0.78)
}
}
func deliveryIcon(_ status: DeliveryStatus) -> String {
switch status {
case .waiting: return "clock"
case .delivered: return "checkmark"
case .read: return "checkmark"
case .error: return "exclamationmark.circle.fill"
}
}
@ViewBuilder
func deliveryIndicator(_ status: DeliveryStatus) -> some View {
switch status {
case .read:
ZStack {
Image(systemName: "checkmark").offset(x: 3)
Image(systemName: "checkmark")
}
.font(.system(size: 9.5, weight: .semibold))
.foregroundStyle(deliveryTint(status))
.frame(width: 12, alignment: .trailing)
default:
Image(systemName: deliveryIcon(status))
DoubleCheckmarkShape()
.fill(deliveryTint(status))
.frame(width: 16, height: 8.7)
case .delivered:
SingleCheckmarkShape()
.fill(deliveryTint(status))
.frame(width: 12, height: 8.8)
case .waiting:
Image(systemName: "clock")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(deliveryTint(status))
case .error:
Image(systemName: "exclamationmark.circle.fill")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(deliveryTint(status))
}
@@ -1071,23 +1296,40 @@ private extension ChatDetailView {
func sendCurrentMessage() {
let message = trimmedMessage
guard !message.isEmpty else { return }
let attachments = pendingAttachments
// 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 = []
sendError = nil
// Desktop parity: delete draft after sending.
DraftManager.shared.deleteDraft(for: route.publicKey)
Task { @MainActor in
do {
try await SessionManager.shared.sendMessage(
text: message,
toPublicKey: route.publicKey,
opponentTitle: route.title,
opponentUsername: route.username
)
if !attachments.isEmpty {
// Send message with attachments
try await SessionManager.shared.sendMessageWithAttachments(
text: message,
attachments: attachments,
toPublicKey: route.publicKey,
opponentTitle: route.title,
opponentUsername: route.username
)
} else {
// Text-only message (existing path)
try await SessionManager.shared.sendMessage(
text: message,
toPublicKey: route.publicKey,
opponentTitle: route.title,
opponentUsername: route.username
)
}
} catch {
sendError = "Failed to send message"
if messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
@@ -1097,6 +1339,15 @@ private extension ChatDetailView {
}
}
/// Handles attachments selected from the attachment panel.
/// Always sends immediately no preview step.
func handleAttachmentsSend(_ attachments: [PendingAttachment]) {
let remaining = PendingAttachment.maxAttachmentsPerMessage - pendingAttachments.count
let toAdd = Array(attachments.prefix(remaining))
pendingAttachments.append(contentsOf: toAdd)
sendCurrentMessage()
}
/// Desktop parity: onClickCamera() sends current user's avatar to this chat.
func sendAvatarToChat() {
guard !isSendingAvatar else { return }
@@ -1191,7 +1442,7 @@ private struct ChatDetailGlassCirclePressStyle: ButtonStyle {
// MARK: - SVG
private struct TelegramVectorIcon: View {
struct TelegramVectorIcon: View {
let pathData: String
let viewBox: CGSize
let color: Color
@@ -1202,193 +1453,8 @@ private struct TelegramVectorIcon: View {
}
}
private struct SVGPathShape: Shape {
let pathData: String
let viewBox: CGSize
func path(in rect: CGRect) -> Path {
guard viewBox.width > 0, viewBox.height > 0 else { return Path() }
var parser = SVGPathParser(pathData: pathData)
var output = Path(parser.parse())
output = output.applying(.init(scaleX: rect.width / viewBox.width, y: rect.height / viewBox.height))
return output
}
}
private enum SVGPathToken {
case command(Character)
case number(CGFloat)
}
private struct SVGPathTokenizer {
static func tokenize(_ source: String) -> [SVGPathToken] {
var tokens: [SVGPathToken] = []
let chars = Array(source)
var index = 0
while index < chars.count {
let ch = chars[index]
if ch.isWhitespace || ch == "," { index += 1; continue }
if ch.isLetter { tokens.append(.command(ch)); index += 1; continue }
if ch.isNumber || ch == "-" || ch == "+" || ch == "." {
let start = index
index += 1
while index < chars.count {
let c = chars[index]
let prev = chars[index - 1]
if c.isNumber || c == "." || c == "e" || c == "E" { index += 1; continue }
if (c == "-" || c == "+"), (prev == "e" || prev == "E") { index += 1; continue }
break
}
let fragment = String(chars[start..<index])
if let value = Double(fragment) { tokens.append(.number(CGFloat(value))) }
continue
}
index += 1
}
return tokens
}
}
private struct SVGPathParser {
private let tokens: [SVGPathToken]
private var index: Int = 0
private var lastCommand: Character = "M"
private var current = CGPoint.zero
private var subpathStart = CGPoint.zero
private var cgPath = CGMutablePath()
init(pathData: String) {
self.tokens = SVGPathTokenizer.tokenize(pathData)
}
mutating func parse() -> CGPath {
while index < tokens.count {
let command = readCommandOrReuse()
switch command {
case "M", "m": parseMove(command)
case "L", "l": parseLine(command)
case "H", "h": parseHorizontal(command)
case "V", "v": parseVertical(command)
case "C", "c": parseCubic(command)
case "Z", "z":
cgPath.closeSubpath()
current = subpathStart
default:
skipToNextCommand()
}
}
return cgPath.copy() ?? CGMutablePath()
}
private var isAtCommand: Bool {
guard index < tokens.count else { return false }
if case .command = tokens[index] { return true }
return false
}
private mutating func readCommandOrReuse() -> Character {
guard index < tokens.count else { return lastCommand }
if case let .command(command) = tokens[index] {
index += 1
lastCommand = command
return command
}
return lastCommand
}
private mutating func readNumber() -> CGFloat? {
guard index < tokens.count else { return nil }
if case let .number(value) = tokens[index] {
index += 1
return value
}
return nil
}
private func resolvedPoint(x: CGFloat, y: CGFloat, relative: Bool) -> CGPoint {
relative ? CGPoint(x: current.x + x, y: current.y + y) : CGPoint(x: x, y: y)
}
private mutating func readPoint(relative: Bool) -> CGPoint? {
guard let x = readNumber(), let y = readNumber() else { return nil }
return resolvedPoint(x: x, y: y, relative: relative)
}
private mutating func parseMove(_ command: Character) {
let relative = command.isLowercase
guard let first = readPoint(relative: relative) else { return }
cgPath.move(to: first)
current = first
subpathStart = first
while !isAtCommand, let point = readPoint(relative: relative) {
cgPath.addLine(to: point)
current = point
}
lastCommand = relative ? "l" : "L"
}
private mutating func parseLine(_ command: Character) {
let relative = command.isLowercase
while !isAtCommand, let point = readPoint(relative: relative) {
cgPath.addLine(to: point)
current = point
}
}
private mutating func parseHorizontal(_ command: Character) {
let relative = command.isLowercase
while !isAtCommand, let value = readNumber() {
current = CGPoint(x: relative ? current.x + value : value, y: current.y)
cgPath.addLine(to: current)
}
}
private mutating func parseVertical(_ command: Character) {
let relative = command.isLowercase
while !isAtCommand, let value = readNumber() {
current = CGPoint(x: current.x, y: relative ? current.y + value : value)
cgPath.addLine(to: current)
}
}
private mutating func parseCubic(_ command: Character) {
let relative = command.isLowercase
while !isAtCommand {
guard let x1 = readNumber(),
let y1 = readNumber(),
let x2 = readNumber(),
let y2 = readNumber(),
let x = readNumber(),
let y = readNumber()
else { return }
let c1 = resolvedPoint(x: x1, y: y1, relative: relative)
let c2 = resolvedPoint(x: x2, y: y2, relative: relative)
let end = resolvedPoint(x: x, y: y, relative: relative)
cgPath.addCurve(to: end, control1: c1, control2: c2)
current = end
}
}
private mutating func skipToNextCommand() {
while index < tokens.count {
if case .command = tokens[index] { return }
index += 1
}
}
}
private enum TelegramIconPath {
enum TelegramIconPath {
static let backChevron = #"M0.317383 10.5957C0.203451 10.498 0.12207 10.376 0.0732422 10.2295C0.0244141 10.0993 0 9.96094 0 9.81445C0 9.66797 0.0244141 9.52962 0.0732422 9.39941C0.12207 9.25293 0.203451 9.13086 0.317383 9.0332L8.83789 0.317383C8.93555 0.219727 9.05762 0.138346 9.2041 0.0732422C9.33431 0.0244141 9.47266 0 9.61914 0C9.74935 0 9.87956 0.0244141 10.0098 0.0732422C10.1562 0.138346 10.2783 0.219727 10.376 0.317383C10.4899 0.431315 10.5713 0.553385 10.6201 0.683594C10.6689 0.830078 10.6934 0.976562 10.6934 1.12305C10.6934 1.25326 10.6689 1.3916 10.6201 1.53809C10.5713 1.66829 10.4899 1.79036 10.376 1.9043L2.63672 9.81445L10.376 17.7246C10.4899 17.8385 10.5713 17.9606 10.6201 18.0908C10.6689 18.2373 10.6934 18.3757 10.6934 18.5059C10.6934 18.6523 10.6689 18.7988 10.6201 18.9453C10.5713 19.0755 10.4899 19.1976 10.376 19.3115C10.2783 19.4092 10.1562 19.4906 10.0098 19.5557C9.87956 19.6045 9.74935 19.6289 9.61914 19.6289C9.47266 19.6289 9.33431 19.6045 9.2041 19.5557C9.05762 19.4906 8.93555 19.4092 8.83789 19.3115L0.317383 10.5957Z"#
static let paperclip = #"M11.0156 17.9297L9.84375 16.7871L17.4316 9.11133C17.8418 8.70117 18.1543 8.22266 18.3691 7.67578C18.584 7.14844 18.6914 6.5918 18.6914 6.00586C18.6914 5.43945 18.584 4.88281 18.3691 4.33594C18.1348 3.80859 17.8125 3.33984 17.4023 2.92969C16.9922 2.51953 16.5137 2.20703 15.9668 1.99219C15.4395 1.75781 14.8828 1.65039 14.2969 1.66992C13.7109 1.66992 13.1543 1.77734 12.627 1.99219C12.0801 2.22656 11.6016 2.54883 11.1914 2.95898L3.60352 10.6055C2.97852 11.2305 2.5 11.9531 2.16797 12.7734C1.83594 13.5742 1.66992 14.4141 1.66992 15.293C1.66992 16.1719 1.8457 17.0215 2.19727 17.8418C2.5293 18.6426 3.00781 19.3555 3.63281 19.9805C4.25781 20.6055 4.98047 21.084 5.80078 21.416C6.62109 21.748 7.4707 21.9141 8.34961 21.9141C9.22852 21.8945 10.0684 21.7188 10.8691 21.3867C11.6895 21.0547 12.4121 20.5762 13.0371 19.9512L18.5449 14.3848C18.7012 14.2285 18.8965 14.1504 19.1309 14.1504C19.3652 14.1504 19.5605 14.2285 19.7168 14.3848C19.873 14.541 19.9512 14.7363 19.9512 14.9707C19.9707 15.2051 19.8926 15.4004 19.7168 15.5566L14.1211 21.1816C13.3594 21.9434 12.4805 22.5293 11.4844 22.9395C10.4688 23.3496 9.42383 23.5547 8.34961 23.5547C8.33008 23.5547 8.04688 23.5547 7.5 23.5547C6.95312 23.5547 6.17188 23.3496 5.15625 22.9395C4.14062 22.5293 3.24219 21.9336 2.46094 21.1523C1.67969 20.3906 1.07422 19.502 0.644531 18.4863C0.234375 17.4707 0.0195312 16.4062 0 15.293V15.2637C0 14.1699 0.214844 13.125 0.644531 12.1289C1.05469 11.1133 1.64062 10.2148 2.40234 9.43359L10.0195 1.78711C10.5859 1.2207 11.2402 0.78125 11.9824 0.46875C12.7246 0.15625 13.4961 0 14.2969 0H14.3262C15.1074 0 15.8691 0.146484 16.6113 0.439453C17.3535 0.751953 18.0078 1.18164 18.5742 1.72852C19.1406 2.29492 19.5801 2.94922 19.8926 3.69141C20.2051 4.43359 20.3613 5.20508 20.3613 6.00586V6.03516C20.3613 6.83594 20.2148 7.59766 19.9219 8.32031C19.6094 9.0625 19.1699 9.7168 18.6035 10.2832L11.0156 17.9297ZM10.957 6.88477C11.0352 6.80664 11.1328 6.74805 11.25 6.70898C11.3477 6.66992 11.4453 6.65039 11.543 6.65039C11.6602 6.65039 11.7676 6.66992 11.8652 6.70898C11.9629 6.74805 12.0508 6.80664 12.1289 6.88477C12.207 6.96289 12.2754 7.05078 12.334 7.14844C12.373 7.24609 12.3926 7.35352 12.3926 7.4707C12.3926 7.56836 12.373 7.67578 12.334 7.79297C12.2754 7.89063 12.207 7.97852 12.1289 8.05664L6.62109 13.623C6.40625 13.8184 6.25 14.0527 6.15234 14.3262C6.03516 14.6191 5.97656 14.9121 5.97656 15.2051C5.97656 15.498 6.03516 15.7812 6.15234 16.0547C6.26953 16.3281 6.43555 16.5723 6.65039 16.7871C6.86523 17.002 7.10938 17.168 7.38281 17.2852C7.65625 17.3828 7.93945 17.4316 8.23242 17.4316C8.54492 17.4316 8.83789 17.373 9.11133 17.2559C9.38477 17.1387 9.62891 16.9824 9.84375 16.7871L11.0156 17.9297C10.6445 18.3008 10.2246 18.584 9.75586 18.7793C9.26758 18.9941 8.75977 19.1016 8.23242 19.1016C7.70508 19.1016 7.20703 19.0039 6.73828 18.8086C6.26953 18.6133 5.84961 18.3301 5.47852 17.959C5.10742 17.5879 4.82422 17.168 4.62891 16.6992C4.41406 16.2305 4.30664 15.7324 4.30664 15.2051V15.1758C4.30664 14.6875 4.4043 14.209 4.59961 13.7402C4.77539 13.291 5.0293 12.8809 5.36133 12.5098L10.957 6.88477Z"#

View File

@@ -0,0 +1,198 @@
import SwiftUI
// MARK: - MessageAvatarView
/// Displays an avatar attachment inside a message bubble.
///
/// Desktop parity: `MessageAvatar.tsx` shows a bordered card with circular avatar
/// preview, "Avatar" title with lock icon, and descriptive text.
///
/// States:
/// 1. **Cached** avatar already in AttachmentCache, display immediately
/// 2. **Downloading** show placeholder + spinner
/// 3. **Downloaded** display avatar, auto-saved to AvatarRepository
/// 4. **Error** "Avatar expired" or download error
struct MessageAvatarView: View {
let attachment: MessageAttachment
let message: ChatMessage
let outgoing: Bool
@State private var avatarImage: UIImage?
@State private var isDownloading = false
@State private var downloadError = false
/// Avatar circle diameter (desktop parity: 60px).
private let avatarSize: CGFloat = 56
var body: some View {
HStack(spacing: 10) {
// Avatar circle (left side)
avatarCircle
// Metadata (right side)
VStack(alignment: .leading, spacing: 3) {
// Title row: "Avatar" + lock icon
HStack(spacing: 4) {
Text("Avatar")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(outgoing ? .white : RosettaColors.Adaptive.text)
Image(systemName: "lock.fill")
.font(.system(size: 10))
.foregroundStyle(Color.green.opacity(0.8))
}
// Description
Text("An avatar image shared in the message.")
.font(.system(size: 12))
.foregroundStyle(outgoing ? .white.opacity(0.5) : RosettaColors.Adaptive.textSecondary)
.lineLimit(2)
// Download state indicator
if isDownloading {
HStack(spacing: 4) {
ProgressView()
.tint(outgoing ? .white.opacity(0.6) : Color(hex: 0x008BFF))
.scaleEffect(0.7)
Text("Downloading...")
.font(.system(size: 11))
.foregroundStyle(outgoing ? .white.opacity(0.4) : RosettaColors.Adaptive.textSecondary)
}
.padding(.top, 2)
} else if downloadError {
Text("Avatar expired")
.font(.system(size: 11))
.foregroundStyle(RosettaColors.error)
.padding(.top, 2)
}
}
Spacer(minLength: 0)
}
.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 {
downloadAvatar()
}
}
}
// MARK: - Avatar Circle
@ViewBuilder
private var avatarCircle: some View {
ZStack {
if let avatarImage {
Image(uiImage: avatarImage)
.resizable()
.scaledToFill()
.frame(width: avatarSize, height: avatarSize)
.clipShape(Circle())
} else {
Circle()
.fill(outgoing ? Color.white.opacity(0.15) : Color.white.opacity(0.08))
.frame(width: avatarSize, height: avatarSize)
.overlay {
if isDownloading {
ProgressView()
.tint(.white.opacity(0.5))
} else {
Image(systemName: "person.fill")
.font(.system(size: 22))
.foregroundStyle(.white.opacity(0.3))
}
}
}
}
}
// MARK: - Download
private func loadFromCache() {
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
avatarImage = cached
}
}
private func downloadAvatar() {
guard !isDownloading, avatarImage == nil else { return }
let tag = extractTag(from: attachment.preview)
guard !tag.isEmpty else {
downloadError = true
return
}
guard let password = message.attachmentPassword, !password.isEmpty else {
print("🎭 [AvatarView] NO password for attachment \(attachment.id)")
downloadError = true
return
}
print("🎭 [AvatarView] Downloading avatar \(attachment.id), tag=\(tag.prefix(20))")
isDownloading = true
downloadError = false
Task {
do {
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
)
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
AttachmentCache.shared.saveImage(downloadedImage, forAttachmentId: attachment.id)
} else {
downloadError = true
}
isDownloading = false
}
} catch {
await MainActor.run {
downloadError = true
isDownloading = false
}
}
}
}
/// Extracts the server tag from preview string.
/// Format: "tag::blurhash" returns "tag".
private func extractTag(from preview: String) -> String {
let parts = preview.components(separatedBy: "::")
return parts.first ?? preview
}
}

View File

@@ -0,0 +1,199 @@
import SwiftUI
// MARK: - MessageFileView
/// Displays a file attachment inside a message bubble.
///
/// Desktop parity: `MessageFile.tsx` shows file icon + filename + size card.
/// Tap to download (if not cached), then share via UIActivityViewController.
///
/// Preview format: "tag::filesize::filename" (desktop parity).
struct MessageFileView: View {
let attachment: MessageAttachment
let message: ChatMessage
let outgoing: Bool
@State private var isDownloading = false
@State private var downloadProgress: String = ""
@State private var isDownloaded = false
@State private var downloadError = false
@State private var cachedFileURL: URL?
var body: some View {
HStack(spacing: 10) {
// File icon circle
ZStack {
Circle()
.fill(outgoing ? Color.white.opacity(0.2) : Color(hex: 0x008BFF).opacity(0.2))
.frame(width: 40, height: 40)
if isDownloading {
ProgressView()
.tint(outgoing ? .white : Color(hex: 0x008BFF))
.scaleEffect(0.8)
} else if isDownloaded {
Image(systemName: fileIcon)
.font(.system(size: 18))
.foregroundStyle(outgoing ? .white : Color(hex: 0x008BFF))
} else {
Image(systemName: "arrow.down.circle.fill")
.font(.system(size: 18))
.foregroundStyle(outgoing ? .white.opacity(0.7) : Color(hex: 0x008BFF).opacity(0.7))
}
}
// File metadata
VStack(alignment: .leading, spacing: 2) {
Text(fileName)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(outgoing ? .white : RosettaColors.Adaptive.text)
.lineLimit(1)
if isDownloading {
Text("Downloading...")
.font(.system(size: 12))
.foregroundStyle(outgoing ? .white.opacity(0.5) : RosettaColors.Adaptive.textSecondary)
} else if downloadError {
Text("File expired")
.font(.system(size: 12))
.foregroundStyle(RosettaColors.error)
} else {
Text(formattedFileSize)
.font(.system(size: 12))
.foregroundStyle(
outgoing ? .white.opacity(0.5) : RosettaColors.Adaptive.textSecondary
)
}
}
Spacer(minLength: 0)
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.frame(width: 220)
.contentShape(Rectangle())
.onTapGesture {
if isDownloaded, let url = cachedFileURL {
shareFile(url)
} else if !isDownloading {
downloadFile()
}
}
.task {
checkCache()
}
}
// MARK: - Metadata Parsing
/// Parses "tag::filesize::filename" preview format.
private var fileMetadata: (tag: String, size: Int, name: String) {
let parts = attachment.preview.components(separatedBy: "::")
let tag = parts.first ?? ""
let size = parts.count > 1 ? Int(parts[1]) ?? 0 : 0
let name = parts.count > 2 ? parts[2] : "file"
return (tag, size, name)
}
private var fileName: String { fileMetadata.name }
private var fileSize: Int { fileMetadata.size }
private var fileTag: String { fileMetadata.tag }
private var formattedFileSize: String {
let bytes = fileSize
if bytes < 1024 { return "\(bytes) B" }
if bytes < 1024 * 1024 { return String(format: "%.1f KB", Double(bytes) / 1024) }
return String(format: "%.1f MB", Double(bytes) / (1024 * 1024))
}
private var fileIcon: String {
let ext = (fileName as NSString).pathExtension.lowercased()
switch ext {
case "pdf": return "doc.fill"
case "zip", "rar", "7z": return "doc.zipper"
case "jpg", "jpeg", "png", "gif": return "photo.fill"
case "mp4", "mov", "avi": return "film.fill"
case "mp3", "wav", "aac": return "waveform"
default: return "doc.fill"
}
}
// MARK: - Download
private func checkCache() {
if let url = AttachmentCache.shared.fileURL(forAttachmentId: attachment.id, fileName: fileName) {
cachedFileURL = url
isDownloaded = true
}
}
private func downloadFile() {
guard !isDownloading, !fileTag.isEmpty else { return }
guard let password = message.attachmentPassword, !password.isEmpty else {
downloadError = true
return
}
isDownloading = true
downloadError = false
Task {
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
)
// Parse data URI if present, otherwise use raw data
let fileData: Data
if let decryptedString = String(data: decryptedData, encoding: .utf8),
decryptedString.hasPrefix("data:"),
let commaIndex = decryptedString.firstIndex(of: ",") {
let base64Part = String(decryptedString[decryptedString.index(after: commaIndex)...])
fileData = Data(base64Encoded: base64Part) ?? decryptedData
} else {
fileData = decryptedData
}
let url = AttachmentCache.shared.saveFile(
fileData, forAttachmentId: attachment.id, fileName: fileName
)
await MainActor.run {
cachedFileURL = url
isDownloaded = true
isDownloading = false
}
} catch {
await MainActor.run {
downloadError = true
isDownloading = false
}
}
}
}
// MARK: - Share
private func shareFile(_ url: URL) {
let activityVC = UIActivityViewController(
activityItems: [url],
applicationActivities: nil
)
if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = scene.windows.first,
let rootVC = window.rootViewController {
var topVC = rootVC
while let presented = topVC.presentedViewController {
topVC = presented
}
if let popover = activityVC.popoverPresentationController {
popover.sourceView = topVC.view
popover.sourceRect = CGRect(x: topVC.view.bounds.midX, y: topVC.view.bounds.midY, width: 0, height: 0)
}
topVC.present(activityVC, animated: true)
}
}
}

View File

@@ -0,0 +1,179 @@
import SwiftUI
// MARK: - MessageImageView
/// Displays an image attachment inside a message bubble.
///
/// Desktop parity: `MessageImage.tsx` shows blur placeholder while downloading,
/// full image after download, "Image expired" on error.
///
/// States:
/// 1. **Cached** image already in AttachmentCache, display immediately
/// 2. **Downloading** show placeholder + spinner
/// 3. **Downloaded** display image, tap for full-screen (future)
/// 4. **Error** "Image expired" or download error
struct MessageImageView: View {
let attachment: MessageAttachment
let message: ChatMessage
let outgoing: Bool
let maxWidth: CGFloat
@State private var image: 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
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))
} else if isDownloading {
placeholder
.overlay { ProgressView().tint(.white) }
} 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))
}
}
} else {
placeholder
.overlay {
Image(systemName: "arrow.down.circle")
.font(.system(size: 24))
.foregroundStyle(.white.opacity(0.6))
}
.onTapGesture { downloadImage() }
}
}
.task {
loadFromCache()
if image == nil {
downloadImage()
}
}
}
// MARK: - Placeholder
private var placeholder: some View {
RoundedRectangle(cornerRadius: 12)
.fill(Color.white.opacity(0.08))
.frame(width: 200, height: 150)
}
// MARK: - Download
private func loadFromCache() {
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
image = cached
}
}
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))")
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
)
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
}
}
}
}
/// 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
}
}

View File

@@ -0,0 +1,251 @@
import SwiftUI
/// Profile screen for viewing opponent (other user) information.
/// Pushed from ChatDetailView when tapping the toolbar capsule or avatar.
///
/// Desktop parity: ProfileCard (avatar + name + subtitle)
/// Username section (copyable) Public Key section (copyable).
struct OpponentProfileView: View {
let route: ChatRoute
@Environment(\.dismiss) private var dismiss
@State private var copiedField: String?
// MARK: - Computed properties
private var dialog: Dialog? {
DialogRepository.shared.dialogs[route.publicKey]
}
private var displayName: String {
if let dialog, !dialog.opponentTitle.isEmpty { return dialog.opponentTitle }
if !route.title.isEmpty { return route.title }
if let dialog, !dialog.opponentUsername.isEmpty { return "@\(dialog.opponentUsername)" }
if !route.username.isEmpty { return "@\(route.username)" }
return String(route.publicKey.prefix(12))
}
private var username: String {
if let dialog, !dialog.opponentUsername.isEmpty { return dialog.opponentUsername }
return route.username
}
private var effectiveVerified: Int {
if let dialog { return dialog.effectiveVerified }
if route.verified > 0 { return route.verified }
return 0
}
private var avatarInitials: String {
RosettaColors.initials(name: displayName, publicKey: route.publicKey)
}
private var avatarColorIndex: Int {
RosettaColors.avatarColorIndex(for: displayName, publicKey: route.publicKey)
}
private var opponentAvatar: UIImage? {
AvatarRepository.shared.loadAvatar(publicKey: route.publicKey)
}
/// Desktop parity: @username shortKey
private var subtitleText: String {
let shortKey = route.publicKey.prefix(4) + "..." + route.publicKey.suffix(4)
if !username.isEmpty {
return "@\(username) · \(shortKey)"
}
return String(shortKey)
}
// MARK: - Body
var body: some View {
ScrollView {
VStack(spacing: 0) {
profileCard
.padding(.top, 32)
infoSections
.padding(.top, 32)
.padding(.horizontal, 16)
}
}
.scrollIndicators(.hidden)
.background(RosettaColors.Adaptive.background.ignoresSafeArea())
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true)
.enableSwipeBack()
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button { dismiss() } label: { backButtonLabel }
.buttonStyle(.plain)
}
}
.toolbarBackground(.hidden, for: .navigationBar)
}
// MARK: - Back Button
private var backButtonLabel: some View {
TelegramVectorIcon(
pathData: TelegramIconPath.backChevron,
viewBox: CGSize(width: 11, height: 20),
color: .white
)
.frame(width: 11, height: 20)
.allowsHitTesting(false)
.frame(width: 36, height: 36)
.frame(height: 44)
.padding(.horizontal, 4)
.background { glassCapsule() }
}
// MARK: - Profile Card (Desktop: ProfileCard component)
private var profileCard: some View {
VStack(spacing: 0) {
AvatarView(
initials: avatarInitials,
colorIndex: avatarColorIndex,
size: 100,
isOnline: false,
image: opponentAvatar
)
HStack(spacing: 5) {
Text(displayName)
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(2)
.multilineTextAlignment(.center)
if effectiveVerified > 0 {
VerifiedBadge(verified: effectiveVerified, size: 18, badgeTint: .white)
}
}
.padding(.top, 12)
.padding(.horizontal, 32)
Text(subtitleText)
.font(.system(size: 14))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.padding(.top, 4)
}
}
// MARK: - Info Sections (Desktop: SettingsInput.Copy rows)
private var infoSections: some View {
VStack(spacing: 16) {
if !username.isEmpty {
copyRow(
label: "Username",
value: "@\(username)",
rawValue: username,
fieldId: "username",
helper: "Username for search user or send message."
)
}
copyRow(
label: "Public Key",
value: route.publicKey,
rawValue: route.publicKey,
fieldId: "publicKey",
helper: "This is user public key. If user haven't set a @username yet, you can send message using public key."
)
}
}
// MARK: - Copy Row (Desktop: SettingsInput.Copy)
private func copyRow(
label: String,
value: String,
rawValue: String,
fieldId: String,
helper: String
) -> some View {
VStack(alignment: .leading, spacing: 6) {
Button {
UIPasteboard.general.string = rawValue
withAnimation(.easeInOut(duration: 0.2)) { copiedField = fieldId }
Task { @MainActor in
try? await Task.sleep(for: .seconds(1.5))
withAnimation(.easeInOut(duration: 0.2)) {
if copiedField == fieldId { copiedField = nil }
}
}
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.system(size: 13))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
Text(copiedField == fieldId ? "Copied" : value)
.font(.system(size: 16))
.foregroundStyle(
copiedField == fieldId
? RosettaColors.online
: RosettaColors.Adaptive.text
)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer()
Image(systemName: copiedField == fieldId ? "checkmark" : "doc.on.doc")
.font(.system(size: 13))
.foregroundStyle(
copiedField == fieldId
? RosettaColors.online
: RosettaColors.Adaptive.textSecondary
)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background { glassCard() }
}
.buttonStyle(.plain)
Text(helper)
.font(.system(size: 12))
.foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.7))
.padding(.horizontal, 8)
}
}
// MARK: - Glass helpers
@ViewBuilder
private func glassCapsule() -> some 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)
}
}
@ViewBuilder
private func glassCard() -> some View {
if #available(iOS 26.0, *) {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(.clear)
.glassEffect(
.regular,
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)
}
}
}
}

View File

@@ -0,0 +1,91 @@
import UIKit
// MARK: - PendingAttachment
/// Represents an attachment selected by the user but not yet sent.
/// Used in the attachment preview strip above the compositor.
///
/// Desktop parity: the `attachments` state array in `DialogInput.tsx` before
/// `prepareAttachmentsToSend()` processes and uploads them.
struct PendingAttachment: Identifiable, Sendable {
/// Random 8-character alphanumeric ID (matches desktop's `generateRandomKey(8)`).
let id: String
/// Attachment type `.image` or `.file` for user-initiated sends.
let type: AttachmentType
/// Raw image/file data (pre-compression for images).
let data: Data
/// Thumbnail for preview (images only). `nil` for files.
let thumbnail: UIImage?
/// Original file name (files only). `nil` for images.
let fileName: String?
/// File size in bytes (files only). `nil` for images.
let fileSize: Int?
// MARK: - Factory
/// Creates a PendingAttachment from a UIImage (compressed to JPEG).
static func fromImage(_ image: UIImage) -> PendingAttachment {
let id = generateRandomId()
// Resize to max 1280px on longest side for mobile optimization
let resized = resizeImage(image, maxDimension: 1280)
let data = resized.jpegData(compressionQuality: 0.8) ?? Data()
let thumbnail = resizeImage(image, maxDimension: 200)
return PendingAttachment(
id: id,
type: .image,
data: data,
thumbnail: thumbnail,
fileName: nil,
fileSize: nil
)
}
/// Creates a PendingAttachment from file data + metadata.
static func fromFile(data: Data, fileName: String) -> PendingAttachment {
return PendingAttachment(
id: generateRandomId(),
type: .file,
data: data,
thumbnail: nil,
fileName: fileName,
fileSize: data.count
)
}
// MARK: - Helpers
/// Generates a random 8-character ID (desktop: `generateRandomKey(8)`).
private static func generateRandomId() -> String {
let chars = "abcdefghijklmnopqrstuvwxyz0123456789"
return String((0..<8).map { _ in chars.randomElement()! })
}
/// Resizes image so longest side is at most `maxDimension`.
private static func resizeImage(_ image: UIImage, maxDimension: CGFloat) -> UIImage {
let size = image.size
let maxSide = max(size.width, size.height)
guard maxSide > maxDimension else { return image }
let scale = maxDimension / maxSide
let newSize = CGSize(width: size.width * scale, height: size.height * scale)
let renderer = UIGraphicsImageRenderer(size: newSize)
return renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: newSize))
}
}
}
// MARK: - Constants
extension PendingAttachment {
/// Desktop parity: `MAX_ATTACHMENTS_IN_MESSAGE = 5`.
static let maxAttachmentsPerMessage = 5
}

View File

@@ -0,0 +1,356 @@
import SwiftUI
import Photos
// MARK: - PhotoGridView
/// Custom photo library grid matching Figma attachment panel design (1:1).
///
/// Figma layout (node 3994:39103):
/// ```
///
/// Photo[0] Photo[1] row 1
/// Camera
/// (live) Photo[2] Photo[3] row 2
///
///
/// Photo[4] Photo[5] Photo[6] row 3 (LazyVGrid)
///
/// ```
///
/// Camera tile: col 1, rows 12, shows live rear camera feed (AVCaptureSession).
/// Photos: square tiles with selection circle (22pt, white border 1.5pt).
/// 1px spacing between all tiles.
struct PhotoGridView: View {
@Binding var selectedAssets: [PHAsset]
let maxSelection: Int
let onCameraTap: () -> Void
var onPhotoPreview: ((PHAsset) -> Void)? = nil
@State private var assets: [PHAsset] = []
@State private var authorizationStatus: PHAuthorizationStatus = .notDetermined
private let imageManager = PHCachingImageManager()
private let columns = 3
private let spacing: CGFloat = 1
var body: some View {
Group {
switch authorizationStatus {
case .authorized, .limited:
photoGrid
case .denied, .restricted:
permissionDeniedView
default:
requestingView
}
}
.task {
await requestPhotoAccess()
}
}
// MARK: - Photo Grid
private var photoGrid: some View {
GeometryReader { geometry in
let tileSize = (geometry.size.width - spacing * CGFloat(columns - 1)) / CGFloat(columns)
let cameraHeight = tileSize * 2 + spacing
ScrollView {
VStack(spacing: spacing) {
// Header: Camera (2 rows) + 4 photos (2×2 grid)
headerSection(tileSize: tileSize, cameraHeight: cameraHeight)
// Remaining photos: standard 3-column grid
remainingPhotosGrid(tileSize: tileSize, startIndex: 4)
}
.padding(.bottom, 100) // Space for tab bar + send button
}
}
}
/// Header section: Camera tile (col 1, rows 1-2) + 4 photos (cols 2-3, rows 1-2).
@ViewBuilder
private func headerSection(tileSize: CGFloat, cameraHeight: CGFloat) -> some View {
HStack(alignment: .top, spacing: spacing) {
// Camera tile spans 2 rows (double height)
CameraPreviewTile(
width: tileSize,
height: cameraHeight,
onTap: onCameraTap
)
// Right side: 2 rows × 2 columns of photos
VStack(spacing: spacing) {
// Row 1: photos[0], photos[1]
HStack(spacing: spacing) {
headerPhotoTile(index: 0, size: tileSize)
headerPhotoTile(index: 1, size: tileSize)
}
// Row 2: photos[2], photos[3]
HStack(spacing: spacing) {
headerPhotoTile(index: 2, size: tileSize)
headerPhotoTile(index: 3, size: tileSize)
}
}
}
}
/// Single photo tile in the header section.
@ViewBuilder
private func headerPhotoTile(index: Int, size: CGFloat) -> some View {
if index < assets.count {
let asset = assets[index]
let isSelected = selectedAssets.contains(where: { $0.localIdentifier == asset.localIdentifier })
let selectionIndex = selectedAssets.firstIndex(where: { $0.localIdentifier == asset.localIdentifier })
PhotoTile(
asset: asset,
imageManager: imageManager,
size: size,
isSelected: isSelected,
selectionNumber: selectionIndex.map { $0 + 1 },
onTap: { handlePhotoTap(asset) },
onCircleTap: { toggleSelection(asset) }
)
.frame(width: size, height: size)
} else {
Color(white: 0.15)
.frame(width: size, height: size)
}
}
/// Remaining photos after the header, displayed as a standard 3-column LazyVGrid.
@ViewBuilder
private func remainingPhotosGrid(tileSize: CGFloat, startIndex: Int) -> some View {
let remaining = assets.count > startIndex ? Array(assets[startIndex...]) : []
LazyVGrid(
columns: Array(repeating: GridItem(.flexible(), spacing: spacing), count: columns),
spacing: spacing
) {
ForEach(0..<remaining.count, id: \.self) { index in
let asset = remaining[index]
let isSelected = selectedAssets.contains(where: { $0.localIdentifier == asset.localIdentifier })
let selectionIndex = selectedAssets.firstIndex(where: { $0.localIdentifier == asset.localIdentifier })
PhotoTile(
asset: asset,
imageManager: imageManager,
size: tileSize,
isSelected: isSelected,
selectionNumber: selectionIndex.map { $0 + 1 },
onTap: { handlePhotoTap(asset) },
onCircleTap: { toggleSelection(asset) }
)
.frame(height: tileSize)
}
}
}
// MARK: - Permission Views
private var requestingView: some View {
VStack(spacing: 16) {
ProgressView()
.tint(.white)
Text("Requesting photo access...")
.font(.system(size: 15))
.foregroundStyle(.white.opacity(0.6))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var permissionDeniedView: some View {
VStack(spacing: 16) {
Image(systemName: "photo.on.rectangle.angled")
.font(.system(size: 40))
.foregroundStyle(.white.opacity(0.4))
Text("Photo Access Required")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.white)
Text("Enable photo access in Settings to send images.")
.font(.system(size: 14))
.foregroundStyle(.white.opacity(0.6))
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
Button("Open Settings") {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
.font(.system(size: 15, weight: .medium))
.foregroundStyle(RosettaColors.primaryBlue)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
// MARK: - Logic
private func requestPhotoAccess() async {
let status = await PHPhotoLibrary.requestAuthorization(for: .readWrite)
await MainActor.run {
authorizationStatus = status
if status == .authorized || status == .limited {
loadAssets()
}
}
}
private func loadAssets() {
let options = PHFetchOptions()
options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
options.fetchLimit = 500
let result = PHAsset.fetchAssets(with: .image, options: options)
var fetched: [PHAsset] = []
result.enumerateObjects { asset, _, _ in
fetched.append(asset)
}
assets = fetched
}
/// Handles tap on the photo body (not the selection circle).
/// Always opens preview selection is handled by the circle tap only.
private func handlePhotoTap(_ asset: PHAsset) {
onPhotoPreview?(asset)
}
private func toggleSelection(_ asset: PHAsset) {
if let index = selectedAssets.firstIndex(where: { $0.localIdentifier == asset.localIdentifier }) {
selectedAssets.remove(at: index)
} else if selectedAssets.count < maxSelection {
selectedAssets.append(asset)
}
}
}
// MARK: - CameraPreviewTile
/// Camera tile with live AVCaptureSession preview.
///
/// Figma: col 1, rows 12 in attachment grid. Shows live rear camera feed
/// with a camera icon overlay in the top-right corner.
/// Tapping opens full-screen UIImagePickerController for capture.
private struct CameraPreviewTile: View {
let width: CGFloat
let height: CGFloat
let onTap: () -> Void
var body: some View {
ZStack(alignment: .topTrailing) {
// Live camera preview (or placeholder on simulator)
CameraPreviewView()
.frame(width: width, height: height)
.clipped()
// Camera icon overlay (Figma: top-right)
Image(systemName: "camera.fill")
.font(.system(size: 18))
.foregroundStyle(.white)
.shadow(color: .black.opacity(0.5), radius: 2, x: 0, y: 1)
.padding(8)
}
.frame(width: width, height: height)
.contentShape(Rectangle())
.onTapGesture(perform: onTap)
}
}
// MARK: - PhotoTile
/// Single photo tile in the grid with async thumbnail loading and selection circle.
///
/// Figma: square aspect ratio, selection circle (22pt, white border 1.5pt) in
/// top-right corner with 6pt padding. Selected: blue #008BFF filled circle
/// with white number.
///
/// Two tap targets:
/// - Photo body (`onTap`): selects if unselected, opens preview if selected
/// - Selection circle (`onCircleTap`): always toggles selection (deselects if selected)
private struct PhotoTile: View {
let asset: PHAsset
let imageManager: PHCachingImageManager
let size: CGFloat
let isSelected: Bool
let selectionNumber: Int?
let onTap: () -> Void
let onCircleTap: () -> Void
@State private var thumbnail: UIImage?
var body: some View {
ZStack(alignment: .topTrailing) {
// Photo thumbnail body tap
Group {
if let thumbnail {
Image(uiImage: thumbnail)
.resizable()
.scaledToFill()
.frame(width: size, height: size)
.clipped()
} else {
Color(white: 0.15)
.frame(width: size, height: size)
}
}
.contentShape(Rectangle())
.onTapGesture(perform: onTap)
// Selection circle (Figma: 22pt, top-right, 6pt inset) circle tap
selectionCircle
.padding(6)
.contentShape(Circle())
.onTapGesture(perform: onCircleTap)
}
.task(id: asset.localIdentifier) {
loadThumbnail()
}
}
@ViewBuilder
private var selectionCircle: some View {
if isSelected {
ZStack {
Circle()
.fill(Color(hex: 0x008BFF))
.frame(width: 22, height: 22)
if let number = selectionNumber {
Text("\(number)")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(.white)
}
}
} else {
Circle()
.strokeBorder(Color.white, lineWidth: 1.5)
.frame(width: 22, height: 22)
}
}
private func loadThumbnail() {
let scale = UIScreen.main.scale
let targetSize = CGSize(width: size * scale, height: size * scale)
let options = PHImageRequestOptions()
options.deliveryMode = .opportunistic
options.isNetworkAccessAllowed = true
options.resizeMode = .fast
imageManager.requestImage(
for: asset,
targetSize: targetSize,
contentMode: .aspectFill,
options: options
) { image, _ in
if let image {
DispatchQueue.main.async {
self.thumbnail = image
}
}
}
}
}

View File

@@ -0,0 +1,361 @@
import Photos
import SwiftUI
// MARK: - PhotoPreviewView
/// Full-screen photo preview with editing toolbar and caption input.
///
/// Presented via `.fullScreenCover` with transparent background.
/// Dismissible by swipe-down drag gesture or back button.
/// On swipe-down, the presenting view (attachment panel) is visible behind.
///
/// Layout (Telegram-style):
/// ```
/// +----------------------------------+
/// | [radio button] |
/// | |
/// | [Full photo] |
/// | |
/// |-----------------------------------|
/// | Add a caption... [emoji] [done]| <- checkmark OUTSIDE bar
/// |-----------------------------------|
/// | [<] [crop] [Aa] [adj] [SD] [>] | <- toolbar row
/// +-----------------------------------+
/// ```
struct PhotoPreviewView: View {
let asset: PHAsset
let isSelected: Bool
let selectionNumber: Int?
@Binding var captionText: String
let onSend: (UIImage) -> Void
let onToggleSelect: () -> Void
@Environment(\.dismiss) private var dismiss
@State private var fullImage: UIImage?
@State private var isLoading = true
@State private var dragOffset: CGFloat = 0
@FocusState private var isKeyboardFocused: Bool
private var isKeyboardVisible: Bool { isKeyboardFocused }
var body: some View {
ZStack {
// Semi-transparent background fades on swipe to reveal content behind
Color.black
.opacity(max(0, 1.0 - dragOffset / 400))
.ignoresSafeArea(.container)
VStack(spacing: 0) {
topBar
Spacer()
photoContent
Spacer()
bottomSection
.padding(.bottom, 12)
}
}
.offset(y: dragOffset)
.gesture(dismissDragGesture)
.preferredColorScheme(.dark)
.task {
await loadFullResolutionImage()
}
}
// MARK: - Drag to Dismiss
private var dismissDragGesture: some Gesture {
DragGesture(minimumDistance: 20)
.onChanged { value in
if value.translation.height > 0 {
dragOffset = value.translation.height
}
}
.onEnded { value in
if value.translation.height > 120 {
dismiss()
} else {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
dragOffset = 0
}
}
}
}
// MARK: - Top Bar
private var topBar: some View {
HStack {
Spacer()
Button {
onToggleSelect()
} label: {
if isSelected {
ZStack {
Circle()
.fill(Color(hex: 0x008BFF))
.frame(width: 28, height: 28)
if let number = selectionNumber {
Text("\(number)")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(.white)
}
}
} else {
Circle()
.strokeBorder(Color.white, lineWidth: 1.5)
.frame(width: 28, height: 28)
}
}
.frame(width: 44, height: 44)
}
.padding(.horizontal, 12)
.padding(.top, 8)
}
// MARK: - Photo Content
private var photoContent: some View {
Group {
if let fullImage {
Image(uiImage: fullImage)
.resizable()
.scaledToFit()
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.padding(.horizontal, 8)
} else if isLoading {
ProgressView()
.tint(.white)
.scaleEffect(1.2)
}
}
}
// MARK: - Bottom Section
@ViewBuilder
private var bottomSection: some View {
VStack(spacing: 12) {
captionInputBar
.padding(.horizontal, 16)
if !isKeyboardVisible {
toolbarRow
.padding(.horizontal, 12)
.transition(.opacity.combined(with: .move(edge: .bottom)))
}
}
.animation(.easeInOut(duration: 0.25), value: isKeyboardVisible)
}
// MARK: - Caption Input Bar
/// Inactive: placeholder centered, no icons, no checkmark.
/// Active (keyboard): placeholder slides left, emoji inside bar, checkmark OUTSIDE bar.
private var captionInputBar: some View {
HStack(spacing: 8) {
// Glass capsule input bar
HStack(spacing: 0) {
ZStack {
// Custom animated placeholder (slides center left)
if captionText.isEmpty {
Text("Add a caption...")
.font(.system(size: 16))
.foregroundStyle(.white.opacity(0.4))
.frame(
maxWidth: .infinity,
alignment: isKeyboardVisible ? .leading : .center
)
.padding(.leading, isKeyboardVisible ? 16 : 0)
.allowsHitTesting(false)
}
// TextField (no built-in placeholder, always left-aligned)
TextField("", text: $captionText)
.font(.system(size: 16))
.foregroundStyle(.white)
.tint(Color(hex: 0x008BFF))
.padding(.leading, 16)
.focused($isKeyboardFocused)
}
// Emoji icon only when keyboard active
if isKeyboardVisible {
TelegramVectorIcon(
pathData: TelegramIconPath.emojiMoon,
viewBox: CGSize(width: 19, height: 19),
color: RosettaColors.Adaptive.textSecondary
)
.frame(width: 19, height: 19)
.padding(.trailing, 12)
}
}
.padding(.horizontal, 3)
.frame(height: 42)
.background { captionBarBackground }
// Checkmark button OUTSIDE the bar (white circle + dark checkmark)
if isKeyboardVisible {
Button {
isKeyboardFocused = false
} label: {
ZStack {
Circle()
.fill(.white)
SingleCheckmarkShape()
.fill(Color.black.opacity(0.85))
.frame(width: 14, height: 10)
}
.frame(width: 42, height: 42)
}
.buttonStyle(.plain)
.transition(.scale.combined(with: .opacity))
}
}
.animation(.easeInOut(duration: 0.25), value: isKeyboardVisible)
}
@ViewBuilder
private var captionBarBackground: some View {
if #available(iOS 26, *) {
RoundedRectangle(cornerRadius: 21, style: .continuous)
.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)
}
}
// MARK: - Toolbar Row (Telegram 1:1 match)
private var toolbarRow: some View {
HStack(spacing: 0) {
// Back button white outlined circle with chevron (matches screenshot)
Button {
dismiss()
} label: {
ZStack {
Circle()
.strokeBorder(Color.white, lineWidth: 1.5)
Image(systemName: "chevron.left")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(.white)
}
.frame(width: 33, height: 33)
}
.buttonStyle(.plain)
Spacer()
// Center editing tools
HStack(spacing: 20) {
toolbarIconButton(systemName: "crop")
toolbarTextButton
toolbarIconButton(systemName: "slider.horizontal.3")
qualityBadge
}
Spacer()
// Send button blue circle with arrow up (matches screenshot)
Button {
guard let image = fullImage else { return }
onSend(image)
} label: {
ZStack {
Circle()
.fill(Color(hex: 0x008BFF))
Image(systemName: "arrow.up")
.font(.system(size: 17, weight: .bold))
.foregroundStyle(.white)
}
.frame(width: 33, height: 33)
}
.buttonStyle(.plain)
}
}
private func toolbarIconButton(systemName: String) -> some View {
Button {
// Phase 1: non-functional
} label: {
Image(systemName: systemName)
.font(.system(size: 20, weight: .medium))
.foregroundStyle(.white)
.frame(width: 28, height: 28)
}
.buttonStyle(.plain)
}
/// "Aa" text editing button.
private var toolbarTextButton: some View {
Button {
// Phase 1: non-functional
} label: {
Text("Aa")
.font(.system(size: 19, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 28, height: 28)
}
.buttonStyle(.plain)
}
/// SD quality badge dark filled background with border (matches Telegram).
private var qualityBadge: some View {
Button {
// Phase 1: non-functional
} label: {
Text("SD")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(.white)
.padding(.horizontal, 5)
.padding(.vertical, 2)
.background(
RoundedRectangle(cornerRadius: 4)
.fill(Color(white: 0.15))
)
.overlay(
RoundedRectangle(cornerRadius: 4)
.strokeBorder(Color.white.opacity(0.7), lineWidth: 1.5)
)
}
.buttonStyle(.plain)
}
// MARK: - Image Loading
private func loadFullResolutionImage() async {
let manager = PHImageManager.default()
let image = await withCheckedContinuation { continuation in
let options = PHImageRequestOptions()
options.deliveryMode = .highQualityFormat
options.isNetworkAccessAllowed = true
options.isSynchronous = false
manager.requestImage(
for: asset,
targetSize: PHImageManagerMaximumSize,
contentMode: .default,
options: options
) { image, info in
let isDegraded = (info?[PHImageResultIsDegradedKey] as? Bool) ?? false
if !isDegraded {
continuation.resume(returning: image)
}
}
}
await MainActor.run {
fullImage = image
isLoading = false
}
}
}