1037 lines
38 KiB
Markdown
1037 lines
38 KiB
Markdown
# 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<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
|
|
|
|
```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<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
|
|
|
|
```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 |
|