Files
Luma/Sources/Shared/UI/Components/ReplyThreadOverlay.swift
T
wt cf2bda9c54
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
updated up
2026-09-06 01:23:27 +07:00

373 lines
13 KiB
Swift

import Foundation
import SwiftUI
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
/// iMessage-style focus mode for one source message and its reply thread.
@MainActor
struct ReplyThreadOverlay: View {
@ObservedObject var model: AppModel
let rootMessage: ChatMessage
let replies: [ChatMessage]
let selectedReplyID: String
let onDismiss: () -> Void
@Environment(\.colorScheme) private var colorScheme
@State private var isVisible = false
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .topTrailing) {
Rectangle()
.fill(.ultraThinMaterial)
.ignoresSafeArea()
Color.black
.opacity(colorScheme == .dark ? 0.72 : 0.62)
.ignoresSafeArea()
.contentShape(Rectangle())
.onTapGesture(perform: onDismiss)
ScrollViewReader { proxy in
ScrollView {
VStack(spacing: 14) {
Text(
rootMessage.timestamp,
format: .dateTime
.weekday(.abbreviated)
.month(.abbreviated)
.day()
.hour()
.minute()
)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.white.opacity(0.56))
.padding(.bottom, 2)
threadContent(
maximumBubbleWidth: min(520, geometry.size.width * 0.76)
)
}
.frame(maxWidth: min(760, geometry.size.width))
.padding(.horizontal, 18)
.padding(.top, max(58, geometry.safeAreaInsets.top + 30))
.padding(.bottom, max(34, geometry.safeAreaInsets.bottom + 20))
.frame(maxWidth: .infinity)
}
.scrollIndicators(.hidden)
.onAppear {
isVisible = true
DispatchQueue.main.async {
withAnimation(.easeOut(duration: 0.28)) {
proxy.scrollTo(selectedReplyID, anchor: .center)
}
}
}
}
Button(action: onDismiss) {
Image(systemName: "xmark")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.white.opacity(0.82))
.padding(7)
}
.buttonStyle(.glass)
.buttonBorderShape(.circle)
.background(.clear)
.keyboardShortcut(.cancelAction)
.accessibilityLabel("Закрыть ветку ответов")
.padding(.top, max(12, geometry.safeAreaInsets.top + 6))
.padding(.trailing, 14)
}
.opacity(isVisible ? 1 : 0)
.scaleEffect(isVisible ? 1 : 0.985)
.animation(.easeOut(duration: 0.22), value: isVisible)
}
.transition(.opacity.combined(with: .scale(scale: 0.985)))
}
private func threadContent(maximumBubbleWidth: CGFloat) -> some View {
let showsBracket = replies.count > 1
let bracketOnLeadingEdge = rootMessage.direction == .outgoing
return VStack(spacing: 9) {
ReplyThreadMessageRow(
model: model,
message: rootMessage,
maximumBubbleWidth: maximumBubbleWidth,
isSelected: false,
showsDeliveryStatus: false
)
.id(rootMessage.id)
if showsBracket {
replyCountRow
}
ForEach(replies) { reply in
ReplyThreadMessageRow(
model: model,
message: reply,
maximumBubbleWidth: maximumBubbleWidth,
isSelected: reply.clientID == selectedReplyID,
showsDeliveryStatus: reply.clientID == selectedReplyID
)
.id(reply.clientID)
}
}
.padding(.leading, showsBracket && bracketOnLeadingEdge ? 43 : 0)
.padding(.trailing, showsBracket && !bracketOnLeadingEdge ? 43 : 0)
.overlay(
alignment: bracketOnLeadingEdge ? .leading : .trailing
) {
if showsBracket {
ReplyThreadBracket(opensToTrailing: bracketOnLeadingEdge)
.stroke(
Color.white.opacity(0.25),
style: StrokeStyle(lineWidth: 3, lineCap: .round)
)
.frame(width: 38)
.padding(.vertical, 28)
.allowsHitTesting(false)
}
}
}
private var replyCountRow: some View {
HStack {
if rootMessage.direction == .outgoing { Spacer(minLength: 40) }
Text(replyCountText)
.font(.subheadline.weight(.semibold))
.foregroundStyle(Color(red: 0.16, green: 0.59, blue: 1))
.padding(.horizontal, 13)
if rootMessage.direction == .incoming { Spacer(minLength: 40) }
}
}
private var replyCountText: String {
let value = replies.count
let mod10 = value % 10
let mod100 = value % 100
let noun: String
if mod10 == 1, mod100 != 11 {
noun = "ответ"
} else if (2...4).contains(mod10), !(12...14).contains(mod100) {
noun = "ответа"
} else {
noun = "ответов"
}
return "\(value) \(noun)"
}
}
@MainActor
private struct ReplyThreadMessageRow: View {
@ObservedObject var model: AppModel
let message: ChatMessage
let maximumBubbleWidth: CGFloat
let isSelected: Bool
let showsDeliveryStatus: Bool
var body: some View {
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(.white.opacity(0.62))
.padding(.horizontal, 12)
}
HStack(alignment: .bottom) {
if message.direction == .outgoing { Spacer(minLength: 44) }
focusedBubble
.frame(maxWidth: maximumBubbleWidth, alignment: message.direction == .outgoing ? .trailing : .leading)
.shadow(
color: isSelected ? Color.blue.opacity(0.5) : Color.clear,
radius: isSelected ? 22 : 0,
y: isSelected ? 8 : 0
)
.scaleEffect(isSelected ? 1.018 : 1)
if message.direction == .incoming { Spacer(minLength: 44) }
}
if showsDeliveryStatus, message.direction == .outgoing {
Text(deliveryText)
.font(.caption.weight(.semibold))
.foregroundStyle(.white.opacity(0.54))
.padding(.trailing, 13)
}
}
.animation(.spring(response: 0.34, dampingFraction: 0.78), value: isSelected)
.task(id: message.remoteAttachmentURL) {
if isVisualMedia {
await model.prepareMediaPreview(message)
}
}
}
@ViewBuilder
private var focusedBubble: some View {
if isVisualMedia {
visualMediaBubble
} else {
HStack(spacing: 10) {
if let icon = nonVisualMediaIcon {
Image(systemName: icon)
.font(.system(size: 18, weight: .semibold))
}
Text(displayText)
.font(.body)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.leading, message.direction == .incoming ? 17 : 14)
.padding(.trailing, message.direction == .outgoing ? 17 : 14)
.padding(.vertical, 11)
.foregroundStyle(.white)
.background(focusedBubbleBackground)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
}
}
private var visualMediaBubble: some View {
VStack(alignment: .leading, spacing: 0) {
ZStack {
Color.white.opacity(0.08)
mediaThumbnail
if message.kind != .photo {
Image(systemName: "play.fill")
.font(.system(size: 24, weight: .bold))
.foregroundStyle(.white)
.frame(width: 52, height: 52)
.background(.black.opacity(0.42), in: Circle())
}
}
.frame(width: min(maximumBubbleWidth, 430), height: 190)
.clipped()
Text(message.previewText)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.white)
.padding(.horizontal, 15)
.padding(.vertical, 11)
}
.background(focusedBubbleBackground)
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
}
@ViewBuilder
private var mediaThumbnail: some View {
if let data = model.mediaThumbnail(for: message) {
#if os(iOS)
if let image = ChatMediaImageCache.image(for: message, data: data) {
Image(uiImage: image)
.resizable()
.scaledToFill()
} else {
mediaPlaceholder
}
#elseif os(macOS)
if let image = ChatMediaImageCache.image(for: message, data: data) {
Image(nsImage: image)
.resizable()
.scaledToFill()
} else {
mediaPlaceholder
}
#endif
} else {
mediaPlaceholder
}
}
private var mediaPlaceholder: some View {
Image(systemName: message.kind == .photo ? "photo.fill" : "video.fill")
.font(.system(size: 42, weight: .medium))
.foregroundStyle(.white.opacity(0.62))
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
@ViewBuilder
private var focusedBubbleBackground: some View {
if message.direction == .outgoing {
LinearGradient(
colors: [
Color(red: 0.12, green: 0.51, blue: 0.96),
Color(red: 0.10, green: 0.63, blue: 1)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
} else {
LinearGradient(
colors: [Color.white.opacity(0.34), Color.white.opacity(0.23)],
startPoint: .top,
endPoint: .bottom
)
}
}
private var isVisualMedia: Bool {
!message.isRetracted
&& (message.kind == .photo || message.kind == .video || message.kind == .videoNote)
}
private var nonVisualMediaIcon: String? {
switch message.kind {
case .attachment: return "doc.fill"
case .audio: return "music.note"
case .voice: return "waveform"
case .location: return "location.fill"
case .text, .system, .photo, .video, .videoNote: return nil
}
}
private var displayText: String {
message.isRetracted ? "Сообщение удалено" : message.body
}
private var deliveryText: String {
let state: String
switch message.delivery {
case .sending: state = "Отправка"
case .sent: state = "Отправлено"
case .delivered: state = "Доставлено"
case .failed: state = "Не отправлено"
}
let value = Calendar.current.isDateInToday(message.timestamp)
? message.timestamp.formatted(date: .omitted, time: .shortened)
: message.timestamp.formatted(date: .numeric, time: .omitted)
return "\(state) \(value)"
}
}
#Preview {
ReplyThreadOverlay(
model: PreviewSupport.model,
rootMessage: PreviewSupport.message(
body: "Кто пойдёт на конференцию?",
timestamp: Date().addingTimeInterval(-3_600)
),
replies: [
PreviewSupport.message(
id: "preview-reply-1",
body: "Я пойду!",
timestamp: Date().addingTimeInterval(-2_400)
),
PreviewSupport.message(
id: "preview-reply-2",
body: "Я тоже, но опоздаю на полчаса",
timestamp: Date().addingTimeInterval(-1_800)
),
],
selectedReplyID: "preview-reply-1",
onDismiss: {}
)
}