added .read state
This commit is contained in:
1 parent
2d29f82647
commit
299f78de36
3 files changed
+144
-47
No files matched your search
@@ -13,6 +13,7 @@ final class ChatMessage {
|
||||
case sent
|
||||
case delivered
|
||||
case failed
|
||||
case read
|
||||
}
|
||||
|
||||
enum Security: String, Codable {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user