# iOS Client — v0.8.4 Changes Reakce (1 per user), Pinned Messages (banner), Forwarding, @Mentions, mark_read optimalizace. --- ## 1. `Models/Message.swift` — Nové fieldy ```swift 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 // --- 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 ```swift 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 ```swift // 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: ```swift // 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: ```swift reactions: [], pinnedAt: nil, pinnedBy: nil, forwardedFrom: nil ``` ### 2d. Nové metody — react, pin, get_pinned, forward ```swift // 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: ```swift 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: ```swift // 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 ```swift @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? 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 ```swift 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 ```swift 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) ```swift 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) ```swift 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) ```swift 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 |