From 299f78de36b351109fd191560d28908b3a7a681e Mon Sep 17 00:00:00 2001 From: TolaMironcenko Date: Mon, 14 Sep 2026 21:49:59 +0700 Subject: [PATCH] added .read state --- Sources/Shared/Models/ChatMessage.swift | 1 + .../Shared/UI/Components/MessageBubble.swift | 189 +++++++++++++----- .../UI/Components/ReplyThreadOverlay.swift | 1 + 3 files changed, 144 insertions(+), 47 deletions(-) diff --git a/Sources/Shared/Models/ChatMessage.swift b/Sources/Shared/Models/ChatMessage.swift index b9e15e8..0ca425d 100644 --- a/Sources/Shared/Models/ChatMessage.swift +++ b/Sources/Shared/Models/ChatMessage.swift @@ -13,6 +13,7 @@ final class ChatMessage { case sent case delivered case failed + case read } enum Security: String, Codable { diff --git a/Sources/Shared/UI/Components/MessageBubble.swift b/Sources/Shared/UI/Components/MessageBubble.swift index 2a75861..3f69a5a 100644 --- a/Sources/Shared/UI/Components/MessageBubble.swift +++ b/Sources/Shared/UI/Components/MessageBubble.swift @@ -63,12 +63,18 @@ struct MessageBubble: View { } if message.canBeRepliedTo { Button(action: onReply) { - Label("Ответить", systemImage: "arrowshape.turn.up.left") + Label( + "Ответить", + systemImage: "arrowshape.turn.up.left" + ) } } if message.canBeForwarded { Button(action: onForward) { - Label("Переслать", systemImage: "arrowshape.turn.up.right") + Label( + "Переслать", + systemImage: "arrowshape.turn.up.right" + ) } } if message.hasText { @@ -78,14 +84,22 @@ struct MessageBubble: View { } if message.kind.isMedia, !message.isRetracted { Button { - Task { await MediaDownloadService.save(message, model: model) } + Task { + await MediaDownloadService.save( + message, + model: model + ) + } } label: { Label("Сохранить", systemImage: "square.and.arrow.down") } } if model.canRetryMediaMessage(message) { Button(action: onRetry) { - Label("Повторить отправку", systemImage: "arrow.clockwise") + Label( + "Повторить отправку", + systemImage: "arrow.clockwise" + ) } } if message.canBeEdited { @@ -108,7 +122,9 @@ struct MessageBubble: View { } } .accessibilityLabel(Text(selectionAccessibilityLabel)) - .accessibilityAction(named: Text(isSelected ? "Убрать из выбранных" : "Выбрать")) { + .accessibilityAction( + named: Text(isSelected ? "Убрать из выбранных" : "Выбрать") + ) { if isSelectionMode { onToggleSelection() } else { @@ -129,12 +145,16 @@ struct MessageBubble: View { private var messageContainer: some View { HStack(spacing: 2) { if isSelectionMode { - Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") - .font(.system(size: 23, weight: .semibold)) - .foregroundStyle(isSelected ? Color.accentColor : Color.secondary) - .frame(width: 34) - .padding(.leading, 7) - .accessibilityHidden(true) + Image( + systemName: isSelected ? "checkmark.circle.fill" : "circle" + ) + .font(.system(size: 23, weight: .semibold)) + .foregroundStyle( + isSelected ? Color.accentColor : Color.secondary + ) + .frame(width: 34) + .padding(.leading, 7) + .accessibilityHidden(true) } VStack(spacing: 0) { @@ -172,13 +192,15 @@ struct MessageBubble: View { .foregroundStyle(Color.accentColor) .scaleEffect(x: replySwipeOffset > 0 ? -1 : 1, y: 1) .scaleEffect( - CGFloat(0.8) + CGFloat(0.2) * MessageReplySwipePolicy.progress(for: replySwipeOffset) + CGFloat(0.8) + CGFloat(0.2) + * MessageReplySwipePolicy.progress(for: replySwipeOffset) ) .opacity( Double( CGFloat(0.35) + CGFloat(0.65) * MessageReplySwipePolicy.progress(for: replySwipeOffset) - )) + ) + ) } private var replySwipeGesture: some Gesture { @@ -192,7 +214,8 @@ struct MessageBubble: View { if MessageReplySwipePolicy.canLock(value.translation) { replySwipeLocked = true } else if abs(value.translation.height) > 16, - abs(value.translation.width) < abs(value.translation.height) + abs(value.translation.width) + < abs(value.translation.height) { scrollOwnsGesture = true } @@ -207,7 +230,9 @@ struct MessageBubble: View { // indicator back and never publish per-cell state changes // during an ordinary timeline scroll. if replySwipeOffset != 0 || replySwipeArmed { - withAnimation(.spring(response: 0.24, dampingFraction: 0.82)) { + withAnimation( + .spring(response: 0.24, dampingFraction: 0.82) + ) { replySwipeOffset = 0 } replySwipeArmed = false @@ -240,7 +265,9 @@ struct MessageBubble: View { replySwipeLocked = false scrollOwnsGesture = false if replySwipeOffset != 0 || replySwipeArmed { - withAnimation(.spring(response: 0.24, dampingFraction: 0.82)) { + withAnimation( + .spring(response: 0.24, dampingFraction: 0.82) + ) { replySwipeOffset = 0 replySwipeArmed = false } @@ -259,19 +286,29 @@ struct MessageBubble: View { private var selectionAccessibilityLabel: String { guard isSelectionMode else { return message.previewText } - return "\(message.previewText), \(isSelected ? "выбрано" : "не выбрано")" + return + "\(message.previewText), \(isSelected ? "выбрано" : "не выбрано")" } private var messageRow: some View { HStack(alignment: .bottom, spacing: 8) { - if message.direction == .outgoing { Spacer(minLength: sideSpacerMinimum) } + if message.direction == .outgoing { + Spacer(minLength: sideSpacerMinimum) + } - VStack(alignment: message.direction == .outgoing ? .trailing : .leading, spacing: 4) { + VStack( + alignment: message.direction == .outgoing + ? .trailing : .leading, + spacing: 4 + ) { if message.isGroupMessage, message.direction == .incoming { - Text(message.senderDisplayName ?? model.displayName(for: message.senderJID)) - .font(.caption.weight(.semibold)) - .foregroundStyle(senderColor) - .padding(.horizontal, 8) + Text( + message.senderDisplayName + ?? model.displayName(for: message.senderJID) + ) + .font(.caption.weight(.semibold)) + .foregroundStyle(senderColor) + .padding(.horizontal, 8) } // The drag lives on the bubble itself so a swipe can only start // a reply when it begins on the bubble — not on the empty @@ -280,7 +317,8 @@ struct MessageBubble: View { .contentShape(Rectangle()) .simultaneousGesture( replySwipeGesture, - including: !isSelectionMode && message.canBeRepliedTo ? .gesture : .none + including: !isSelectionMode && message.canBeRepliedTo + ? .gesture : .none ) if !reactionSummaries.isEmpty { @@ -294,7 +332,9 @@ struct MessageBubble: View { } } - if message.direction == .incoming { Spacer(minLength: sideSpacerMinimum) } + if message.direction == .incoming { + Spacer(minLength: sideSpacerMinimum) + } } .padding(.horizontal, 12) .contentShape(Rectangle()) @@ -319,7 +359,9 @@ struct MessageBubble: View { systemName: message.security == .plaintext ? "lock.open" : "exclamationmark.shield" ) - .foregroundStyle(message.security == .plaintext ? .orange : .red) + .foregroundStyle( + message.security == .plaintext ? .orange : .red + ) } if message.delivery == .failed { Image(systemName: "exclamationmark.circle.fill") @@ -338,7 +380,9 @@ struct MessageBubble: View { systemName: message.security == .plaintext ? "lock.open" : "exclamationmark.shield" ) - .foregroundStyle(message.security == .plaintext ? .orange : .red) + .foregroundStyle( + message.security == .plaintext ? .orange : .red + ) } if message.editedAt != nil { Text("изм.") @@ -346,7 +390,10 @@ struct MessageBubble: View { Text(message.timestamp, format: .dateTime.hour().minute()) if message.direction == .outgoing, message.callHistory == nil { Image(systemName: deliveryIcon) - .foregroundStyle(message.delivery == .failed ? .red : .secondary) + .foregroundStyle( + message.delivery == .failed ? .red : .secondary + ) + Text(deliveryText) } } .font(.caption2) @@ -366,7 +413,8 @@ struct MessageBubble: View { .padding(.horizontal, 13) .padding(.vertical, 9) .foregroundStyle( - message.direction == .outgoing ? Color.white.opacity(0.86) : Color.secondary + message.direction == .outgoing + ? Color.white.opacity(0.86) : Color.secondary ) .background(bubbleBackground) .clipShape(messageBubbleShape) @@ -414,7 +462,10 @@ struct MessageBubble: View { .lumaTextSelection() .padding(.horizontal, 13) .padding(.vertical, 9) - .foregroundStyle(message.direction == .outgoing ? Color.white : Color.primary) + .foregroundStyle( + message.direction == .outgoing + ? Color.white : Color.primary + ) .background(bubbleBackground) .clipShape(messageBubbleShape) } @@ -425,7 +476,9 @@ struct MessageBubble: View { .buttonStyle(.plain) .padding(.horizontal, 13) .padding(.vertical, 9) - .foregroundStyle(message.direction == .outgoing ? Color.white : Color.primary) + .foregroundStyle( + message.direction == .outgoing ? Color.white : Color.primary + ) .background(bubbleBackground) .clipShape(messageBubbleShape) case .text, .system: @@ -436,21 +489,26 @@ struct MessageBubble: View { .lumaTextSelection() .padding(.horizontal, 13) .padding(.vertical, 9) - .foregroundStyle(message.direction == .outgoing ? Color.white : Color.primary) + .foregroundStyle( + message.direction == .outgoing + ? Color.white : Color.primary + ) .background(bubbleBackground) .clipShape(messageBubbleShape) } } } - private func callHistoryContent(_ history: CallHistoryMetadata) -> some View { + private func callHistoryContent(_ history: CallHistoryMetadata) -> some View + { HStack(spacing: 12) { ZStack { Circle() .fill( callAccentColor(for: history).opacity( message.direction == .outgoing ? 0.28 : 0.14 - )) + ) + ) Image(systemName: history.isVideo ? "video.fill" : "phone.fill") .font(.system(size: 17, weight: .semibold)) Image( @@ -480,7 +538,9 @@ struct MessageBubble: View { .padding(.horizontal, 14) .padding(.vertical, 10) .frame(maxWidth: 300, alignment: .leading) - .foregroundStyle(message.direction == .outgoing ? Color.white : Color.primary) + .foregroundStyle( + message.direction == .outgoing ? Color.white : Color.primary + ) .background(bubbleBackground) .clipShape(messageBubbleShape) .accessibilityElement(children: .combine) @@ -541,7 +601,9 @@ struct MessageBubble: View { } } .buttonStyle(.plain) - .accessibilityLabel("\(summary.emoji), реакций: \(summary.count)") + .accessibilityLabel( + "\(summary.emoji), реакций: \(summary.count)" + ) } } .padding(.horizontal, 4) @@ -585,7 +647,8 @@ struct MessageBubble: View { } VStack( - alignment: compactTargetDirection == .outgoing ? .trailing : .leading, + alignment: compactTargetDirection == .outgoing + ? .trailing : .leading, spacing: -1 ) { compactSourceBubble @@ -629,7 +692,9 @@ struct MessageBubble: View { @ViewBuilder private var compactReplyThumbnail: some View { - if let target = replyTarget, target.kind != .text, target.kind != .system { + if let target = replyTarget, target.kind != .text, + target.kind != .system + { ZStack { RoundedRectangle(cornerRadius: 6, style: .continuous) .fill(Color.primary.opacity(0.08)) @@ -644,7 +709,10 @@ struct MessageBubble: View { private func replyThumbnailImage(for target: ChatMessage) -> some View { if let data = model.mediaThumbnail(for: target) { #if os(iOS) - if let image = ChatMediaImageCache.image(for: target, data: data) { + if let image = ChatMediaImageCache.image( + for: target, + data: data + ) { Image(uiImage: image) .resizable() .scaledToFill() @@ -653,7 +721,10 @@ struct MessageBubble: View { .foregroundStyle(compactReplyColor) } #elseif os(macOS) - if let image = ChatMediaImageCache.image(for: target, data: data) { + if let image = ChatMediaImageCache.image( + for: target, + data: data + ) { Image(nsImage: image) .resizable() .scaledToFill() @@ -670,7 +741,9 @@ struct MessageBubble: View { } private var replyTarget: ChatMessage? { - message.replyToID.flatMap { model.message(withID: $0, in: message.conversationID) } + message.replyToID.flatMap { + model.message(withID: $0, in: message.conversationID) + } } private var compactTargetDirection: ChatMessage.Direction { @@ -685,17 +758,23 @@ struct MessageBubble: View { } private var replyText: String { - replyTarget?.quotePreview ?? message.replyPreview ?? "Исходное сообщение недоступно" + replyTarget?.quotePreview ?? message.replyPreview + ?? "Исходное сообщение недоступно" } private var mediaContent: some View { HStack(spacing: 11) { ZStack { Circle() - .fill(.white.opacity(message.direction == .outgoing ? 0.22 : 0.12)) + .fill( + .white.opacity( + message.direction == .outgoing ? 0.22 : 0.12 + ) + ) .frame( width: message.kind == .videoNote ? 58 : 38, - height: message.kind == .videoNote ? 58 : 38) + height: message.kind == .videoNote ? 58 : 38 + ) Image(systemName: mediaIcon) .font(.system(size: message.kind == .videoNote ? 24 : 17)) } @@ -734,9 +813,14 @@ struct MessageBubble: View { } if let byteCount = message.byteCount { parts.append( - ByteCountFormatter.string(fromByteCount: Int64(byteCount), countStyle: .file)) + ByteCountFormatter.string( + fromByteCount: Int64(byteCount), + countStyle: .file + ) + ) } - return parts.isEmpty ? "Нажмите для просмотра" : parts.joined(separator: " · ") + return parts.isEmpty + ? "Нажмите для просмотра" : parts.joined(separator: " · ") } private var mediaIcon: String { @@ -789,8 +873,19 @@ struct MessageBubble: View { switch message.delivery { case .sending: return "clock" case .sent: return "checkmark" - case .delivered: return "checkmark.circle.fill" + case .delivered: return "checkmark.circle.dotted" case .failed: return "exclamationmark.circle.fill" + case .read: return "checkmark.circle.fill" + } + } + + private var deliveryText: String { + switch message.delivery { + case .sending: return "Sending..." + case .sent: return "Sended" + case .delivered: return "Delivered" + case .failed: return "Error" + case .read: return "Read" } } diff --git a/Sources/Shared/UI/Components/ReplyThreadOverlay.swift b/Sources/Shared/UI/Components/ReplyThreadOverlay.swift index 309f326..c87a158 100644 --- a/Sources/Shared/UI/Components/ReplyThreadOverlay.swift +++ b/Sources/Shared/UI/Components/ReplyThreadOverlay.swift @@ -339,6 +339,7 @@ private struct ReplyThreadMessageRow: View { case .sent: state = "Отправлено" case .delivered: state = "Доставлено" case .failed: state = "Не отправлено" + case .read: state = "Прочитано" } let value = Calendar.current.isDateInToday(message.timestamp) ? message.timestamp.formatted(date: .omitted, time: .shortened)