Files
Kecalek_python/ios_client/v0.8.4_changes.md
2026-03-11 16:54:14 +01:00

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