Expand iOS messaging shell actions

This commit is contained in:
Mikhail Kilin
2026-05-20 15:45:17 +03:00
parent d68d68aeda
commit 593b19ba8e
4 changed files with 226 additions and 0 deletions

View File

@@ -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"
}
}
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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)