From 6a41b5845b7dc2d88bbb158251d998b7dfa38593 Mon Sep 17 00:00:00 2001 From: Umair Date: Wed, 26 Feb 2025 14:06:35 +0000 Subject: [PATCH] Extends example app to include edit and delete functionality in `.mock` and `.live` mode --- Example/AblyChatExample/ContentView.swift | 196 +++++++++++++----- .../MessageViews/DeletedMessageView.swift | 23 ++ .../MessageViews/MenuButtonView.swift | 20 ++ .../MessageViews/MessageView.swift | 56 +++++ .../MessageViews/PresenceMessageView.swift | 34 +++ .../AblyChatExample/Mocks/MockClients.swift | 38 +++- Sources/AblyChat/Message.swift | 6 + 7 files changed, 316 insertions(+), 57 deletions(-) create mode 100644 Example/AblyChatExample/MessageViews/DeletedMessageView.swift create mode 100644 Example/AblyChatExample/MessageViews/MenuButtonView.swift create mode 100644 Example/AblyChatExample/MessageViews/MessageView.swift create mode 100644 Example/AblyChatExample/MessageViews/PresenceMessageView.swift diff --git a/Example/AblyChatExample/ContentView.swift b/Example/AblyChatExample/ContentView.swift index 125fdd5..fe110d4 100644 --- a/Example/AblyChatExample/ContentView.swift +++ b/Example/AblyChatExample/ContentView.swift @@ -46,13 +46,29 @@ struct ContentView: View { @State private var chatClient = Environment.current.createChatClient() @State private var title = "Room" - @State private var messages = [BasicListItem]() @State private var reactions: [Reaction] = [] @State private var newMessage = "" @State private var typingInfo = "" @State private var occupancyInfo = "Connections: 0" @State private var statusInfo = "" + @State private var listItems = [ListItem]() + @State private var editingItemID: String? + + enum ListItem: Identifiable { + case message(MessageListItem) + case presence(PresenceListItem) + + var id: String { + switch self { + case let .message(item): + item.message.id + case let .presence(item): + item.presence.timestamp.description + } + } + } + private func room() async throws -> Room { try await chatClient.rooms.get( roomID: roomID, @@ -61,7 +77,17 @@ struct ContentView: View { } private var sendTitle: String { - newMessage.isEmpty ? ReactionType.like.emoji : "Send" + if newMessage.isEmpty { + ReactionType.like.emoji + } else if editingItemID != nil { + "Update" + } else { + "Send" + } + } + + func onMessageDelete(message: Message) async throws { + _ = try await room().messages.delete(message: message, params: .init()) } var body: some View { @@ -78,17 +104,51 @@ struct ContentView: View { .font(.footnote) .frame(height: 12) .padding(.horizontal, 8) - List(messages, id: \.id) { item in - MessageBasicView(item: item) - .flip() + List(listItems, id: \.id) { item in + switch item { + case let .message(messageItem): + if messageItem.message.action == .delete { + DeletedMessageView(item: messageItem) + .flip() + } else { + MessageView( + item: messageItem, + isEditing: Binding(get: { + editingItemID == messageItem.message.id + }, set: { editing in + editingItemID = editing ? messageItem.message.id : nil + newMessage = editing ? messageItem.message.text : "" + }) + ) { + Task { + try await onMessageDelete(message: messageItem.message) + } + }.id(item.id) + .flip() + } + case let .presence(item): + PresenceMessageView(item: item) + .flip() + } } .flip() .listStyle(PlainListStyle()) HStack { TextField("Type a message...", text: $newMessage) .onChange(of: newMessage) { - Task { - try await startTyping() + // this ensures that typing events are sent only when the message is actually changed whilst editing + if let index = listItems.firstIndex(where: { $0.id == editingItemID }) { + if case let .message(messageItem) = listItems[index] { + if newMessage != messageItem.message.text { + Task { + try await startTyping() + } + } + } + } else { + Task { + try await startTyping() + } } } #if !os(tvOS) @@ -106,7 +166,16 @@ struct ContentView: View { Text(sendTitle) #endif } + if editingItemID != nil { + Button("", systemImage: "xmark.circle.fill") { + editingItemID = nil + newMessage = "" + } + .foregroundStyle(.red.opacity(0.8)) + .transition(.scale.combined(with: .opacity)) + } } + .animation(.easeInOut, value: editingItemID) .padding(.horizontal, 12) HStack { Text(typingInfo) @@ -153,6 +222,11 @@ struct ContentView: View { Task { try await sendReaction(type: ReactionType.like.emoji) } + } else if editingItemID != nil { + Task { + try await sendEditedMessage() + editingItemID = nil + } } else { Task { try await sendMessage() @@ -173,16 +247,39 @@ struct ContentView: View { let previousMessages = try await messagesSubscription.getPreviousMessages(params: .init()) for message in previousMessages.items { - withAnimation { - messages.append(BasicListItem(id: message.serial, title: message.clientID, text: message.text)) + switch message.action { + case .create, .update, .delete: + withAnimation { + listItems.append(.message(.init(message: message, isSender: message.clientID == chatClient.realtime.clientId))) + } } } // Continue listening for messages on a background task so this function can return Task { for await message in messagesSubscription { - withAnimation { - messages.insert(BasicListItem(id: message.serial, title: message.clientID, text: message.text), at: 0) + switch message.action { + case .create: + withAnimation { + listItems.insert( + .message( + .init( + message: message, + isSender: message.clientID == chatClient.realtime.clientId + ) + ), + at: 0 + ) + } + case .update, .delete: + if let index = listItems.firstIndex(where: { $0.id == message.id }) { + listItems[index] = .message( + .init( + message: message, + isSender: message.clientID == chatClient.realtime.clientId + ) + ) + } } } } @@ -208,11 +305,14 @@ struct ContentView: View { Task { for await event in try await room().presence.subscribe(events: [.enter, .leave, .update]) { withAnimation { - let status = event.data?.objectValue?["status"]?.stringValue - let clientPresenceChangeMessage = "\(event.clientID) \(event.action.displayedText)" - let presenceMessage = status != nil ? "\(clientPresenceChangeMessage) with status: \(status!)" : clientPresenceChangeMessage - - messages.insert(BasicListItem(id: UUID().uuidString, title: "System", text: presenceMessage), at: 0) + listItems.insert( + .presence( + .init( + presence: event + ) + ), + at: 0 + ) } } } @@ -291,12 +391,34 @@ struct ContentView: View { newMessage = "" } + func sendEditedMessage() async throws { + guard !newMessage.isEmpty else { + return + } + + if let editingMessageItem = listItems.compactMap({ listItem -> MessageListItem? in + if case let .message(message) = listItem, message.message.id == editingItemID { + return message + } + return nil + }).first { + let editedMessage = editingMessageItem.message.copy(text: newMessage) + _ = try await room().messages.update(newMessage: editedMessage, description: nil, metadata: nil) + } + + newMessage = "" + } + func sendReaction(type: String) async throws { try await room().reactions.send(params: .init(type: type)) } func startTyping() async throws { - try await room().typing.start() + if newMessage.isEmpty { + try await room().typing.stop() + } else { + try await room().typing.start() + } } } @@ -360,41 +482,6 @@ extension ContentView { } } -struct BasicListItem { - var id: String - var title: String - var text: String -} - -struct MessageBasicView: View { - var item: BasicListItem - - var body: some View { - HStack { - VStack { - Text("\(item.title):") - .foregroundColor(.blue) - .bold() - Spacer() - } - VStack { - Text(item.text) - Spacer() - } - } - #if !os(tvOS) - .listRowSeparator(.hidden) - #endif - } -} - -extension View { - func flip() -> some View { - rotationEffect(.radians(.pi)) - .scaleEffect(x: -1, y: 1, anchor: .center) - } -} - #Preview { ContentView() } @@ -424,4 +511,9 @@ extension View { } } } + + func flip() -> some View { + rotationEffect(.radians(.pi)) + .scaleEffect(x: -1, y: 1, anchor: .center) + } } diff --git a/Example/AblyChatExample/MessageViews/DeletedMessageView.swift b/Example/AblyChatExample/MessageViews/DeletedMessageView.swift new file mode 100644 index 0000000..801b4ea --- /dev/null +++ b/Example/AblyChatExample/MessageViews/DeletedMessageView.swift @@ -0,0 +1,23 @@ +import SwiftUI + +struct DeletedMessageView: View { + var item: MessageListItem + + var body: some View { + HStack(alignment: .firstTextBaseline) { + VStack { + Text("\(item.message.clientID):") + .foregroundColor(.blue) + .bold() + } + VStack { + Text("This message was deleted.") + .foregroundStyle(.secondary) + .italic() + } + } + #if !os(tvOS) + .listRowSeparator(.hidden) + #endif + } +} diff --git a/Example/AblyChatExample/MessageViews/MenuButtonView.swift b/Example/AblyChatExample/MessageViews/MenuButtonView.swift new file mode 100644 index 0000000..eeddd38 --- /dev/null +++ b/Example/AblyChatExample/MessageViews/MenuButtonView.swift @@ -0,0 +1,20 @@ +import SwiftUI + +struct MenuButtonView: View { + var onEdit: () -> Void + var onDelete: () -> Void + + var body: some View { + Menu { + Button(action: onEdit) { + Label("Edit", systemImage: "pencil") + } + + Button(role: .destructive, action: onDelete) { + Label("Delete", systemImage: "trash") + } + } label: { + Image(systemName: "ellipsis.circle") + } + } +} diff --git a/Example/AblyChatExample/MessageViews/MessageView.swift b/Example/AblyChatExample/MessageViews/MessageView.swift new file mode 100644 index 0000000..9f96da8 --- /dev/null +++ b/Example/AblyChatExample/MessageViews/MessageView.swift @@ -0,0 +1,56 @@ +import AblyChat +import SwiftUI + +struct MessageView: View { + var item: MessageListItem + @Binding var isEditing: Bool + var onDelete: () -> Void + @State private var isPresentingConfirm = false + + var body: some View { + HStack(alignment: .firstTextBaseline) { + VStack { + Text("\(item.message.clientID):") + .foregroundColor(.blue) + .bold() + } + VStack(alignment: .leading) { + Text(item.message.text) + .foregroundStyle(.black) + .background(isEditing ? .orange.opacity(0.12) : .clear) + if item.message.action == .update { + Text("Edited").foregroundStyle(.gray).font(.footnote) + } + } + Spacer() + if item.isSender { + MenuButtonView( + onEdit: { + isEditing = true + }, onDelete: { + isPresentingConfirm = true + } + ) + .confirmationDialog( + "Are you sure?", + isPresented: $isPresentingConfirm + ) { + Button("Delete message", role: .destructive) { + onDelete() + isPresentingConfirm = false + } + } message: { + Text("You cannot undo this action") + } + } + } + #if !os(tvOS) + .listRowSeparator(.hidden) + #endif + } +} + +struct MessageListItem { + var message: Message + var isSender: Bool = false +} diff --git a/Example/AblyChatExample/MessageViews/PresenceMessageView.swift b/Example/AblyChatExample/MessageViews/PresenceMessageView.swift new file mode 100644 index 0000000..356b685 --- /dev/null +++ b/Example/AblyChatExample/MessageViews/PresenceMessageView.swift @@ -0,0 +1,34 @@ +import AblyChat +import SwiftUI + +struct PresenceMessageView: View { + var item: PresenceListItem + + var body: some View { + HStack(alignment: .firstTextBaseline) { + VStack { + Text("System:") + .foregroundColor(.blue) + .bold() + Spacer() + } + VStack { + Text(generatePresenceMessage()) + } + } + #if !os(tvOS) + .listRowSeparator(.hidden) + #endif + } + + func generatePresenceMessage() -> String { + let status = item.presence.data?.objectValue?["status"]?.stringValue + let clientPresenceChangeMessage = "\(item.presence.clientID) \(item.presence.action.displayedText)" + let presenceMessage = status != nil ? "\(clientPresenceChangeMessage) with status: \(status!)" : clientPresenceChangeMessage + return presenceMessage + } +} + +struct PresenceListItem { + var presence: PresenceEvent +} diff --git a/Example/AblyChatExample/Mocks/MockClients.swift b/Example/AblyChatExample/Mocks/MockClients.swift index cbe24a6..84959a2 100644 --- a/Example/AblyChatExample/Mocks/MockClients.swift +++ b/Example/AblyChatExample/Mocks/MockClients.swift @@ -144,12 +144,40 @@ actor MockMessages: Messages { return message } - func update(newMessage _: Message, description _: String?, metadata _: OperationMetadata?) async throws -> Message { - fatalError("Not yet implemented") + func update(newMessage: Message, description _: String?, metadata _: OperationMetadata?) async throws -> Message { + let message = Message( + serial: newMessage.serial, + action: .update, + clientID: clientID, + roomID: roomID, + text: newMessage.text, + createdAt: Date(), + metadata: newMessage.metadata, + headers: newMessage.headers, + version: "\(Date().timeIntervalSince1970)", + timestamp: Date(), + operation: .init(clientID: clientID) + ) + mockSubscriptions.emit(message) + return message } - func delete(message _: Message, params _: DeleteMessageParams) async throws -> Message { - fatalError("Not yet implemented") + func delete(message: Message, params _: DeleteMessageParams) async throws -> Message { + let message = Message( + serial: message.serial, + action: .delete, + clientID: clientID, + roomID: roomID, + text: message.text, + createdAt: Date(), + metadata: message.metadata, + headers: message.headers, + version: "\(Date().timeIntervalSince1970)", + timestamp: Date(), + operation: .init(clientID: clientID) + ) + mockSubscriptions.emit(message) + return message } func onDiscontinuity(bufferingPolicy _: BufferingPolicy) -> Subscription { @@ -180,7 +208,7 @@ actor MockRoomReactions: RoomReactions { clientID: self.clientID, isSelf: false ) - }, interval: Double.random(in: 0.1 ... 0.5)) + }, interval: Double.random(in: 0.3 ... 0.6)) } func send(params: SendReactionParams) async throws { diff --git a/Sources/AblyChat/Message.swift b/Sources/AblyChat/Message.swift index a7f6a3d..4a0130f 100644 --- a/Sources/AblyChat/Message.swift +++ b/Sources/AblyChat/Message.swift @@ -143,6 +143,12 @@ public struct MessageOperation: Sendable, Equatable { public var clientID: String public var description: String? public var metadata: MessageMetadata? + + public init(clientID: String, description: String? = nil, metadata: MessageMetadata? = nil) { + self.clientID = clientID + self.description = description + self.metadata = metadata + } } extension Message: JSONObjectDecodable {