369 lines
13 KiB
Swift
369 lines
13 KiB
Swift
import SwiftUI
|
|
import Combine
|
|
|
|
// MARK: - ChatRowView
|
|
|
|
/// Chat row matching Figma "Row - Chats" component spec (node 3994:38947):
|
|
///
|
|
/// Row: height 78, pl-10, pr-16, items-center
|
|
/// Avatar: 62px circle, pr-10
|
|
/// Contents: flex-col, h-full, items-start, justify-center, pb-px
|
|
/// Title and Trailing Accessories: flex-1, gap-6, items-center, w-full
|
|
/// Title and Detail: flex-1, h-63, items-start, overflow-clip
|
|
/// Title: gap-4, items-center — SF Pro Medium 17/22, tracking -0.43
|
|
/// Message: h-41 — SF Pro Regular 15/20, tracking -0.23, secondary
|
|
/// Accessories: h-full, items-center, justify-end
|
|
/// Contents-Trailing: flex-col, h-full, items-end, justify-between, pt-8
|
|
/// Time: SF Pro Regular 14/20, tracking -0.23, secondary
|
|
/// Other: flex-1, items-end, justify-end, pb-14
|
|
/// Badge: bg-#008BFF, min-w-20, max-w-37, px-4, rounded-full
|
|
/// SF Pro Regular 15/20, black, tracking -0.23
|
|
struct ChatRowView: View {
|
|
let dialog: Dialog
|
|
/// Desktop parity: suppress unread badge during sync.
|
|
var isSyncing: Bool = false
|
|
/// Desktop parity: show "typing..." instead of last message.
|
|
var isTyping: Bool = false
|
|
|
|
|
|
var displayTitle: String {
|
|
if dialog.isSavedMessages { return "Saved Messages" }
|
|
if !dialog.opponentTitle.isEmpty { return dialog.opponentTitle }
|
|
if !dialog.opponentUsername.isEmpty { return "@\(dialog.opponentUsername)" }
|
|
return String(dialog.opponentKey.prefix(12))
|
|
}
|
|
|
|
var body: some View {
|
|
let _ = PerformanceLogger.shared.track("chatRow.bodyEval")
|
|
HStack(spacing: 0) {
|
|
avatarSection
|
|
.padding(.trailing, 10)
|
|
|
|
contentSection
|
|
}
|
|
.padding(.leading, 10)
|
|
.padding(.trailing, 16)
|
|
.frame(height: 78)
|
|
.contentShape(Rectangle())
|
|
}
|
|
}
|
|
|
|
// MARK: - Avatar
|
|
|
|
/// Observation-isolated: reads `AvatarRepository.avatarVersion` in its own
|
|
/// scope so only the avatar re-renders when opponent avatar changes — not the
|
|
/// entire ChatRowView (title, message preview, badge, etc.).
|
|
private struct ChatRowAvatar: View {
|
|
let dialog: Dialog
|
|
|
|
var body: some View {
|
|
// Establish @Observable tracking — re-renders this view on avatar save/remove.
|
|
let _ = AvatarRepository.shared.avatarVersion
|
|
AvatarView(
|
|
initials: dialog.initials,
|
|
colorIndex: dialog.avatarColorIndex,
|
|
size: 62,
|
|
isOnline: dialog.isOnline,
|
|
isSavedMessages: dialog.isSavedMessages,
|
|
image: dialog.isSavedMessages ? nil : AvatarRepository.shared.loadAvatar(publicKey: dialog.opponentKey)
|
|
)
|
|
}
|
|
}
|
|
|
|
private extension ChatRowView {
|
|
var avatarSection: some View {
|
|
ChatRowAvatar(dialog: dialog)
|
|
}
|
|
}
|
|
|
|
// MARK: - Content Section
|
|
// Figma "Contents": flex-col, h-full, items-start, justify-center, pb-px
|
|
// └─ "Title and Trailing Accessories": flex-1, gap-6, items-center
|
|
|
|
private extension ChatRowView {
|
|
var contentSection: some View {
|
|
HStack(alignment: .center, spacing: 6) {
|
|
// "Title and Detail": flex-1, h-63, items-start, overflow-clip
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
titleRow
|
|
messageRow
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.frame(height: 63)
|
|
.clipped()
|
|
|
|
// "Accessories and Grabber": h-full, items-center, justify-end
|
|
trailingColumn
|
|
.frame(maxHeight: .infinity)
|
|
}
|
|
.frame(maxHeight: .infinity)
|
|
.padding(.bottom, 1)
|
|
}
|
|
}
|
|
|
|
// MARK: - Title Row (name + badges)
|
|
// Figma "Title": gap-4, items-center, w-full
|
|
|
|
private extension ChatRowView {
|
|
var titleRow: some View {
|
|
HStack(spacing: 4) {
|
|
Text(displayTitle)
|
|
.font(.system(size: 17, weight: .medium))
|
|
.tracking(-0.43)
|
|
.foregroundStyle(RosettaColors.Adaptive.text)
|
|
.lineLimit(1)
|
|
|
|
if !dialog.isSavedMessages && dialog.effectiveVerified > 0 {
|
|
VerifiedBadge(
|
|
verified: dialog.effectiveVerified,
|
|
size: 16
|
|
)
|
|
}
|
|
|
|
if dialog.isMuted {
|
|
Image(systemName: "speaker.slash.fill")
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Message Row
|
|
// Figma "Message": h-41, SF Pro Regular 15/20, tracking -0.23, secondary
|
|
|
|
private extension ChatRowView {
|
|
var messageRow: some View {
|
|
Text(messageText)
|
|
.font(.system(size: 15))
|
|
.tracking(-0.23)
|
|
.foregroundStyle(
|
|
isTyping && !dialog.isSavedMessages
|
|
? RosettaColors.figmaBlue
|
|
: RosettaColors.Adaptive.textSecondary
|
|
)
|
|
.lineLimit(2)
|
|
.frame(height: 41, alignment: .topLeading)
|
|
}
|
|
|
|
/// Static cache for emoji-parsed message text (avoids regex per row per render).
|
|
private static var messageTextCache: [String: String] = [:]
|
|
|
|
var messageText: String {
|
|
// Desktop parity: show "typing..." in chat list row when opponent is typing.
|
|
if isTyping && !dialog.isSavedMessages {
|
|
return "typing..."
|
|
}
|
|
if dialog.lastMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
return "No messages yet"
|
|
}
|
|
if let cached = Self.messageTextCache[dialog.lastMessage] {
|
|
return cached
|
|
}
|
|
// Strip inline markdown markers and convert emoji shortcodes for clean preview.
|
|
let cleaned = dialog.lastMessage.replacingOccurrences(of: "**", with: "")
|
|
let result = EmojiParser.replaceShortcodes(in: cleaned)
|
|
if Self.messageTextCache.count > 500 {
|
|
let keysToRemove = Array(Self.messageTextCache.keys.prefix(250))
|
|
for key in keysToRemove { Self.messageTextCache.removeValue(forKey: key) }
|
|
}
|
|
Self.messageTextCache[dialog.lastMessage] = result
|
|
return result
|
|
}
|
|
}
|
|
|
|
// MARK: - Trailing Column
|
|
// Figma "Contents - Trailing": flex-col, h-full, items-end, justify-between, pt-8
|
|
// ├─ "Read Status and Time": gap-2, items-center
|
|
// └─ "Other": flex-1, items-end, justify-end, pb-14
|
|
|
|
private extension ChatRowView {
|
|
var trailingColumn: some View {
|
|
VStack(alignment: .trailing, spacing: 0) {
|
|
// Top: read status + time
|
|
HStack(spacing: 2) {
|
|
if dialog.lastMessageFromMe && !dialog.isSavedMessages {
|
|
deliveryIcon
|
|
}
|
|
|
|
Text(formattedTime)
|
|
.font(.system(size: 14))
|
|
.tracking(-0.23)
|
|
.foregroundStyle(
|
|
dialog.unreadCount > 0 && !dialog.isMuted
|
|
? RosettaColors.figmaBlue
|
|
: RosettaColors.Adaptive.textSecondary
|
|
)
|
|
}
|
|
.padding(.top, 8)
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
// Bottom: pin or unread badge
|
|
HStack(spacing: 8) {
|
|
if dialog.isPinned && dialog.unreadCount == 0 {
|
|
Image(systemName: "pin.fill")
|
|
.font(.system(size: 15))
|
|
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
|
.rotationEffect(.degrees(45))
|
|
}
|
|
|
|
// Show unread badge whenever there are unread messages.
|
|
// Previously hidden when lastMessageFromMe (desktop parity),
|
|
// but this caused invisible unreads when user sent a reply
|
|
// without reading prior incoming messages first.
|
|
if dialog.unreadCount > 0 && !isSyncing {
|
|
unreadBadge
|
|
}
|
|
}
|
|
.padding(.bottom, 14)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
var deliveryIcon: some View {
|
|
if dialog.lastMessageDelivered == .delivered && dialog.lastMessageRead {
|
|
DoubleCheckmarkShape()
|
|
.fill(RosettaColors.figmaBlue)
|
|
.frame(width: 17, height: 9.3)
|
|
} else {
|
|
switch dialog.lastMessageDelivered {
|
|
case .waiting:
|
|
// Timer isolated to sub-view — only .waiting rows create a timer.
|
|
DeliveryWaitingIcon(sentTimestamp: dialog.lastMessageTimestamp)
|
|
case .delivered:
|
|
SingleCheckmarkShape()
|
|
.fill(RosettaColors.Adaptive.textSecondary)
|
|
.frame(width: 14, height: 10.3)
|
|
case .error:
|
|
Image(systemName: "exclamationmark.circle.fill")
|
|
.font(.system(size: 14))
|
|
.foregroundStyle(RosettaColors.error)
|
|
}
|
|
}
|
|
}
|
|
|
|
var unreadBadge: some View {
|
|
let count = dialog.unreadCount
|
|
let text = count > 999 ? "\(count / 1000)K" : (count > 99 ? "99+" : "\(count)")
|
|
let isMuted = dialog.isMuted
|
|
let isSmall = count < 10
|
|
|
|
return Text(text)
|
|
.font(.system(size: 15))
|
|
.tracking(-0.23)
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, isSmall ? 0 : 4)
|
|
.frame(
|
|
minWidth: 20,
|
|
maxWidth: isSmall ? 20 : 37,
|
|
minHeight: 20
|
|
)
|
|
.background {
|
|
Capsule()
|
|
.fill(isMuted ? Color(hex: 0x787880) : RosettaColors.figmaBlue)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Delivery Waiting Icon (timer-isolated)
|
|
|
|
/// Desktop parity: clock → error after 80s. Timer only exists on rows with
|
|
/// `.waiting` delivery status — all other rows have zero timer overhead.
|
|
private struct DeliveryWaitingIcon: View {
|
|
let sentTimestamp: Int64
|
|
@State private var now = Date()
|
|
private let recheckTimer = Timer.publish(every: 40, on: .main, in: .common).autoconnect()
|
|
|
|
private var isWithinWindow: Bool {
|
|
guard sentTimestamp > 0 else { return true }
|
|
let sentDate = Date(timeIntervalSince1970: Double(sentTimestamp) / 1000)
|
|
return now.timeIntervalSince(sentDate) < 80
|
|
}
|
|
|
|
var body: some View {
|
|
Group {
|
|
if isWithinWindow {
|
|
Image(systemName: "clock")
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
|
} else {
|
|
Image(systemName: "exclamationmark.circle.fill")
|
|
.font(.system(size: 14))
|
|
.foregroundStyle(RosettaColors.error)
|
|
}
|
|
}
|
|
.onReceive(recheckTimer) { now = $0 }
|
|
}
|
|
}
|
|
|
|
// MARK: - Time Formatting
|
|
|
|
private extension ChatRowView {
|
|
private static let timeFormatter: DateFormatter = {
|
|
let f = DateFormatter(); f.dateFormat = "h:mm a"; return f
|
|
}()
|
|
private static let dayFormatter: DateFormatter = {
|
|
let f = DateFormatter(); f.dateFormat = "EEE"; return f
|
|
}()
|
|
private static let dateFormatter: DateFormatter = {
|
|
let f = DateFormatter(); f.dateFormat = "dd.MM.yy"; return f
|
|
}()
|
|
|
|
/// Static cache for formatted time strings (avoids Date/Calendar per row per render).
|
|
private static var timeStringCache: [Int64: String] = [:]
|
|
|
|
var formattedTime: String {
|
|
guard dialog.lastMessageTimestamp > 0 else { return "" }
|
|
|
|
if let cached = Self.timeStringCache[dialog.lastMessageTimestamp] {
|
|
return cached
|
|
}
|
|
|
|
let date = Date(timeIntervalSince1970: Double(dialog.lastMessageTimestamp) / 1000)
|
|
let now = Date()
|
|
let calendar = Calendar.current
|
|
|
|
let result: String
|
|
if calendar.isDateInToday(date) {
|
|
result = Self.timeFormatter.string(from: date)
|
|
} else if calendar.isDateInYesterday(date) {
|
|
result = "Yesterday"
|
|
} else if let days = calendar.dateComponents([.day], from: date, to: now).day, days < 7 {
|
|
result = Self.dayFormatter.string(from: date)
|
|
} else {
|
|
result = Self.dateFormatter.string(from: date)
|
|
}
|
|
|
|
if Self.timeStringCache.count > 500 {
|
|
let keysToRemove = Array(Self.timeStringCache.keys.prefix(250))
|
|
for key in keysToRemove { Self.timeStringCache.removeValue(forKey: key) }
|
|
}
|
|
Self.timeStringCache[dialog.lastMessageTimestamp] = result
|
|
return result
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview
|
|
|
|
#Preview {
|
|
let sampleDialog = Dialog(
|
|
id: "preview", account: "mykey", opponentKey: "abc001",
|
|
opponentTitle: "Alice Johnson",
|
|
opponentUsername: "alice",
|
|
lastMessage: "Hey, how are you?",
|
|
lastMessageTimestamp: Int64(Date().timeIntervalSince1970 * 1000),
|
|
unreadCount: 3, isOnline: true, lastSeen: 0,
|
|
verified: 1, iHaveSent: true,
|
|
isPinned: false, isMuted: false,
|
|
lastMessageFromMe: true, lastMessageDelivered: .delivered,
|
|
lastMessageRead: true
|
|
)
|
|
|
|
VStack(spacing: 0) {
|
|
ChatRowView(dialog: sampleDialog)
|
|
ChatRowView(dialog: sampleDialog, isTyping: true)
|
|
}
|
|
.background(RosettaColors.Adaptive.background)
|
|
}
|