added .read state
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s

This commit is contained in:
wt committed 2026-09-14 21:49:59 +07:00
1 parent 2d29f82647
commit 299f78de36
3 files changed
+144 -47

No files matched your search

+1
View File
@@ -13,6 +13,7 @@ final class ChatMessage {
case sent
case delivered
case failed
case read
}
enum Security: String, Codable {
+142 -47
View File
@@ -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"
}
}
@@ -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)