Expand iOS messaging shell actions
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user