From 593b19ba8e9481e8c6afaf767ba22c4bfc1748a9 Mon Sep 17 00:00:00 2001 From: Mikhail Kilin Date: Wed, 20 May 2026 15:45:17 +0300 Subject: [PATCH] Expand iOS messaging shell actions --- .../Sources/TeleTuiIOSCore/Bridge.swift | 87 +++++++++++++++++++ .../Sources/TeleTuiIOSCore/ViewModels.swift | 74 ++++++++++++++++ .../Sources/TeleTuiIOSCore/Views.swift | 26 ++++++ .../Sources/TeleTuiIOSSmokeTests/main.swift | 39 +++++++++ 4 files changed, 226 insertions(+) diff --git a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Bridge.swift b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Bridge.swift index 7f12c12..6aeeb15 100644 --- a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Bridge.swift +++ b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Bridge.swift @@ -9,8 +9,15 @@ public protocol SessionBridge: Sendable { func loadFolders() async throws -> [Folder] func loadChats(folderId: Int32?) async throws -> [ChatSummary] func loadHistory(chatId: Int64) async throws -> [Message] + func searchMessages(chatId: Int64, query: String) async throws -> [Message] func openProfile(chatId: Int64) async throws -> Profile func sendMessage(chatId: Int64, text: String, replyToMessageId: Int64?) async throws -> Message + func editMessage(chatId: Int64, messageId: Int64, text: String) async throws -> Message + func deleteMessages(chatId: Int64, messageIds: [Int64]) async throws + func forwardMessages(toChatId: Int64, fromChatId: Int64, messageIds: [Int64]) async throws + func react(chatId: Int64, messageId: Int64, reaction: String) async throws -> [Reaction] + func pinnedMessages(chatId: Int64) async throws -> [Message] + func copyPayload(chatId: Int64, messageId: Int64) async throws -> String } public actor FakeSessionBridge: SessionBridge { @@ -93,6 +100,16 @@ public actor FakeSessionBridge: SessionBridge { messages[chatId] ?? [] } + public func searchMessages(chatId: Int64, query: String) async throws -> [Message] { + guard !query.isEmpty else { + return messages[chatId] ?? [] + } + return (messages[chatId] ?? []).filter { + $0.text.localizedCaseInsensitiveContains(query) + || $0.senderName.localizedCaseInsensitiveContains(query) + } + } + public func openProfile(chatId: Int64) async throws -> Profile { let chat = chats.first { $0.id == chatId } let profile = Profile( @@ -125,4 +142,74 @@ public actor FakeSessionBridge: SessionBridge { events.append(.messageAdded(chatId, message)) return message } + + public func editMessage(chatId: Int64, messageId: Int64, text: String) async throws -> Message { + guard var chatMessages = messages[chatId], + let index = chatMessages.firstIndex(where: { $0.id == messageId }) + else { + throw FakeBridgeError.messageNotFound + } + chatMessages[index].text = text + chatMessages[index].editDate = Int32(Date().timeIntervalSince1970) + messages[chatId] = chatMessages + return chatMessages[index] + } + + public func deleteMessages(chatId: Int64, messageIds: [Int64]) async throws { + messages[chatId]?.removeAll { messageIds.contains($0.id) } + } + + public func forwardMessages(toChatId: Int64, fromChatId: Int64, messageIds: [Int64]) async throws { + let sourceMessages = (messages[fromChatId] ?? []).filter { messageIds.contains($0.id) } + for source in sourceMessages { + let forwarded = Message( + id: nextMessageId, + chatId: toChatId, + senderName: "Me", + text: source.text, + isOutgoing: true, + forwardSenderName: source.senderName + ) + nextMessageId += 1 + messages[toChatId, default: []].append(forwarded) + events.append(.messageAdded(toChatId, forwarded)) + } + } + + public func react(chatId: Int64, messageId: Int64, reaction: String) async throws -> [Reaction] { + guard var chatMessages = messages[chatId], + let index = chatMessages.firstIndex(where: { $0.id == messageId }) + else { + throw FakeBridgeError.messageNotFound + } + if let reactionIndex = chatMessages[index].reactions.firstIndex(where: { $0.emoji == reaction }) { + chatMessages[index].reactions.remove(at: reactionIndex) + } else { + chatMessages[index].reactions.append(Reaction(emoji: reaction, count: 1, isChosen: true)) + } + messages[chatId] = chatMessages + return chatMessages[index].reactions + } + + public func pinnedMessages(chatId: Int64) async throws -> [Message] { + Array((messages[chatId] ?? []).prefix(1)) + } + + public func copyPayload(chatId: Int64, messageId: Int64) async throws -> String { + guard let message = messages[chatId]?.first(where: { $0.id == messageId }) else { + throw FakeBridgeError.messageNotFound + } + return message.text + } +} + +public enum FakeBridgeError: LocalizedError { + case messageNotFound + + public var errorDescription: String? { + switch self { + case .messageNotFound: + "Message not found" + } + } } diff --git a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/ViewModels.swift b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/ViewModels.swift index 0514a6c..03168d9 100644 --- a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/ViewModels.swift +++ b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/ViewModels.swift @@ -116,6 +116,10 @@ public final class ChatViewModel: ObservableObject { @Published public private(set) var messages: [Message] = [] @Published public var composeText: String @Published public var replyTo: Message? + @Published public var searchText = "" + @Published public private(set) var searchResults: [Message] = [] + @Published public private(set) var pinnedMessages: [Message] = [] + @Published public private(set) var copiedPayload: String? @Published public private(set) var isLoading = false @Published public private(set) var errorMessage: String? @@ -133,6 +137,7 @@ public final class ChatViewModel: ObservableObject { do { messages = try await bridge.loadHistory(chatId: chat.id) + pinnedMessages = try await bridge.pinnedMessages(chatId: chat.id) errorMessage = nil } catch { errorMessage = error.localizedDescription @@ -159,6 +164,75 @@ public final class ChatViewModel: ObservableObject { errorMessage = error.localizedDescription } } + + public func search() async { + do { + searchResults = try await bridge.searchMessages(chatId: chat.id, query: searchText) + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } + + public func beginReply(to message: Message) { + replyTo = message + } + + public func edit(message: Message, text: String) async { + do { + let edited = try await bridge.editMessage(chatId: chat.id, messageId: message.id, text: text) + replaceMessage(edited) + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } + + public func delete(message: Message) async { + do { + try await bridge.deleteMessages(chatId: chat.id, messageIds: [message.id]) + messages.removeAll { $0.id == message.id } + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } + + public func forward(message: Message, to chatId: Int64) async { + do { + try await bridge.forwardMessages(toChatId: chatId, fromChatId: chat.id, messageIds: [message.id]) + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } + + public func react(message: Message, reaction: String) async { + do { + let reactions = try await bridge.react(chatId: chat.id, messageId: message.id, reaction: reaction) + var updated = message + updated.reactions = reactions + replaceMessage(updated) + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } + + public func copyPayload(for message: Message) async { + do { + copiedPayload = try await bridge.copyPayload(chatId: chat.id, messageId: message.id) + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } + + private func replaceMessage(_ message: Message) { + if let index = messages.firstIndex(where: { $0.id == message.id }) { + messages[index] = message + } + } } @MainActor diff --git a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Views.swift b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Views.swift index 99344c1..4124479 100644 --- a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Views.swift +++ b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Views.swift @@ -230,6 +230,20 @@ public struct ChatDetailView: View { VStack(spacing: 0) { List(viewModel.messages) { message in MessageRow(message: message) + .contextMenu { + Button("Reply") { + viewModel.beginReply(to: message) + } + Button("React") { + Task { await viewModel.react(message: message, reaction: "👍") } + } + Button("Copy") { + Task { await viewModel.copyPayload(for: message) } + } + Button("Delete", role: .destructive) { + Task { await viewModel.delete(message: message) } + } + } .listRowSeparator(.hidden) } ComposeBar(text: $viewModel.composeText) { @@ -237,6 +251,10 @@ public struct ChatDetailView: View { } } .navigationTitle(viewModel.chat.title) + .searchable(text: $viewModel.searchText) + .onSubmit(of: .search) { + Task { await viewModel.search() } + } .toolbar { Button("Profile") { showsProfile = true @@ -303,6 +321,14 @@ public struct ComposeBar: View { public var body: some View { HStack(spacing: 10) { + if !text.isEmpty { + Button { + text = "" + } label: { + Image(systemName: "xmark.circle.fill") + } + .buttonStyle(.plain) + } TextField("Message", text: $text, axis: .vertical) .textFieldStyle(.roundedBorder) .lineLimit(1...5) diff --git a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSSmokeTests/main.swift b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSSmokeTests/main.swift index 94f751e..607051b 100644 --- a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSSmokeTests/main.swift +++ b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSSmokeTests/main.swift @@ -7,6 +7,7 @@ struct TeleTuiIOSSmokeTests { try await authFlowMatchesAllInteractiveStates() try await chatListLoadsDeterministicFakeDataAndFilters() try await chatDetailLoadsAndSendsMessage() + try await messageActionsCoverEditReplyForwardReactDeleteSearchAndCopy() try await profileLoadsFromSelectedChat() appStorageUsesApplicationSupportStyleAccountPaths() print("TeleTuiIOS smoke tests passed") @@ -61,6 +62,44 @@ struct TeleTuiIOSSmokeTests { precondition(viewModel.composeText.isEmpty) } + @MainActor + private static func messageActionsCoverEditReplyForwardReactDeleteSearchAndCopy() async throws { + let bridge = FakeSessionBridge(auth: .ready) + let chat = try await bridge.loadChats(folderId: nil)[0] + let viewModel = ChatViewModel(chat: chat, bridge: bridge) + + await viewModel.load() + guard let first = viewModel.messages.first else { + preconditionFailure("fake chat should contain a message") + } + + await viewModel.edit(message: first, text: "Edited text") + precondition(viewModel.messages.first?.text == "Edited text") + precondition(viewModel.messages.first?.editDate != nil) + + viewModel.beginReply(to: viewModel.messages[0]) + viewModel.composeText = "Reply text" + await viewModel.send() + precondition(viewModel.messages.last?.replyText == "Reply to #1") + + await viewModel.react(message: viewModel.messages[0], reaction: "👍") + precondition(viewModel.messages[0].reactions.first?.emoji == "👍") + + viewModel.searchText = "reply" + await viewModel.search() + precondition(viewModel.searchResults.count == 1) + + await viewModel.copyPayload(for: viewModel.messages[0]) + precondition(viewModel.copiedPayload == "Edited text") + + await viewModel.forward(message: viewModel.messages[0], to: 2) + let forwarded = try await bridge.loadHistory(chatId: 2) + precondition(forwarded.contains { $0.forwardSenderName == "Alice" && $0.text == "Edited text" }) + + await viewModel.delete(message: viewModel.messages[0]) + precondition(!viewModel.messages.contains { $0.id == 1 }) + } + @MainActor private static func profileLoadsFromSelectedChat() async throws { let bridge = FakeSessionBridge(auth: .ready)