import SwiftUI #if os(iOS) import UIKit #elseif os(macOS) import AppKit #endif @MainActor struct MessageBubble: View { let model: AppModel let message: ChatMessage let isSelectionMode: Bool let isSelected: Bool let onAttachmentTap: () -> Void let onEdit: () -> Void let onReply: () -> Void let onForward: () -> Void let onRetry: () -> Void let onToggleSelection: () -> Void let onBeginSelection: () -> Void let onRetract: () -> Void let onDelete: () -> Void let onReplyTap: (String) -> Void let onReact: (String) -> Void let onReactPicker: () -> Void @State private var replySwipeOffset: CGFloat = 0 @State private var replySwipeArmed = false var body: some View { ZStack { replySwipeIndicator messageContainer .offset(x: replySwipeOffset) } .frame(maxWidth: .infinity) .background(isSelected ? Color.accentColor.opacity(0.1) : Color.clear) .contentShape(Rectangle()) .onTapGesture { guard isSelectionMode else { return } onToggleSelection() } .padding( .vertical, message.kind == .photo || message.kind == .video ? 7 : 2 ) .simultaneousGesture( replySwipeGesture, including: !isSelectionMode && message.canBeRepliedTo ? .gesture : .none ) .onChange(of: isSelectionMode) { _, selecting in guard selecting else { return } replySwipeOffset = 0 replySwipeArmed = false } .contextMenu { if !isSelectionMode { if message.canBeReactedTo { Button(action: onReactPicker) { Label("Реакция", systemImage: "face.smiling") } } if message.canBeRepliedTo { Button(action: onReply) { Label("Ответить", systemImage: "arrowshape.turn.up.left") } } if message.canBeForwarded { Button(action: onForward) { Label("Переслать", systemImage: "arrowshape.turn.up.right") } } if message.hasText { Button(action: { #if os(macOS) NSPasteboard.general.clearContents() NSPasteboard.general.setString(message.text, forType: .string) #else UIPasteboard.general.string = message.text #endif }) { Label("Копировать текст", systemImage: "doc.on.doc") } } if model.canRetryMediaMessage(message) { Button(action: onRetry) { Label("Повторить отправку", systemImage: "arrow.clockwise") } } if message.canBeEdited { Button(action: onEdit) { Label("Редактировать", systemImage: "pencil") } } Button(action: onBeginSelection) { Label("Выбрать", systemImage: "checkmark.circle") } Divider() Button(role: .destructive, action: onDelete) { Label("Удалить у меня", systemImage: "trash") } if message.canBeRetracted { Button(role: .destructive, action: onRetract) { Label("Удалить у всех", systemImage: "trash.slash") } } } } .accessibilityLabel(Text(selectionAccessibilityLabel)) .accessibilityAction(named: Text(isSelected ? "Убрать из выбранных" : "Выбрать")) { if isSelectionMode { onToggleSelection() } else { onBeginSelection() } } } 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) } VStack(spacing: 0) { if hasReplyReference { compactReplyReference } messageRow } .allowsHitTesting(!isSelectionMode) } } private var replySwipeIndicator: some View { HStack { if replySwipeOffset >= 0 { replySwipeIcon Spacer() } else { Spacer() replySwipeIcon } } .padding(.horizontal, 18) .allowsHitTesting(false) .opacity(replySwipeOffset == 0 ? 0 : 1) } private var replySwipeIcon: some View { Image( systemName: replySwipeArmed ? "arrowshape.turn.up.left.fill" : "arrowshape.turn.up.left" ) .font(.system(size: 19, weight: .semibold)) .foregroundStyle(Color.accentColor) .scaleEffect( 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 { DragGesture(minimumDistance: 20) .onChanged { value in let offset = MessageReplySwipePolicy.offset(for: value.translation) guard offset != 0 || replySwipeOffset != 0 else { // A vertical timeline pan must not publish per-cell state // changes or compete with UIScrollView's own recognizer. return } replySwipeOffset = offset let armed = MessageReplySwipePolicy.shouldReply( translation: value.translation, predictedEndTranslation: value.translation ) if armed, !replySwipeArmed { replySwipeFeedback() } replySwipeArmed = armed } .onEnded { value in let shouldReply = MessageReplySwipePolicy.shouldReply( translation: value.translation, predictedEndTranslation: value.predictedEndTranslation ) if replySwipeOffset != 0 || replySwipeArmed { withAnimation(.spring(response: 0.24, dampingFraction: 0.82)) { replySwipeOffset = 0 replySwipeArmed = false } } if shouldReply { onReply() } } } private func replySwipeFeedback() { #if os(iOS) UIImpactFeedbackGenerator(style: .light).impactOccurred() #endif } private var selectionAccessibilityLabel: String { guard isSelectionMode else { return message.previewText } return "\(message.previewText), \(isSelected ? "выбрано" : "не выбрано")" } private var messageRow: some View { HStack(alignment: .bottom, spacing: 8) { if message.direction == .outgoing { Spacer(minLength: sideSpacerMinimum) } 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) } messageContent if !reactionSummaries.isEmpty { reactionStrip } if hasReplyReference { replyWarningIndicators } else { standardMetadata } } if message.direction == .incoming { Spacer(minLength: sideSpacerMinimum) } } .padding(.horizontal, 12) .contentShape(Rectangle()) .onTapGesture { guard let targetID = message.replyToID, message.kind == .text || message.kind == .system else { return } onReplyTap(targetID) } } private var sideSpacerMinimum: CGFloat { message.kind.isMedia ? 16 : 54 } @ViewBuilder private var replyWarningIndicators: some View { if message.security != .omemo || message.delivery == .failed { HStack(spacing: 4) { if message.security != .omemo { Image( systemName: message.security == .plaintext ? "lock.open" : "exclamationmark.shield" ) .foregroundStyle(message.security == .plaintext ? .orange : .red) } if message.delivery == .failed { Image(systemName: "exclamationmark.circle.fill") .foregroundStyle(.red) } } .font(.caption2) .padding(.horizontal, 4) } } private var standardMetadata: some View { HStack(spacing: 4) { if message.callHistory == nil, message.security != .omemo { Image( systemName: message.security == .plaintext ? "lock.open" : "exclamationmark.shield" ) .foregroundStyle(message.security == .plaintext ? .orange : .red) } if message.editedAt != nil { Text("изм.") } Text(message.timestamp, format: .dateTime.hour().minute()) if message.direction == .outgoing, message.callHistory == nil { Image(systemName: deliveryIcon) .foregroundStyle(message.delivery == .failed ? .red : .secondary) } } .font(.caption2) .foregroundStyle(.secondary) .padding(.horizontal, 4) } @ViewBuilder private var messageContent: some View { if message.isRetracted { HStack(spacing: 7) { Image(systemName: "trash.slash") Text("Сообщение удалено") .italic() } .font(.subheadline) .padding(.horizontal, 13) .padding(.vertical, 9) .foregroundStyle( message.direction == .outgoing ? Color.white.opacity(0.86) : Color.secondary ) .background(bubbleBackground) .clipShape(messageBubbleShape) } else { regularMessageContent } } @ViewBuilder private var regularMessageContent: some View { switch message.kind { case .videoNote: VideoNotePreview( model: model, message: message, onFallbackTap: onAttachmentTap ) case .video: VideoAttachmentPreview( model: model, message: message, onFallbackTap: onAttachmentTap ) case .audio, .voice: AudioMessagePlayer( model: model, message: message, onFallbackTap: onAttachmentTap ) .padding(.horizontal, 13) .padding(.vertical, 10) .background(bubbleBackground) .clipShape(messageBubbleShape) case .photo: PhotoAttachmentPreview( model: model, message: message, onFallbackTap: onAttachmentTap ) case .location: if let location = GeoLocation(uri: message.body) { LocationMessagePreview(location: location) } else { Text(message.body) .lumaTextSelection() .padding(.horizontal, 13) .padding(.vertical, 9) .foregroundStyle(message.direction == .outgoing ? Color.white : Color.primary) .background(bubbleBackground) .clipShape(messageBubbleShape) } case .attachment: Button(action: onAttachmentTap) { mediaContent } .buttonStyle(.plain) .padding(.horizontal, 13) .padding(.vertical, 9) .foregroundStyle(message.direction == .outgoing ? Color.white : Color.primary) .background(bubbleBackground) .clipShape(messageBubbleShape) case .text, .system: if let callHistory = message.callHistory { callHistoryContent(callHistory) } else { Text(message.body) .lumaTextSelection() .padding(.horizontal, 13) .padding(.vertical, 9) .foregroundStyle(message.direction == .outgoing ? Color.white : Color.primary) .background(bubbleBackground) .clipShape(messageBubbleShape) } } } 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( systemName: message.direction == .outgoing ? "arrow.up.right" : "arrow.down.left" ) .font(.system(size: 8, weight: .heavy)) .padding(3) .background(Circle().fill(callDirectionBadgeBackground)) .offset(x: 14, y: 14) } .frame(width: 42, height: 42) .foregroundStyle(callIconForeground(for: history)) VStack(alignment: .leading, spacing: 3) { Text(message.callTitle) .font(.subheadline.weight(.semibold)) .lineLimit(2) if let subtitle = message.callSubtitle { Text(subtitle) .font(.caption) .opacity(0.78) .lineLimit(1) } } } .padding(.horizontal, 14) .padding(.vertical, 10) .frame(maxWidth: 300, alignment: .leading) .foregroundStyle(message.direction == .outgoing ? Color.white : Color.primary) .background(bubbleBackground) .clipShape(messageBubbleShape) .accessibilityElement(children: .combine) .accessibilityLabel(Text(message.callTitle)) } private func callAccentColor(for history: CallHistoryMetadata) -> Color { switch history.outcome { case .completed: return .green case .declined, .missed, .unanswered, .failed: return .red case .cancelled, .answeredElsewhere: return .secondary } } private func callIconForeground(for history: CallHistoryMetadata) -> Color { message.direction == .outgoing ? .white : callAccentColor(for: history) } private var callDirectionBadgeBackground: Color { message.direction == .outgoing ? Color(red: 0.13, green: 0.57, blue: 0.94) : Color.secondary.opacity(0.16) } private var reactionSummaries: [MessageReactionSummary] { message.reactionSummaries(ownJID: model.account?.normalizedJID) } private var reactionStrip: some View { HStack(spacing: 5) { ForEach(reactionSummaries) { summary in Button { onReact(summary.emoji) } label: { HStack(spacing: 3) { Text(summary.emoji) Text(String(summary.count)) .font(.caption2.weight(.semibold)) } .padding(.horizontal, 8) .padding(.vertical, 4) .background( summary.includesOwnReaction ? Color.accentColor.opacity(0.2) : Color.secondary.opacity(0.12), in: Capsule() ) .overlay { Capsule().stroke( summary.includesOwnReaction ? Color.accentColor.opacity(0.55) : Color.secondary.opacity(0.16), lineWidth: 0.8 ) } } .buttonStyle(.plain) .accessibilityLabel("\(summary.emoji), реакций: \(summary.count)") } } .padding(.horizontal, 4) } private var hasReplyReference: Bool { message.replyToID != nil || message.replyPreview != nil } private var compactReplyReference: some View { Group { if let targetID = message.replyToID { Button { onReplyTap(targetID) } label: { compactReplyLayout } .buttonStyle(.plain) .accessibilityLabel("Открыть ветку ответа") } else { compactReplyLayout } } .opacity(0.56) .padding(.bottom, -2) .task(id: replyTarget?.remoteAttachmentURL) { if let replyTarget, replyTarget.kind == .photo || replyTarget.kind == .video || replyTarget.kind == .videoNote { await model.prepareMediaPreview(replyTarget) } } } private var compactReplyLayout: some View { HStack { if compactTargetDirection == .outgoing { Spacer(minLength: 58) } VStack( alignment: compactTargetDirection == .outgoing ? .trailing : .leading, spacing: -1 ) { compactSourceBubble ReplyThreadCurve(sourceDirection: compactTargetDirection) .stroke( compactReplyColor.opacity(0.72), style: StrokeStyle(lineWidth: 2.4, lineCap: .round) ) .frame(width: 58, height: 34) .offset(x: compactTargetDirection == .outgoing ? -18 : 18) } if compactTargetDirection == .incoming { Spacer(minLength: 58) } } .padding(.horizontal, 18) .frame(maxWidth: .infinity) } private var compactSourceBubble: some View { HStack(spacing: 7) { compactReplyThumbnail Text(replyText) .font(.callout.weight(.medium)) .foregroundStyle(compactReplyColor) .lineLimit(1) .multilineTextAlignment(.leading) } .padding(.leading, compactTargetDirection == .incoming ? 12 : 10) .padding(.trailing, compactTargetDirection == .outgoing ? 12 : 10) .padding(.vertical, 7) .frame(maxWidth: 230, minHeight: 34, alignment: .leading) .background(Color.primary.opacity(0.018)) .clipShape(RoundedRectangle(cornerRadius: 17, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 17, style: .continuous) .stroke(compactReplyColor.opacity(0.75), lineWidth: 1.25) } } @ViewBuilder private var compactReplyThumbnail: some View { if let target = replyTarget, target.kind != .text, target.kind != .system { ZStack { RoundedRectangle(cornerRadius: 6, style: .continuous) .fill(Color.primary.opacity(0.08)) replyThumbnailImage(for: target) } .frame(width: 27, height: 27) .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) } } @ViewBuilder 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) { Image(uiImage: image) .resizable() .scaledToFill() } else { Image(systemName: replyMediaIcon(for: target.kind)) .foregroundStyle(compactReplyColor) } #elseif os(macOS) if let image = ChatMediaImageCache.image(for: target, data: data) { Image(nsImage: image) .resizable() .scaledToFill() } else { Image(systemName: replyMediaIcon(for: target.kind)) .foregroundStyle(compactReplyColor) } #endif } else { Image(systemName: replyMediaIcon(for: target.kind)) .font(.system(size: 12, weight: .semibold)) .foregroundStyle(compactReplyColor) } } private var replyTarget: ChatMessage? { message.replyToID.flatMap { model.message(withID: $0, in: message.conversationID) } } private var compactTargetDirection: ChatMessage.Direction { if let direction = replyTarget?.direction { return direction } return message.direction == .outgoing ? .incoming : .outgoing } private var compactReplyColor: Color { compactTargetDirection == .outgoing ? Color(red: 0.18, green: 0.59, blue: 0.98) : Color.secondary } private var replyText: String { replyTarget?.quotePreview ?? message.replyPreview ?? "Исходное сообщение недоступно" } private var mediaContent: some View { HStack(spacing: 11) { ZStack { Circle() .fill(.white.opacity(message.direction == .outgoing ? 0.22 : 0.12)) .frame( width: message.kind == .videoNote ? 58 : 38, height: message.kind == .videoNote ? 58 : 38) Image(systemName: mediaIcon) .font(.system(size: message.kind == .videoNote ? 24 : 17)) } VStack(alignment: .leading, spacing: 2) { Text(mediaTitle) .font(.subheadline.weight(.semibold)) .lineLimit(2) Text(mediaSubtitle) .font(.caption) .opacity(0.8) } } } private var mediaTitle: String { switch message.kind { case .photo: return "Фото" case .video: return "Видео" case .audio: return message.localFilename ?? "Аудио" case .voice: return "Голосовое сообщение" case .videoNote: return "Видеосообщение" case .attachment: return message.localFilename ?? message.body case .location: return "Геопозиция" case .text, .system: return message.body } } private var mediaSubtitle: String { if message.remoteAttachmentURL == nil { return "Подготовка…" } var parts: [String] = [] if let duration = message.duration { let seconds = max(0, Int(duration.rounded())) parts.append(String(format: "%d:%02d", seconds / 60, seconds % 60)) } if let byteCount = message.byteCount { parts.append( ByteCountFormatter.string(fromByteCount: Int64(byteCount), countStyle: .file)) } return parts.isEmpty ? "Нажмите для просмотра" : parts.joined(separator: " · ") } private var mediaIcon: String { switch message.kind { case .attachment: return "doc.fill" case .photo: return "photo.fill" case .video: return "play.rectangle.fill" case .audio: return "music.note" case .voice: return "waveform" case .videoNote: return "video.circle.fill" case .location: return "location.fill" case .text, .system: return "doc.fill" } } private func replyMediaIcon(for kind: ChatMessage.Kind) -> String { switch kind { case .photo: return "photo.fill" case .video: return "play.fill" case .audio: return "music.note" case .voice: return "waveform" case .videoNote: return "video.circle.fill" case .attachment: return "doc.fill" case .location: return "location.fill" case .text, .system: return "quote.bubble.fill" } } @ViewBuilder private var bubbleBackground: some View { if message.direction == .outgoing { LinearGradient( colors: [ Color(red: 0.13, green: 0.51, blue: 0.94), Color(red: 0.12, green: 0.63, blue: 0.94), ], startPoint: .topLeading, endPoint: .bottomTrailing ) } else { Color.secondary.opacity(0.13) } } private var messageBubbleShape: RoundedRectangle { RoundedRectangle(cornerRadius: 18, style: .continuous) } private var deliveryIcon: String { switch message.delivery { case .sending: return "clock" case .sent: return "checkmark" case .delivered: return "checkmark.circle.fill" case .failed: return "exclamationmark.circle.fill" } } private var senderColor: Color { color(for: message.senderJID) } private func color(for jid: String) -> Color { let seed = jid.utf8.reduce(UInt64(2_166_136_261)) { value, byte in (value ^ UInt64(byte)) &* 16_777_619 } return Color( hue: Double(seed % 360) / 360, saturation: 0.72, brightness: 0.78 ) } } extension View { @ViewBuilder fileprivate func lumaTextSelection() -> some View { #if os(macOS) textSelection(.enabled) #else self #endif } }