Expand iOS messaging shell actions
This commit is contained in:
@@ -9,8 +9,15 @@ public protocol SessionBridge: Sendable {
|
|||||||
func loadFolders() async throws -> [Folder]
|
func loadFolders() async throws -> [Folder]
|
||||||
func loadChats(folderId: Int32?) async throws -> [ChatSummary]
|
func loadChats(folderId: Int32?) async throws -> [ChatSummary]
|
||||||
func loadHistory(chatId: Int64) async throws -> [Message]
|
func loadHistory(chatId: Int64) async throws -> [Message]
|
||||||
|
func searchMessages(chatId: Int64, query: String) async throws -> [Message]
|
||||||
func openProfile(chatId: Int64) async throws -> Profile
|
func openProfile(chatId: Int64) async throws -> Profile
|
||||||
func sendMessage(chatId: Int64, text: String, replyToMessageId: Int64?) async throws -> Message
|
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 {
|
public actor FakeSessionBridge: SessionBridge {
|
||||||
@@ -93,6 +100,16 @@ public actor FakeSessionBridge: SessionBridge {
|
|||||||
messages[chatId] ?? []
|
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 {
|
public func openProfile(chatId: Int64) async throws -> Profile {
|
||||||
let chat = chats.first { $0.id == chatId }
|
let chat = chats.first { $0.id == chatId }
|
||||||
let profile = Profile(
|
let profile = Profile(
|
||||||
@@ -125,4 +142,74 @@ public actor FakeSessionBridge: SessionBridge {
|
|||||||
events.append(.messageAdded(chatId, message))
|
events.append(.messageAdded(chatId, message))
|
||||||
return 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 private(set) var messages: [Message] = []
|
||||||
@Published public var composeText: String
|
@Published public var composeText: String
|
||||||
@Published public var replyTo: Message?
|
@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 isLoading = false
|
||||||
@Published public private(set) var errorMessage: String?
|
@Published public private(set) var errorMessage: String?
|
||||||
|
|
||||||
@@ -133,6 +137,7 @@ public final class ChatViewModel: ObservableObject {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
messages = try await bridge.loadHistory(chatId: chat.id)
|
messages = try await bridge.loadHistory(chatId: chat.id)
|
||||||
|
pinnedMessages = try await bridge.pinnedMessages(chatId: chat.id)
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = error.localizedDescription
|
errorMessage = error.localizedDescription
|
||||||
@@ -159,6 +164,75 @@ public final class ChatViewModel: ObservableObject {
|
|||||||
errorMessage = error.localizedDescription
|
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
|
@MainActor
|
||||||
|
|||||||
@@ -230,6 +230,20 @@ public struct ChatDetailView: View {
|
|||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
List(viewModel.messages) { message in
|
List(viewModel.messages) { message in
|
||||||
MessageRow(message: message)
|
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)
|
.listRowSeparator(.hidden)
|
||||||
}
|
}
|
||||||
ComposeBar(text: $viewModel.composeText) {
|
ComposeBar(text: $viewModel.composeText) {
|
||||||
@@ -237,6 +251,10 @@ public struct ChatDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(viewModel.chat.title)
|
.navigationTitle(viewModel.chat.title)
|
||||||
|
.searchable(text: $viewModel.searchText)
|
||||||
|
.onSubmit(of: .search) {
|
||||||
|
Task { await viewModel.search() }
|
||||||
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
Button("Profile") {
|
Button("Profile") {
|
||||||
showsProfile = true
|
showsProfile = true
|
||||||
@@ -303,6 +321,14 @@ public struct ComposeBar: View {
|
|||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
|
if !text.isEmpty {
|
||||||
|
Button {
|
||||||
|
text = ""
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
TextField("Message", text: $text, axis: .vertical)
|
TextField("Message", text: $text, axis: .vertical)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
.lineLimit(1...5)
|
.lineLimit(1...5)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ struct TeleTuiIOSSmokeTests {
|
|||||||
try await authFlowMatchesAllInteractiveStates()
|
try await authFlowMatchesAllInteractiveStates()
|
||||||
try await chatListLoadsDeterministicFakeDataAndFilters()
|
try await chatListLoadsDeterministicFakeDataAndFilters()
|
||||||
try await chatDetailLoadsAndSendsMessage()
|
try await chatDetailLoadsAndSendsMessage()
|
||||||
|
try await messageActionsCoverEditReplyForwardReactDeleteSearchAndCopy()
|
||||||
try await profileLoadsFromSelectedChat()
|
try await profileLoadsFromSelectedChat()
|
||||||
appStorageUsesApplicationSupportStyleAccountPaths()
|
appStorageUsesApplicationSupportStyleAccountPaths()
|
||||||
print("TeleTuiIOS smoke tests passed")
|
print("TeleTuiIOS smoke tests passed")
|
||||||
@@ -61,6 +62,44 @@ struct TeleTuiIOSSmokeTests {
|
|||||||
precondition(viewModel.composeText.isEmpty)
|
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
|
@MainActor
|
||||||
private static func profileLoadsFromSelectedChat() async throws {
|
private static func profileLoadsFromSelectedChat() async throws {
|
||||||
let bridge = FakeSessionBridge(auth: .ready)
|
let bridge = FakeSessionBridge(auth: .ready)
|
||||||
|
|||||||
Reference in New Issue
Block a user