2cc77d804d
- Hide the chat content until the initial fetch completes and the ScrollView has laid out at its bottom anchor, then fade it in. No more visible scroll jump when opening a conversation. - Use defaultScrollAnchor(.bottom) so the first layout is already at the newest message. - Add the waving hand emoji to the reaction picker.
116 lines
2.6 KiB
Swift
116 lines
2.6 KiB
Swift
import SwiftUI
|
|
|
|
struct MessageRow: View {
|
|
let message: Message
|
|
let senderName: String
|
|
let isSelf: Bool
|
|
let replyToName: String?
|
|
let canDMSender: Bool
|
|
let onReply: () -> Void
|
|
let onReact: (String) -> Void
|
|
let onDMSender: () -> Void
|
|
|
|
static let reactionEmojis = ["👋", "👍", "❤️", "😂", "😮", "😢", "🙏"]
|
|
|
|
private static let timeFmt: DateFormatter = {
|
|
let f = DateFormatter()
|
|
f.dateFormat = "HH:mm"
|
|
return f
|
|
}()
|
|
|
|
var body: some View {
|
|
HStack {
|
|
if isSelf { Spacer(minLength: 40) }
|
|
VStack(alignment: isSelf ? .trailing : .leading, spacing: 2) {
|
|
header
|
|
bubble
|
|
.contextMenu { menuContent }
|
|
}
|
|
if !isSelf { Spacer(minLength: 40) }
|
|
}
|
|
}
|
|
|
|
private var header: some View {
|
|
HStack(spacing: 6) {
|
|
if !isSelf {
|
|
Text(senderName)
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.tint)
|
|
}
|
|
Text(Self.timeFmt.string(from: message.timestamp))
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
if isSelf {
|
|
Text(senderName)
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.tint)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var bubble: some View {
|
|
HStack(alignment: .bottom, spacing: 4) {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
if let name = replyToName {
|
|
HStack(spacing: 3) {
|
|
Image(systemName: "arrowshape.turn.up.left.fill")
|
|
.font(.caption2)
|
|
Text(name).font(.caption.weight(.semibold))
|
|
}
|
|
.foregroundStyle(isSelf ? Color.white.opacity(0.85) : Color.secondary)
|
|
}
|
|
Text(message.text ?? "")
|
|
.font(.body)
|
|
.foregroundStyle(isSelf ? .white : .primary)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
.fill(isSelf
|
|
? AnyShapeStyle(Color.accentColor)
|
|
: AnyShapeStyle(Color(.systemGray5)))
|
|
)
|
|
if isSelf, message.delivery != .none {
|
|
Image(systemName: message.delivery.icon)
|
|
.font(.caption2)
|
|
.foregroundStyle(deliveryColor)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var menuContent: some View {
|
|
Menu("React", systemImage: "face.smiling") {
|
|
ForEach(Self.reactionEmojis, id: \.self) { e in
|
|
Button {
|
|
onReact(e)
|
|
} label: {
|
|
Text(e)
|
|
}
|
|
}
|
|
}
|
|
Button {
|
|
onReply()
|
|
} label: {
|
|
Label("Reply", systemImage: "arrowshape.turn.up.left")
|
|
}
|
|
if canDMSender {
|
|
Button {
|
|
onDMSender()
|
|
} label: {
|
|
Label("Message \(senderName)", systemImage: "bubble.left.and.bubble.right")
|
|
}
|
|
}
|
|
}
|
|
|
|
private var deliveryColor: Color {
|
|
switch message.delivery {
|
|
case .confirmed: return .green
|
|
case .failed: return .red
|
|
case .pending: return .gray
|
|
case .none: return .clear
|
|
}
|
|
}
|
|
}
|