38 KiB
38 KiB
iOS Client — v0.8.4 Changes
Reakce (1 per user), Pinned Messages (banner), Forwarding, @Mentions, mark_read optimalizace.
1. Models/Message.swift — Nové fieldy
struct Message: Identifiable, Equatable {
let id: String
let conversationId: String
let senderId: String
var senderUsername: String
let createdAt: Date
var text: String?
var replyTo: String?
var imageFileId: String?
var file: FileInfo?
var isDeleted: Bool
var readBy: Set<String>
// --- v0.8.4 NEW ---
var reactions: [Reaction] // reakce na zprávu
var pinnedAt: String? // ISO timestamp pokud pinnutá, nil jinak
var pinnedBy: String? // user_id kdo pinnul
var forwardedFrom: ForwardInfo? // info o přeposlání
func isMine(currentUserId: String) -> Bool {
senderId == currentUserId
}
/// Vrátí reakci aktuálního uživatele (max 1 per user)
func myReaction(currentUserId: String) -> String? {
reactions.first(where: { $0.userId == currentUserId })?.reaction
}
var isPinned: Bool { pinnedAt != nil }
static func == (lhs: Message, rhs: Message) -> Bool {
lhs.id == rhs.id
}
}
// --- v0.8.4 NEW structs ---
struct Reaction: Equatable {
let userId: String
let reaction: String // "thumbsup", "heart", "laugh", "surprised", "sad", "thumbsdown"
}
struct ForwardInfo: Equatable {
let sender: String // original sender username
let conversationId: String // original conversation
let messageId: String // original message id
}
struct FileInfo: Equatable, Codable {
let fileId: String
let aesKey: String
let iv: String
let filename: String
let size: Int
let mimeType: String
}
2. Core/ChatClient.swift — Nové notifikace + metody
2a. ChatNotification enum — přidat 3 nové case
enum ChatNotification {
// ... existující cases ...
case sessionReset(data: [String: Any])
case connectionStateChanged(connected: Bool)
// --- v0.8.4 NEW ---
case messageReacted(data: [String: Any])
case messagePinned(data: [String: Any])
case messageUnpinned(data: [String: Any])
}
2b. routeMessage() — přidat do notificationTypes setu a switch
// V notificationTypes Set přidat:
let notificationTypes = Set([
"new_message", "messages_read", "message_deleted",
"conversation_created", "member_added", "member_removed",
"user_online", "user_offline", "online_users",
"group_invitation", "conversation_renamed", "session_reset",
// v0.8.4:
"message_reacted", "message_pinned", "message_unpinned"
])
// V switch přidat:
case "message_reacted":
notificationContinuation?.yield(.messageReacted(data: data))
case "message_pinned":
notificationContinuation?.yield(.messagePinned(data: data))
case "message_unpinned":
notificationContinuation?.yield(.messageUnpinned(data: data))
2c. getMessages() — parsovat reactions, pinned, forwarded_from
V getMessages(), při vytváření Message objektu (cca řádek 1289), parsovat nová pole:
// Po dekrypci JSON payloadu (jsonObj), před vytvořením Message:
// --- v0.8.4: Parse forwarded_from ---
var forwardedFrom: ForwardInfo?
if let fwd = jsonObj["forwarded_from"] as? [String: Any] {
forwardedFrom = ForwardInfo(
sender: fwd["sender"] as? String ?? "?",
conversationId: fwd["conversation_id"] as? String ?? "",
messageId: fwd["message_id"] as? String ?? ""
)
}
// --- v0.8.4: Parse reactions from server response (on msgDict, not jsonObj!) ---
var reactions: [Reaction] = []
if let reactionsRaw = msgDict["reactions"] as? [[String: Any]] {
for r in reactionsRaw {
if let uid = r["user_id"] as? String, let rtype = r["reaction"] as? String {
reactions.append(Reaction(userId: uid, reaction: rtype))
}
}
}
// --- v0.8.4: Parse pinned ---
let pinnedAt = msgDict["pinned_at"] as? String // ISO string or nil
let pinnedBy = msgDict["pinned_by"] as? String
// Pak v Message(...) přidat:
messages.append(Message(
id: msgId, conversationId: convId, senderId: senderId,
senderUsername: msgDict.string(for: "sender_username") ?? "",
createdAt: createdAt, text: messageText, replyTo: replyTo,
imageFileId: msgDict.string(for: "image_file_id"), file: file,
isDeleted: false, readBy: [],
reactions: reactions, // NEW
pinnedAt: pinnedAt, // NEW
pinnedBy: pinnedBy, // NEW
forwardedFrom: forwardedFrom // NEW
))
POZOR: Taky pro deleted messages a fallback append volat s defaultními hodnotami:
reactions: [], pinnedAt: nil, pinnedBy: nil, forwardedFrom: nil
2d. Nové metody — react, pin, get_pinned, forward
// MARK: - Reactions (v0.8.4)
static let allowedReactions = ["thumbsup", "heart", "laugh", "surprised", "sad", "thumbsdown"]
func reactMessage(messageId: String, reaction: String, action: String = "add") async -> Bool {
let resp = await sendAndReceive(type: "react_message", params: [
"message_id": messageId,
"reaction": reaction,
"action": action,
])
return resp.string(for: "status") == "ok"
}
// MARK: - Pins (v0.8.4)
func pinMessage(messageId: String, conversationId: String, action: String = "pin") async -> Bool {
let resp = await sendAndReceive(type: "pin_message", params: [
"message_id": messageId,
"conversation_id": conversationId,
"action": action,
])
return resp.string(for: "status") == "ok"
}
func getPinnedMessages(conversationId: String) async -> [String] {
let resp = await sendAndReceive(type: "get_pinned_messages", params: [
"conversation_id": conversationId,
])
guard resp.string(for: "status") == "ok",
let data = resp.dict(for: "data"),
let msgs = data["messages"] as? [[String: Any]] else {
return []
}
return msgs.compactMap { $0["message_id"] as? String }
}
// MARK: - Forward (v0.8.4)
func forwardMessage(targetConvId: String, originalMsg: Message,
targetMembers: [ConversationMember]) async -> Bool {
// Forward = normální send_message s forwarded_from v payloadu
var text = originalMsg.text ?? ""
if originalMsg.imageFileId != nil {
text = "[Forwarded image]"
}
if originalMsg.file != nil {
text = "[Forwarded file: \(originalMsg.file?.filename ?? "file")]"
}
// Sestavit payload JSON s forwarded_from
// Tady záleží na implementaci sendMessage — buď přidat parametr forwardedFrom,
// nebo vytvořit payload ručně. Nejjednodušší: přidat optional parametr do sendMessage.
let (success, _) = await sendMessage(
convId: targetConvId, text: text, members: targetMembers,
forwardedFrom: ForwardInfo(
sender: originalMsg.senderUsername,
conversationId: originalMsg.conversationId,
messageId: originalMsg.id
)
)
return success
}
2e. sendMessage() — přidat optional forwardedFrom parametr
V existující sendMessage() funkci přidat parametr a propagovat do payloadu:
func sendMessage(convId: String, text: String, members: [ConversationMember],
replyTo: String? = nil,
forwardedFrom: ForwardInfo? = nil // NEW
) async -> (Bool, String) {
// ... existující kód ...
// Kde se sestavuje payload dict (jsonObj), přidat:
if let fwd = forwardedFrom {
payload["forwarded_from"] = [
"sender": fwd.sender,
"conversation_id": fwd.conversationId,
"message_id": fwd.messageId,
]
}
// ... zbytek beze změny ...
}
2f. markRead optimalizace
V getMessages(), po sestavení messages pole a před return, změnit mark_read logiku:
// STARÉ (řádky 21-25 v ChatViewModel.loadMessages):
let unreadIds = messages.filter { !$0.isMine(currentUserId: ...) }.map(\.id)
// NOVÉ — filtrovat jen zprávy co ještě nejsou přečtené:
// V getMessages() zpracovat read_by z server response:
let readByRaw = msgDict["read_by"] as? [[String: Any]] ?? []
let readBySet = Set(readByRaw.compactMap { $0["user_id"] as? String })
// ... a předat do Message(... readBy: readBySet ...)
// Pak v ChatViewModel.loadMessages:
let myId = await chatClient.userId ?? ""
let unreadIds = messages.filter {
!$0.isMine(currentUserId: myId) && !$0.readBy.contains(myId)
}.map(\.id)
if !unreadIds.isEmpty {
await chatClient.markRead(convId: convId, messageIds: unreadIds)
}
3. ViewModels/ChatViewModel.swift — Notification handling + nové metody
@Observable
final class ChatViewModel {
var messages: [Message] = []
var isLoading = false
var isSending = false
var errorMessage: String?
var searchQuery = ""
var searchResults: [String] = []
var currentSearchIndex = 0
var pinnedMessage: Message? // NEW — pro banner
private var notificationTask: Task<Void, Never>?
func loadMessages(convId: String, chatClient: ChatClient) async {
isLoading = true
messages = await chatClient.getMessages(convId: convId, limit: 50)
isLoading = false
// v0.8.4: Jen nepřečtené zprávy od jiných
let myId = await chatClient.userId ?? ""
let unreadIds = messages.filter {
!$0.isMine(currentUserId: myId) && !$0.readBy.contains(myId)
}.map(\.id)
if !unreadIds.isEmpty {
await chatClient.markRead(convId: convId, messageIds: unreadIds)
}
// v0.8.4: Update pin banner
updatePinnedBanner()
}
// --- v0.8.4 NEW ---
/// Aktualizovat pin banner z aktuálních zpráv
func updatePinnedBanner() {
pinnedMessage = messages.last(where: { $0.isPinned })
}
/// Reakce — optimistický update + server call
func react(messageId: String, reaction: String, chatClient: ChatClient) async {
let myId = await chatClient.userId ?? ""
// Optimistický update
if let idx = messages.firstIndex(where: { $0.id == messageId }) {
let existing = messages[idx].myReaction(currentUserId: myId)
if existing == reaction {
// Toggle off
messages[idx].reactions.removeAll { $0.userId == myId }
await chatClient.reactMessage(messageId: messageId, reaction: reaction, action: "remove")
} else {
// Nahradit (1 per user)
messages[idx].reactions.removeAll { $0.userId == myId }
messages[idx].reactions.append(Reaction(userId: myId, reaction: reaction))
await chatClient.reactMessage(messageId: messageId, reaction: reaction, action: "add")
}
}
}
/// Pin/Unpin — optimistický update + server call
func togglePin(messageId: String, convId: String, chatClient: ChatClient) async {
let myId = await chatClient.userId ?? ""
if let idx = messages.firstIndex(where: { $0.id == messageId }) {
if messages[idx].isPinned {
messages[idx].pinnedAt = nil
messages[idx].pinnedBy = nil
await chatClient.pinMessage(messageId: messageId, conversationId: convId, action: "unpin")
} else {
messages[idx].pinnedAt = "now"
messages[idx].pinnedBy = myId
await chatClient.pinMessage(messageId: messageId, conversationId: convId, action: "pin")
}
updatePinnedBanner()
}
}
// --- Notification handler — přidat nové cases ---
@MainActor
private func handleNotification(_ notification: ChatNotification, convId: String, chatClient: ChatClient) {
switch notification {
// ... existující cases (newMessage, messageDeleted, messagesRead) ...
// --- v0.8.4 NEW ---
case .messageReacted(let data):
guard data["conversation_id"] as? String == convId else { break }
let msgId = data["message_id"] as? String ?? ""
let userId = data["user_id"] as? String ?? ""
let reaction = data["reaction"] as? String ?? ""
let action = data["action"] as? String ?? "add"
if let idx = messages.firstIndex(where: { $0.id == msgId }) {
if action == "add" {
// Remove old (1 per user) + add new
messages[idx].reactions.removeAll { $0.userId == userId }
messages[idx].reactions.append(Reaction(userId: userId, reaction: reaction))
} else {
messages[idx].reactions.removeAll { $0.userId == userId }
}
}
case .messagePinned(let data):
guard data["conversation_id"] as? String == convId else { break }
let msgId = data["message_id"] as? String ?? ""
let userId = data["user_id"] as? String ?? ""
if let idx = messages.firstIndex(where: { $0.id == msgId }) {
messages[idx].pinnedAt = "now"
messages[idx].pinnedBy = userId
updatePinnedBanner()
}
case .messageUnpinned(let data):
guard data["conversation_id"] as? String == convId else { break }
let msgId = data["message_id"] as? String ?? ""
if let idx = messages.firstIndex(where: { $0.id == msgId }) {
messages[idx].pinnedAt = nil
messages[idx].pinnedBy = nil
updatePinnedBanner()
}
default:
break
}
}
}
4. Views/Chat/MessageBubbleView.swift — Reakce, forwarded, pin, context menu
import SwiftUI
struct MessageBubbleView: View {
let message: Message
let isMine: Bool
let currentUserId: String // NEW — pro reaction check
var isHighlighted: Bool = false
var isCurrentSearchResult: Bool = false
var onReply: (() -> Void)?
var onDelete: (() -> Void)?
var onReact: ((String) -> Void)? // NEW — reaction callback
var onPin: (() -> Void)? // NEW — pin callback
var onForward: (() -> Void)? // NEW — forward callback
// Emoji mapa
private static let reactionEmoji: [String: String] = [
"thumbsup": "👍", "heart": "❤️", "laugh": "😂",
"surprised": "😮", "sad": "😢", "thumbsdown": "👎",
]
var body: some View {
HStack {
if isMine { Spacer(minLength: 60) }
VStack(alignment: isMine ? .trailing : .leading, spacing: 4) {
if !isMine {
Text(message.senderUsername)
.font(.caption.bold())
.foregroundStyle(.secondary)
}
if message.isDeleted {
Text("Message deleted")
.font(.body.italic())
.foregroundStyle(.secondary)
.padding(12)
.background(Color(.systemGray6))
.clipShape(RoundedRectangle(cornerRadius: 16))
} else {
// --- v0.8.4: Forwarded from header ---
if let fwd = message.forwardedFrom {
HStack(spacing: 4) {
Rectangle()
.fill(.cyan.opacity(0.6))
.frame(width: 2)
VStack(alignment: .leading, spacing: 0) {
Text("Forwarded from")
.font(.caption2)
.foregroundStyle(.secondary)
Text(fwd.sender)
.font(.caption.bold())
.foregroundStyle(.cyan)
}
}
.padding(.horizontal, 8)
.padding(.vertical, 2)
}
// Reply reference
if let _ = message.replyTo {
HStack(spacing: 4) {
Rectangle()
.fill(.blue.opacity(0.5))
.frame(width: 2)
Text("Reply to message")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 8)
}
// File card
if let file = message.file {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: "paperclip")
Text(file.filename).lineLimit(1)
}
.font(.subheadline)
Text(formatFileSize(file.size))
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(12)
.background(Color(.systemGray5))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
// Text content + pin indicator
if let text = message.text {
HStack(alignment: .top, spacing: 4) {
Text(highlightMentions(text))
.padding(12)
// --- v0.8.4: Pin indicator ---
if message.isPinned {
Text("📌")
.font(.caption2)
.padding(.top, 8)
}
}
.background(isMine ? Color.blue : Color(.systemGray5))
.foregroundStyle(isMine ? .white : .primary)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
// --- v0.8.4: Reaction badges ---
if !message.reactions.isEmpty {
reactionBadges
}
// Timestamp
Text(formatTime(message.createdAt))
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.background(
isCurrentSearchResult ? Color.orange.opacity(0.3) :
isHighlighted ? Color.yellow.opacity(0.2) : Color.clear
)
.clipShape(RoundedRectangle(cornerRadius: 16))
.contextMenu {
if !message.isDeleted {
Button(action: { onReply?() }) {
Label("Reply", systemImage: "arrowshape.turn.up.left")
}
Button(action: { UIPasteboard.general.string = message.text ?? "" }) {
Label("Copy", systemImage: "doc.on.doc")
}
// --- v0.8.4: Forward ---
Button(action: { onForward?() }) {
Label("Forward", systemImage: "arrowshape.turn.up.right")
}
// --- v0.8.4: Pin/Unpin ---
Button(action: { onPin?() }) {
Label(message.isPinned ? "Unpin" : "Pin",
systemImage: message.isPinned ? "pin.slash" : "pin")
}
Divider()
// --- v0.8.4: Reactions submenu ---
Menu {
ForEach(Array(Self.reactionEmoji.sorted(by: { $0.key < $1.key })), id: \.key) { key, emoji in
Button(action: { onReact?(key) }) {
let isMine = message.myReaction(currentUserId: currentUserId) == key
Label(
"\(emoji) \(isMine ? "✓" : "")",
systemImage: isMine ? "checkmark.circle.fill" : "face.smiling"
)
}
}
} label: {
Label("React", systemImage: "face.smiling")
}
if isMine {
Divider()
Button(role: .destructive, action: { onDelete?() }) {
Label("Delete", systemImage: "trash")
}
}
}
}
if !isMine { Spacer(minLength: 60) }
}
}
// --- v0.8.4: Reaction badges view ---
private var reactionBadges: some View {
// Seskupit reakce: [reaction: [userId]]
let grouped = Dictionary(grouping: message.reactions, by: \.reaction)
return HStack(spacing: 4) {
ForEach(grouped.sorted(by: { $0.key < $1.key }), id: \.key) { reaction, users in
let emoji = Self.reactionEmoji[reaction] ?? reaction
let isMine = users.contains(where: { $0.userId == currentUserId })
HStack(spacing: 2) {
Text(emoji)
.font(.caption2)
if users.count > 1 {
Text("\(users.count)")
.font(.caption2)
}
}
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(isMine ? Color.blue.opacity(0.2) : Color(.systemGray5))
.clipShape(Capsule())
.overlay(
Capsule()
.stroke(isMine ? Color.blue.opacity(0.5) : Color.clear, lineWidth: 1)
)
}
}
}
// --- v0.8.4: @mention highlighting ---
private func highlightMentions(_ text: String) -> AttributedString {
var result = AttributedString(text)
// Najít @username patterny a zvýraznit modře
let pattern = try? NSRegularExpression(pattern: "@(\\w+)")
let nsText = text as NSString
let matches = pattern?.matches(in: text, range: NSRange(location: 0, length: nsText.length)) ?? []
for match in matches.reversed() {
if let range = Range(match.range, in: text),
let attrRange = Range(range, in: result) {
result[attrRange].foregroundColor = .blue
result[attrRange].font = .body.bold()
}
}
return result
}
private func formatTime(_ date: Date) -> String {
let formatter = DateFormatter()
if Calendar.current.isDateInToday(date) {
formatter.dateFormat = "HH:mm"
} else {
formatter.dateFormat = "MMM d, HH:mm"
}
return formatter.string(from: date)
}
private func formatFileSize(_ bytes: Int) -> String {
if bytes < 1024 { return "\(bytes) B" }
if bytes < 1024 * 1024 { return "\(bytes / 1024) KB" }
return String(format: "%.1f MB", Double(bytes) / (1024 * 1024))
}
}
5. Views/Chat/ChatView.swift — Pin banner, forward dialog, nové callbacky
import SwiftUI
struct ChatView: View {
let conversation: Conversation
var appState: AppState
@State private var viewModel = ChatViewModel()
@State private var inputText = ""
@State private var replyTo: Message?
@State private var showGroupInfo = false
@State private var showSearch = false
@State private var showDeleteConfirm = false
@State private var showForwardPicker: Message? // NEW — zpráva k přeposlání
@State private var showPinnedList = false // NEW — dialog pinnutých zpráv
var body: some View {
VStack(spacing: 0) {
// Search bar
if showSearch {
SearchOverlayView(
query: $viewModel.searchQuery,
matchCount: viewModel.searchResults.count,
currentIndex: viewModel.currentSearchIndex,
onSearch: { viewModel.search(query: $0) },
onNext: { viewModel.nextSearchResult() },
onPrev: { viewModel.prevSearchResult() },
onClose: { showSearch = false; viewModel.search(query: "") }
)
}
// --- v0.8.4: Pinned message banner ---
if let pinned = viewModel.pinnedMessage {
PinnedBannerView(message: pinned) {
// Scroll to pinned message
// (proxy reference needed — viz ScrollViewReader níže)
}
.onTapGesture {
showPinnedList = true
}
}
// Messages
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 8) {
if viewModel.messages.count >= 50 {
Button("Load older messages") {
Task {
await viewModel.loadOlderMessages(
convId: conversation.id,
chatClient: appState.chatClient
)
}
}
.font(.caption)
.padding()
}
ForEach(viewModel.messages) { message in
MessageBubbleView(
message: message,
isMine: message.isMine(currentUserId: appState.currentUser?.id ?? ""),
currentUserId: appState.currentUser?.id ?? "", // NEW
isHighlighted: viewModel.searchResults.contains(message.id),
isCurrentSearchResult: viewModel.searchResults.indices.contains(viewModel.currentSearchIndex) &&
viewModel.searchResults[viewModel.currentSearchIndex] == message.id,
onReply: { replyTo = message },
onDelete: {
Task {
await viewModel.deleteMessage(
messageId: message.id,
convId: conversation.id,
chatClient: appState.chatClient
)
}
},
// --- v0.8.4 NEW callbacks ---
onReact: { reaction in
Task {
await viewModel.react(
messageId: message.id,
reaction: reaction,
chatClient: appState.chatClient
)
}
},
onPin: {
Task {
await viewModel.togglePin(
messageId: message.id,
convId: conversation.id,
chatClient: appState.chatClient
)
}
},
onForward: {
showForwardPicker = message
}
)
.id(message.id)
}
}
.padding(.horizontal)
.padding(.vertical, 8)
}
.onChange(of: viewModel.messages.count) {
if let lastId = viewModel.messages.last?.id {
withAnimation {
proxy.scrollTo(lastId, anchor: .bottom)
}
}
}
// --- v0.8.4: Scroll to pinned on banner tap ---
.onChange(of: showPinnedList) {
if !showPinnedList, let pinId = viewModel.pinnedMessage?.id {
withAnimation {
proxy.scrollTo(pinId, anchor: .center)
}
}
}
}
// Reply preview
if let reply = replyTo {
HStack {
Rectangle().fill(.blue).frame(width: 3)
VStack(alignment: .leading) {
Text(reply.senderUsername).font(.caption.bold())
Text(reply.text ?? "").font(.caption).lineLimit(1)
}
Spacer()
Button(action: { replyTo = nil }) {
Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)
}
}
.padding(.horizontal)
.padding(.vertical, 6)
.background(.ultraThinMaterial)
}
// Input
MessageInputView(
text: $inputText,
isSending: viewModel.isSending,
onSend: {
Task {
let text = inputText
inputText = ""
let reply = replyTo?.id
replyTo = nil
await viewModel.sendMessage(
convId: conversation.id,
text: text,
members: conversation.members,
chatClient: appState.chatClient,
replyTo: reply
)
}
}
)
}
.navigationTitle(conversation.displayName(currentUserId: appState.currentUser?.id ?? ""))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
HStack(spacing: 16) {
// --- v0.8.4: Pinned messages button ---
Button(action: { showPinnedList = true }) {
Image(systemName: "pin")
}
Button(action: { showSearch.toggle() }) {
Image(systemName: "magnifyingglass")
}
if conversation.isGroup {
Button(action: { showGroupInfo = true }) {
Image(systemName: "info.circle")
}
}
if !conversation.isGroup || conversation.createdBy == appState.currentUser?.id {
Button(action: { showDeleteConfirm = true }) {
Image(systemName: "trash").foregroundStyle(.red)
}
}
}
}
}
.alert("Delete Conversation?", isPresented: $showDeleteConfirm) {
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) {
Task { await appState.chatClient.deleteConversation(convId: conversation.id) }
}
} message: {
Text(conversation.isGroup
? "This will remove all members and delete the conversation."
: "This will remove you from the conversation.")
}
// --- v0.8.4: Forward picker sheet ---
.sheet(item: $showForwardPicker) { message in
ForwardPickerView(
message: message,
appState: appState,
onForward: { targetConv in
Task {
await appState.chatClient.forwardMessage(
targetConvId: targetConv.id,
originalMsg: message,
targetMembers: targetConv.members
)
}
showForwardPicker = nil
}
)
}
// --- v0.8.4: Pinned messages list sheet ---
.sheet(isPresented: $showPinnedList) {
PinnedMessagesView(
messages: viewModel.messages.filter(\.isPinned),
onSelect: { msg in
showPinnedList = false
// ScrollViewReader scroll handled by onChange above
}
)
}
.sheet(isPresented: $showGroupInfo) {
GroupInfoView(conversation: conversation, appState: appState)
}
.task {
await viewModel.loadMessages(convId: conversation.id, chatClient: appState.chatClient)
viewModel.startNotificationListener(convId: conversation.id, chatClient: appState.chatClient)
}
.onDisappear {
viewModel.stop()
}
}
}
POZNÁMKA: Message musí být Identifiable (už je) pro .sheet(item:) na showForwardPicker.
6. Nové pomocné views
Views/Chat/PinnedBannerView.swift (NOVÝ SOUBOR)
import SwiftUI
struct PinnedBannerView: View {
let message: Message
var onTap: (() -> Void)?
var body: some View {
HStack(spacing: 8) {
Image(systemName: "pin.fill")
.foregroundStyle(.yellow)
.font(.caption)
VStack(alignment: .leading, spacing: 1) {
Text(message.senderUsername)
.font(.caption.bold())
Text(message.text ?? "")
.font(.caption)
.lineLimit(1)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color(.systemGray5))
.contentShape(Rectangle())
.onTapGesture { onTap?() }
}
}
Views/Chat/ForwardPickerView.swift (NOVÝ SOUBOR)
import SwiftUI
struct ForwardPickerView: View {
let message: Message
var appState: AppState
var onForward: (Conversation) -> Void
@State private var conversations: [Conversation] = []
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List(conversations) { conv in
Button(action: { onForward(conv) }) {
HStack {
Text(conv.displayName(currentUserId: appState.currentUser?.id ?? ""))
Spacer()
Image(systemName: "arrowshape.turn.up.right")
.foregroundStyle(.secondary)
}
}
}
.navigationTitle("Forward to...")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
.task {
conversations = await appState.chatClient.listConversations()
.filter { $0.id != message.conversationId }
}
}
}
}
Views/Chat/PinnedMessagesView.swift (NOVÝ SOUBOR)
import SwiftUI
struct PinnedMessagesView: View {
let messages: [Message]
var onSelect: (Message) -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
Group {
if messages.isEmpty {
ContentUnavailableView(
"No Pinned Messages",
systemImage: "pin.slash",
description: Text("Pin important messages to find them easily.")
)
} else {
List(messages) { msg in
Button(action: { onSelect(msg) }) {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: "pin.fill")
.foregroundStyle(.yellow)
.font(.caption)
Text(msg.senderUsername)
.font(.subheadline.bold())
}
Text(msg.text ?? "")
.font(.subheadline)
.lineLimit(2)
.foregroundStyle(.secondary)
}
}
}
}
}
.navigationTitle("Pinned Messages")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close") { dismiss() }
}
}
}
}
}
7. Shrnutí všech souborů k úpravě/přidání
| Soubor | Akce | Popis |
|---|---|---|
Models/Message.swift |
EDIT | +Reaction, +ForwardInfo structs, +reactions/pinnedAt/forwardedFrom fieldy |
Core/ChatClient.swift |
EDIT | +3 notification types, +routeMessage dispatch, +getMessages parsing, +react/pin/forward metody, +sendMessage forwardedFrom param |
ViewModels/ChatViewModel.swift |
EDIT | +pinnedMessage, +updatePinnedBanner, +react(), +togglePin(), +3 notification cases, +mark_read optimalizace |
Views/Chat/MessageBubbleView.swift |
EDIT | +currentUserId, +onReact/onPin/onForward callbacky, +forwarded header, +pin indicator, +reaction badges, +@mention highlighting, +context menu items |
Views/Chat/ChatView.swift |
EDIT | +pin banner, +showForwardPicker, +showPinnedList, +nové callbacky, +pin toolbar button |
Views/Chat/PinnedBannerView.swift |
NEW | Pin banner component |
Views/Chat/ForwardPickerView.swift |
NEW | Forward conversation picker |
Views/Chat/PinnedMessagesView.swift |
NEW | Pinned messages list dialog |