diff --git a/src/pages/docs/chat/getting-started/react-native.mdx b/src/pages/docs/chat/getting-started/react-native.mdx index f51daa7034..e06c4c6a38 100644 --- a/src/pages/docs/chat/getting-started/react-native.mdx +++ b/src/pages/docs/chat/getting-started/react-native.mdx @@ -824,7 +824,7 @@ You can also use the Ably CLI to enter the room from another client by running t ## Step 9: Send a reaction Clients can send a reaction to a room to show their sentiment for what is happening, such as a point being scored in a sports game. -Ably Chat provides a [`useReactions()`](https://sdk.ably.com/builds/ably/ably-chat-js/main/typedoc/functions/chat-react.useReactions.html) hook to send and receive reactions in a room. These are short-lived (ephemeral) and are not stored in the room history. +Ably Chat provides a [`useRoomReactions()`](https://sdk.ably.com/builds/ably/ably-chat-js/main/typedoc/functions/chat-react.useRoomReactions.html) hook to send and receive reactions in a room. These are short-lived (ephemeral) and are not stored in the room history. In your `ChatApp.tsx` file, add a new component called `ReactionComponent`, like so: diff --git a/src/pages/docs/chat/getting-started/react.mdx b/src/pages/docs/chat/getting-started/react.mdx index 1be877d3b6..041442bd72 100644 --- a/src/pages/docs/chat/getting-started/react.mdx +++ b/src/pages/docs/chat/getting-started/react.mdx @@ -783,7 +783,7 @@ You can also use the Ably CLI to enter the room from another client by running t ## Step 9: Send a room reaction Clients can send a reaction to a room to show their sentiment for what is happening, such as a point being scored in a sports game. -Ably Chat provides a [`useReactions()`](https://sdk.ably.com/builds/ably/ably-chat-js/main/typedoc/functions/chat-react.useReactions.html) hook to send and receive reactions in a room. These are short-lived (ephemeral) and are not stored in the room history. +Ably Chat provides a [`useRoomReactions()`](https://sdk.ably.com/builds/ably/ably-chat-js/main/typedoc/functions/chat-react.useRoomReactions.html) hook to send and receive reactions in a room. These are short-lived (ephemeral) and are not stored in the room history. In your `src/App.tsx` file, add a new component called `ReactionComponent`, like so: diff --git a/src/pages/docs/chat/getting-started/swift.mdx b/src/pages/docs/chat/getting-started/swift.mdx index 1725519f8e..1d69549e11 100644 --- a/src/pages/docs/chat/getting-started/swift.mdx +++ b/src/pages/docs/chat/getting-started/swift.mdx @@ -345,7 +345,7 @@ struct ContentView: View { guard !newMessage.isEmpty, let room = room else { return } do { - _ = try await room.messages.send(params: .init(text: newMessage)) + _ = try await room.messages.send(withParams: .init(text: newMessage)) newMessage = "" } catch { print("Failed to send message: \(error)") @@ -480,7 +480,7 @@ struct ContentView: View { guard !newMessage.isEmpty, let room = room else { return } do { - _ = try await room.messages.send(params: .init(text: newMessage)) + _ = try await room.messages.send(withParams: .init(text: newMessage)) newMessage = "" } catch { print("Failed to send message: \(error)") @@ -492,7 +492,7 @@ struct ContentView: View { do { let updateMessageParams = UpdateMessageParams(text: newMessage) - _ = try await room.messages.update(withSerial: editingMessage.serial params: updateMessageParams, details: nil) + _ = try await room.messages.update(withSerial: editingMessage.serial, params: updateMessageParams, details: nil) self.editingMessage = nil newMessage = "" } catch { @@ -840,7 +840,7 @@ struct ContentView: View { guard let room = room else { return } do { - try await room.reactions.send(params: .init(name: name)) + try await room.reactions.send(withParams: .init(name: name)) } catch { print("Failed to send reaction: \(error)") } diff --git a/src/pages/docs/chat/react-ui-kit/components.mdx b/src/pages/docs/chat/react-ui-kit/components.mdx index b4fdce3818..6542cec8fb 100644 --- a/src/pages/docs/chat/react-ui-kit/components.mdx +++ b/src/pages/docs/chat/react-ui-kit/components.mdx @@ -303,11 +303,11 @@ import { MessageInput } from '@ably/chat-react-ui-kit'; import { useMessages } from '@ably/chat/react'; // Basic usage -const { send } = useMessages(); +const { sendMessage } = useMessages(); const handleSendMessage = (text: string) => { console.log(`Sending message: ${text}`); - send({ text }); + sendMessage({ text }); }; { console.log(`Editing message with serial: ${message.serial}, setting text to: ${newText}`); - update(message.serial, { text: newText }); + updateMessage(message.serial, { text: newText }); }} onDelete={(message: Message) => { console.log(`Deleting message with serial: ${message.serial}`); @@ -546,11 +546,11 @@ const { update, deleteMessage, addReaction, removeReaction } = useMessages(); }} onReactionAdd={(message: Message, emoji: string) => { console.log(`Adding reaction ${emoji} to message with serial: ${message.serial}`); - addReaction(message.serial, emoji); + sendReaction(message.serial, { name: emoji }); }} onReactionRemove={(message: Message, emoji: string) => { console.log(`Removing reaction ${emoji} from message with serial: ${message.serial}`); - removeReaction(message.serial, emoji); + deleteReaction(message.serial, { name: emoji }); }} /> ``` diff --git a/src/pages/docs/chat/rooms/history.mdx b/src/pages/docs/chat/rooms/history.mdx index 378c907732..d95a33b22e 100644 --- a/src/pages/docs/chat/rooms/history.mdx +++ b/src/pages/docs/chat/rooms/history.mdx @@ -50,7 +50,7 @@ const MyComponent = () => { ``` ```swift -let paginatedResult = try await room.messages.history(withOptions: .init(orderBy: .newestFirst)) +let paginatedResult = try await room.messages.history(withParams: .init(orderBy: .newestFirst)) print(paginatedResult.items) if let next = try await paginatedResult.next { print(next.items) diff --git a/src/pages/docs/chat/rooms/index.mdx b/src/pages/docs/chat/rooms/index.mdx index 0257ac84f7..616f5aba81 100644 --- a/src/pages/docs/chat/rooms/index.mdx +++ b/src/pages/docs/chat/rooms/index.mdx @@ -104,7 +104,7 @@ const room = await chatClient.rooms.get('basketball-stream', options); ``` ```swift -let presence = PresenceOptions(enter: false) +let presence = PresenceOptions(enableEvents: false) let typing = TypingOptions(heartbeatThrottle: 5.0) // seconds // using defaults for reactions and occupancy let options = RoomOptions(presence: presence, typing: typing, occupancy: .init()) diff --git a/src/pages/docs/chat/rooms/media.mdx b/src/pages/docs/chat/rooms/media.mdx index a4f72b2e3c..e96b225d22 100644 --- a/src/pages/docs/chat/rooms/media.mdx +++ b/src/pages/docs/chat/rooms/media.mdx @@ -27,7 +27,7 @@ The following is an example of an upload function: ```javascript async function uploadMedia() { - // ask the user to choose their media + // ask the user to choose their media asynchronously // upload the media to your storage service // return a unique identifier for the media @@ -43,6 +43,82 @@ async function uploadMedia() { return { id: mediaId, title, width, height }; } ``` + +```swift +func uploadMedia() async -> JSONObject { + // ask the user to choose their media asynchronously + // upload the media to your storage service + // return a unique identifier for the media + + // mock implementation: + let mediaId = "abcd123abcd" + + // Some media metadata, useful for displaying the media in the UI + let title = "A beautiful image" + let width = 1024 + let height = 768 + + // Return the object + return [ + "id": .string(mediaId), + "title": .string(title), + "width": .number(Double(width)), + "height": .number(Double(height)) + ] +} +``` + +```kotlin +import com.ably.chat.json.* + +suspend fun uploadMedia(): JsonObject { + // ask the user to choose their media asynchronously + // upload the media to your storage service + // return a unique identifier for the media + + // mock implementation: + val mediaId = "abcd123abcd" + + // Some media metadata, useful for displaying the media in the UI + val title = "A beautiful image" + val width = 1024 + val height = 768 + + // Return the object + return jsonObject { + put("id", mediaId) + put("title", title) + put("width", width) + put("height", height) + } +} +``` + +```jetpack +import com.ably.chat.json.* + +suspend fun uploadMedia(): JsonObject { + // ask the user to choose their media asynchronously + // upload the media to your storage service + // return a unique identifier for the media + + // mock implementation: + val mediaId = "abcd123abcd" + + // Some media metadata, useful for displaying the media in the UI + val title = "A beautiful image" + val width = 1024 + val height = 768 + + // Return the object + return jsonObject { + put("id", mediaId) + put("title", title) + put("width", width) + put("height", height) + } +} +``` Use the `uploadMedia()` flow to save the resulting object. In your UI, the `mediaToAttach` array should be displayed so that users can see which which media will be attached to their message. It also enables users to add or remove selected media. @@ -77,6 +153,59 @@ const ChatComponent = () => { ); }; ``` + +```swift +var mediaToAttach: [JSONObject] = [] + +func onMediaAttach() async { + let mediaData = await uploadMedia() + mediaToAttach.append(mediaData) +} +``` + +```kotlin +import com.ably.chat.json.JsonObject + +var mediaToAttach = mutableListOf() + +suspend fun onMediaAttach() { + val mediaData = uploadMedia() + mediaToAttach.add(mediaData) +} +``` + +```jetpack +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import com.ably.chat.json.* +import kotlinx.coroutines.launch + +@Composable +fun MediaAttachmentComponent() { + val mediaToAttach = remember { mutableStateListOf() } + val coroutineScope = rememberCoroutineScope() + + Column { + Button(onClick = { + coroutineScope.launch { + val mediaData = uploadMedia() + mediaToAttach += mediaData + } + }) { + Text("Attach Media") + } + + mediaToAttach.forEach { media -> + val id = (media["id"] as? JsonString)?.value ?: "" + val title = (media["title"] as? JsonString)?.value ?: "" + val width = (media["width"] as? JsonNumber)?.value?.toInt() ?: 0 + val height = (media["height"] as? JsonNumber)?.value?.toInt() ?: 0 + Text("Media to attach: $id ($title, ${width}x${height})") + } + } +} +``` ## Send a message @@ -105,14 +234,14 @@ import { useMessages } from '@ably/chat/react'; const MessageSender = () => { const [mediaToAttach, setMediaToAttach] = useState([]); const [messageText, setMessageText] = useState(''); - const { send } = useMessages(); + const { sendMessage } = useMessages(); const handleSend = async () => { let metadata = {}; if (mediaToAttach.length > 0) { metadata["media"] = mediaToAttach; } - await send({ + await sendMessage({ text: messageText, metadata: metadata }); @@ -142,6 +271,102 @@ const MessageSender = () => { ); }; ``` + +```swift +func send(text: String, mediaToAttach: [JSONObject]) async throws { + var metadata: MessageMetadata = [:] + if !mediaToAttach.isEmpty { + // Convert each JSONObject to JSONValue.object, then wrap in JSONValue.array + metadata["media"] = .array(mediaToAttach.map { .object($0) }) + } + + _ = try await room.messages.send(withParams: .init( + text: text, + metadata: metadata + )) +} +``` + +```kotlin +import com.ably.chat.json.* + +suspend fun send(text: String, mediaToAttach: List) { + // Wrap the media list in a JsonArray for the metadata + val metadata = if (mediaToAttach.isNotEmpty()) { + jsonObject { + put("media", JsonArray(mediaToAttach)) + } + } else { + null + } + + room.messages.send( + text = text, + metadata = metadata + ) +} +``` + +```jetpack +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import com.ably.chat.Room +import com.ably.chat.json.* +import kotlinx.coroutines.launch + +@Composable +fun MessageSenderComponent(room: Room) { + val mediaToAttach = remember { mutableStateListOf() } + var messageText by remember { mutableStateOf("") } + val coroutineScope = rememberCoroutineScope() + + Column { + TextField( + value = messageText, + onValueChange = { messageText = it }, + placeholder = { Text("Type a message...") } + ) + + Button(onClick = { + coroutineScope.launch { + val mediaData = uploadMedia() + mediaToAttach += mediaData + } + }) { + Text("Attach Media") + } + + Button(onClick = { + coroutineScope.launch { + val metadata = if (mediaToAttach.isNotEmpty()) { + jsonObject { + put("media", JsonArray(mediaToAttach)) + } + } else null + + room.messages.send( + text = messageText, + metadata = metadata + ) + + mediaToAttach.clear() + messageText = "" + } + }) { + Text("Send") + } + + mediaToAttach.forEach { media -> + val id = (media["id"] as? JsonString)?.value ?: "" + val title = (media["title"] as? JsonString)?.value ?: "" + val width = (media["width"] as? JsonNumber)?.value?.toInt() ?: 0 + val height = (media["height"] as? JsonNumber)?.value?.toInt() ?: 0 + Text("Media to attach: $id ($title, ${width}x${height})") + } + } +} +``` Be aware that message `metadata` is not validated by the server. Always treat it as untrusted user input. @@ -178,6 +403,73 @@ const getValidMedia = (message) => { return []; }; ``` + +```swift +import Foundation + +// assume IDs are 10-15 characters long and alphanumeric +let mediaIdRegex = /^[a-z0-9]{10,15}$/ + +func getValidMedia(message: Message) -> [JSONObject] { + guard let mediaArray = message.metadata["media"]?.arrayValue else { + return [] + } + + return mediaArray.compactMap { mediaValue in + guard let mediaObj = mediaValue.objectValue, + let id = mediaObj["id"]?.stringValue, + id.wholeMatch(of: mediaIdRegex) != nil else { + return nil + } + return mediaObj + } +} +``` + +```kotlin +import com.ably.chat.json.* + +// assume IDs are 10-15 characters long and alphanumeric +val mediaIdRegex = Regex("^[a-z0-9]{10,15}$") + +fun getValidMedia(message: Message): List { + val mediaArray = message.metadata["media"] as? JsonArray ?: return emptyList() + + return mediaArray.mapNotNull { mediaValue -> + val mediaObj = mediaValue as? JsonObject ?: return@mapNotNull null + val id = (mediaObj["id"] as? JsonString)?.value ?: return@mapNotNull null + + if (mediaIdRegex.matches(id)) { + mediaObj + } else { + null + } + } +} +``` + +```jetpack +import com.ably.chat.Message +import com.ably.chat.json.* + +// assume IDs are 10-15 characters long and alphanumeric +val mediaIdRegex = Regex("^[a-z0-9]{10,15}$") + +fun getValidMedia(message: Message): List { + val mediaArray = message.metadata["media"] as? JsonArray ?: return emptyList() + + return mediaArray.mapNotNull { mediaValue -> + val mediaObj = mediaValue as? JsonObject ?: return@mapNotNull null + val id = (mediaObj["id"] as? JsonString)?.value ?: return@mapNotNull null + + if (mediaIdRegex.matches(id)) { + mediaObj + } else { + null + } + } +} +``` Use a function or component to display the message and its media: @@ -242,6 +534,108 @@ const MessageDisplay = ({ message }) => { ); }; ``` + +```swift +import SwiftUI + +struct MessageView: View { + let message: Message + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(message.text) + + let validMedia = getValidMedia(message: message) + if !validMedia.isEmpty { + VStack(spacing: 4) { + ForEach(validMedia, id: \.self) { media in + if let id = media["id"]?.stringValue, + let title = media["title"]?.stringValue { + AsyncImage(url: URL(string: "https://example.com/images/\(id)")) { image in + image.resizable().aspectRatio(contentMode: .fit) + } placeholder: { + ProgressView() + } + .accessibilityLabel(title) + } + } + } + } + } + } +} +``` + +```kotlin +import android.view.View +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import com.ably.chat.json.* + +fun createMessageView(message: Message, context: android.content.Context): View { + val container = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + } + + val textView = TextView(context).apply { + text = message.text + } + container.addView(textView) + + val validMedia = getValidMedia(message) + if (validMedia.isNotEmpty()) { + val mediaContainer = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + } + + validMedia.forEach { media -> + val id = (media["id"] as? JsonString)?.value ?: "" + val imageView = ImageView(context).apply { + // Load image from URL (using Coil, Glide, or Picasso) + // load("https://example.com/images/$id") + } + + mediaContainer.addView(imageView) + } + + container.addView(mediaContainer) + } + + return container +} +``` + +```jetpack +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import coil.compose.AsyncImage +import com.ably.chat.Message +import com.ably.chat.json.JsonString + +@Composable +fun MessageDisplayComponent(message: Message) { + val validMedia = getValidMedia(message) + + Column { + Text(text = message.text) + + if (validMedia.isNotEmpty()) { + Column { + validMedia.forEach { media -> + val id = (media["id"] as? JsonString)?.value ?: "" + val title = (media["title"] as? JsonString)?.value ?: "" + AsyncImage( + model = "https://example.com/images/$id", + contentDescription = title, + ) + } + } + } + } +} +``` ### Add media to an existing message @@ -266,7 +660,7 @@ room.messages.update(message.serial, message.copy({metadata: newMetadata})) import { useMessages } from '@ably/chat/react'; const AddMediaToMessage = ({ message }) => { - const { update } = useMessages(); + const { updateMessage } = useMessages(); const addMediaToMessage = async () => { const mediaId = 'abcd123abcd'; // assume this is the media we want to add @@ -277,7 +671,7 @@ const AddMediaToMessage = ({ message }) => { } newMetadata.media.push(mediaId); - await update(message.serial, message.copy({metadata: newMetadata})); + await updateMessage(message.serial, message.copy({metadata: newMetadata})); }; return ( @@ -287,6 +681,91 @@ const AddMediaToMessage = ({ message }) => { ); }; ``` + +```swift +func addMediaToMessage(message: Message, mediaData: JSONObject) async throws { + var newMetadata = message.metadata + + // Get existing media array or start with empty array + var mediaArray = newMetadata["media"]?.arrayValue ?? [] + mediaArray.append(.object(mediaData)) + newMetadata["media"] = .array(mediaArray) + + _ = try await room.messages.update( + withSerial: message.serial, + params: .init( + text: message.text, + metadata: newMetadata + ), + details: nil + ) +} +``` + +```kotlin +import com.ably.chat.json.* + +suspend fun addMediaToMessage(message: Message, mediaData: JsonObject) { + val existingMedia = message.metadata["media"] as? JsonArray ?: JsonArray(emptyList()) + + val newMediaArray = JsonArray(existingMedia + mediaData) + + val newMetadata = jsonObject { + message.metadata.forEach { (key, value) -> + if (key != "media") { + put(key, value) + } + } + put("media", newMediaArray) + } + + room.messages.update( + serial = message.serial, + text = message.text, + metadata = newMetadata + ) +} +``` + +```jetpack +import androidx.compose.material.* +import androidx.compose.runtime.* +import com.ably.chat.* +import com.ably.chat.json.* +import kotlinx.coroutines.launch + +@Composable +fun AddMediaToMessageComponent(room: Room, message: Message) { + val coroutineScope = rememberCoroutineScope() + + Button(onClick = { + coroutineScope.launch { + val mediaData = uploadMedia() + + val existingMedia = message.metadata["media"] as? JsonArray ?: JsonArray(emptyList()) + + val newMediaArray = JsonArray(existingMedia + mediaData) + + val newMetadata = jsonObject { + message.metadata.forEach { (key, value) -> + if (key != "media") { + put(key, value) + } + } + put("media", newMediaArray) + } + + room.messages.update( + serial = message.serial, + text = message.text, + metadata = newMetadata + ) + } + }) { + Text("Add Media to Message") + } +} +``` ### Remove media from an existing message @@ -312,7 +791,7 @@ room.messages.update(message.serial, message.copy({metadata: newMetadata})) import { useMessages } from '@ably/chat/react'; const RemoveMediaFromMessage = ({ message }) => { - const { update } = useMessages(); + const { updateMessage } = useMessages(); const removeMediaFromMessage = async () => { const mediaId = 'abcd123abcd'; // assume this is the media we want to remove @@ -325,7 +804,7 @@ const RemoveMediaFromMessage = ({ message }) => { const newMetadata = structuredClone(message.metadata); newMetadata.media = newMetadata.media.filter(id => mediaId !== id); - await update(message.serial, message.copy({metadata: newMetadata})); + await updateMessage(message.serial, message.copy({metadata: newMetadata})); }; return ( @@ -335,6 +814,114 @@ const RemoveMediaFromMessage = ({ message }) => { ); }; ``` + +```swift +func removeMediaFromMessage(message: Message, mediaIdToRemove: String) async throws { + guard let mediaArray = message.metadata["media"]?.arrayValue, + !mediaArray.isEmpty else { + // do nothing if there is no media + return + } + + let newMediaArray = mediaArray.filter { mediaValue in + // Keep items that don't match the ID to remove + mediaValue.objectValue?["id"]?.stringValue != mediaIdToRemove + } + + var newMetadata = message.metadata + newMetadata["media"] = .array(newMediaArray) + + try await room.messages.update( + withSerial: message.serial, + params: .init( + text: message.text, + metadata: newMetadata + ), + details: nil + ) +} +``` + +```kotlin +import com.ably.chat.json.* + +suspend fun removeMediaFromMessage(message: Message, mediaIdToRemove: String) { + val existingMedia = message.metadata["media"] as? JsonArray + if (existingMedia == null || existingMedia.isEmpty()) { + // do nothing if there is no media + return + } + + val newMediaArray = JsonArray(existingMedia.filter { mediaValue -> + val mediaObj = mediaValue as? JsonObject + val id = (mediaObj?.get("id") as? JsonString)?.value + id != mediaIdToRemove + }) + + val newMetadata = jsonObject { + message.metadata.forEach { (key, value) -> + if (key != "media") { + put(key, value) + } + } + put("media", newMediaArray) + } + + room.messages.update( + serial = message.serial, + text = message.text, + metadata = newMetadata + ) +} +``` + +```jetpack +import androidx.compose.material.* +import androidx.compose.runtime.* +import com.ably.chat.* +import com.ably.chat.json.* +import kotlinx.coroutines.launch + +@Composable +fun RemoveMediaFromMessageComponent(room: Room, message: Message) { + val coroutineScope = rememberCoroutineScope() + + Button(onClick = { + coroutineScope.launch { + val mediaIdToRemove = "abcd123abcd" + + val existingMedia = message.metadata["media"] as? JsonArray + if (existingMedia == null || existingMedia.isEmpty()) { + // do nothing if there is no media + return@launch + } + + val newMediaArray = JsonArray(existingMedia.filter { mediaValue -> + val mediaObj = mediaValue as? JsonObject + val id = (mediaObj?.get("id") as? JsonString)?.value + id != mediaIdToRemove + }) + + val newMetadata = jsonObject { + message.metadata.forEach { (key, value) -> + if (key != "media") { + put(key, value) + } + } + put("media", newMediaArray) + } + + room.messages.update( + serial = message.serial, + text = message.text, + metadata = newMetadata + ) + } + }) { + Text("Remove Media from Message") + } +} +``` ## Media moderation diff --git a/src/pages/docs/chat/rooms/message-reactions.mdx b/src/pages/docs/chat/rooms/message-reactions.mdx index cc4a81f0d4..57d86dcd11 100644 --- a/src/pages/docs/chat/rooms/message-reactions.mdx +++ b/src/pages/docs/chat/rooms/message-reactions.mdx @@ -128,21 +128,21 @@ await room.messages.reactions.send(message, { ```swift // Send a 👍 reaction using the default type -await room.messages.reactions.send(forMessageWithSerial: message.serial, params: .init(name: "👍")) +try await room.messages.reactions.send(forMessageWithSerial: message.serial, params: .init(name: "👍")) // The reaction can be anything, not just UTF-8 emojis: -await room.messages.reactions.send(forMessageWithSerial: message.serial, params: .init(name: ":like:")) -await room.messages.reactions.send(forMessageWithSerial: message.serial, params: .init(name: "+1")) +try await room.messages.reactions.send(forMessageWithSerial: message.serial, params: .init(name: ":like:")) +try await room.messages.reactions.send(forMessageWithSerial: message.serial, params: .init(name: "+1")) // Send a :love: reaction using the Unique type -await room.messages.reactions.send(forMessageWithSerial: message.serial, params: .init( - reaction: ":love:", +try await room.messages.reactions.send(forMessageWithSerial: message.serial, params: .init( + name: ":love:", type: .unique )) // Send a ❤️ reaction with count 100 using the Multiple type -await room.messages.reactions.send(forMessageWithSerial: message.serial, params: .init( - reaction: "❤️", +try await room.messages.reactions.send(forMessageWithSerial: message.serial, params: .init( + name: "❤️", type: .multiple, count: 100 )) @@ -323,6 +323,40 @@ const MyComponent = () => { }; ``` +```swift +// Remove a 👍 reaction using the default type +try await room.messages.reactions.delete(fromMessageWithSerial: message.serial, params: .init(name: "👍")) + +// Remove a :love: reaction using the Unique type +try await room.messages.reactions.delete(fromMessageWithSerial: message.serial, params: .init( + name: ":love:", + type: .unique +)) + +// Remove a ❤️ reaction using the Multiple type +try await room.messages.reactions.delete(fromMessageWithSerial: message.serial, params: .init( + name: "❤️", + type: .multiple +)) +``` + +```kotlin +// Remove a 👍 reaction using the default type +room.messages.reactions.delete(message, name = "👍") + +// Remove a :love: reaction using the Unique type +room.messages.reactions.delete(message, + name = ":love:", + type = MessageReactionType.Unique, +) + +// Remove a ❤️ reaction using the Multiple type +room.messages.reactions.delete(message, + name = "❤️", + type = MessageReactionType.Multiple, +) +``` + ```jetpack import androidx.compose.material.* import androidx.compose.runtime.* @@ -346,16 +380,15 @@ fun RemoveMessageReactionComponent(room: Room, message: Message) { Button(onClick = { coroutineScope.launch { - // Remove a ❤️ reaction with count 50 using the Multiple type + // Remove a ❤️ reaction using the Multiple type room.messages.reactions.delete( message, name = "❤️", type = MessageReactionType.Multiple, - count = 50, ) } }) { - Text("Remove ❤️ x50") + Text("Remove ❤️") } } ``` @@ -619,7 +652,7 @@ room.messages.reactions.subscribe((event) => { ```swift // init messages, in practice this should be updated with a message subscription -var messages = (await room.messages.history(withOptions: .init(limit: 50))).items +var messages = (try await room.messages.history(withParams: .init(limit: 50))).items // subscribe to message reactions summary events room.messages.reactions.subscribe { event in diff --git a/src/pages/docs/chat/rooms/messages.mdx b/src/pages/docs/chat/rooms/messages.mdx index 3f19022a5f..afd3dd37e7 100644 --- a/src/pages/docs/chat/rooms/messages.mdx +++ b/src/pages/docs/chat/rooms/messages.mdx @@ -200,7 +200,7 @@ const MyComponent = () => { ``` ```swift -let message = try await room.messages.send(params: .init(text: "hello")) +let message = try await room.messages.send(withParams: .init(text: "hello")) ``` ```kotlin @@ -322,7 +322,7 @@ const MyComponent = () => { ```swift let originalMessage: Message let updatedMessage = try await room.messages.update( - forSerial: originalMessage.serial, + withSerial: originalMessage.serial, params: .init(text: "my updated text"), details: .init(description: "Message update by user") ) @@ -578,8 +578,8 @@ const MyComponent = () => { ```swift let messageToDelete: Message let deletedMessage = try await room.messages.delete( - forSerial: messageToDelete.serial, - params: .init(description: "Message deleted by user") + withSerial: messageToDelete.serial, + details: .init(description: "Message deleted by user") ) ``` diff --git a/src/pages/docs/chat/rooms/presence.mdx b/src/pages/docs/chat/rooms/presence.mdx index 0380ac11b4..51ecc56a2d 100644 --- a/src/pages/docs/chat/rooms/presence.mdx +++ b/src/pages/docs/chat/rooms/presence.mdx @@ -187,11 +187,11 @@ const MyComponent = () => { // By default, presence will be entered when the hook mounts and left // when it subsequently unmounts. - const { isPresent } = usePresence(presenceParams); + const { myPresenceState } = usePresence(presenceParams); return (
-
Presence status: {isPresent ? 'Online' : 'Offline'}
+
Presence status: {myPresenceState.present ? 'Online' : 'Offline'}
); }; @@ -264,7 +264,7 @@ const MyComponent = () => { initialData: { status: 'Online' }, }; - const { update, isPresent } = usePresence(presenceParams); + const { update, myPresenceState } = usePresence(presenceParams); const updatePresence = () => { update({ status: 'Away' }); @@ -272,7 +272,7 @@ const MyComponent = () => { return (
-
Presence status: {isPresent ? 'Online' : 'Offline'}
+
Presence status: {myPresenceState.present ? 'Online' : 'Offline'}
); @@ -345,11 +345,11 @@ const MyComponent = () => { }; // Call leave explicitly to disable auto-entry and leave presence - const { isPresent, leave } = usePresence(presenceParams); + const { myPresenceState, leave } = usePresence(presenceParams); return (
-
Presence status: {isPresent ? 'Online' : 'Offline'}
+
Presence status: {myPresenceState.present ? 'Online' : 'Offline'}
); }; @@ -418,7 +418,7 @@ For various reasons, you may wish to take manual control of presence (as you do autoEnterLeave: false, }; - const { isPresent, leave, enter, update } = usePresence(presenceParams); + const { myPresenceState, leave, enter, update } = usePresence(presenceParams); // Manual mount behavior - enter presence when component mounts useEffect(() => { @@ -438,7 +438,7 @@ For various reasons, you may wish to take manual control of presence (as you do return (
-
Presence status: {isPresent ? 'Online' : 'Offline'}
+
Presence status: {myPresenceState.present ? 'Online' : 'Offline'}
); diff --git a/src/pages/docs/chat/rooms/reactions.mdx b/src/pages/docs/chat/rooms/reactions.mdx index d33540480b..0d2c8fd2e0 100644 --- a/src/pages/docs/chat/rooms/reactions.mdx +++ b/src/pages/docs/chat/rooms/reactions.mdx @@ -39,6 +39,8 @@ const MyComponent = () => { console.log('Received reaction: ', reactionEvent.reaction); }, }); + + return
Room Reactions Component
; }; ``` diff --git a/src/pages/docs/chat/rooms/replies.mdx b/src/pages/docs/chat/rooms/replies.mdx index ae5b54ab3f..5f48a57a88 100644 --- a/src/pages/docs/chat/rooms/replies.mdx +++ b/src/pages/docs/chat/rooms/replies.mdx @@ -20,7 +20,7 @@ async function sendReply(replyToMessage, replyText) { const metadata = { reply: { serial: replyToMessage.serial, - timestamp: replyToMessage.createdAt.getTime(), + timestamp: replyToMessage.timestamp.getTime(), clientId: replyToMessage.clientId, previewText: replyToMessage.text.substring(0, 140) } @@ -43,7 +43,7 @@ const ReplyComponent = ({ messageToReplyTo }) => { const metadata = { reply: { serial: messageToReplyTo.serial, - createdAt: messageToReplyTo.createdAt.getTime(), + timestamp: messageToReplyTo.timestamp.getTime(), clientId: messageToReplyTo.clientId, previewText: messageToReplyTo.text.substring(0, 140) } @@ -62,6 +62,78 @@ const ReplyComponent = ({ messageToReplyTo }) => { ); }; ``` + +```swift +func sendReply(replyToMessage: Message, replyText: String) async throws { + let metadata: MessageMetadata = [ + "reply": .object([ + "serial": .string(replyToMessage.serial), + "timestamp": .number(Double(replyToMessage.timestamp.timeIntervalSince1970 * 1000)), + "clientId": .string(replyToMessage.clientID), + "previewText": .string(String(replyToMessage.text.prefix(140))) + ]) + ] + + try await room.messages.send(withParams: .init( + text: replyText, + metadata: metadata + )) +} +``` + +```kotlin +import com.ably.chat.json.jsonObject + +suspend fun sendReply(replyToMessage: Message, replyText: String) { + val metadata = jsonObject { + putObject("reply") { + put("serial", replyToMessage.serial) + put("timestamp", replyToMessage.timestamp) + put("clientId", replyToMessage.clientId) + put("previewText", replyToMessage.text.take(140)) + } + } + + room.messages.send( + text = replyText, + metadata = metadata + ) +} +``` + +```jetpack +import androidx.compose.material.* +import androidx.compose.runtime.* +import com.ably.chat.Message +import com.ably.chat.Room +import com.ably.chat.json.jsonObject +import kotlinx.coroutines.launch + +@Composable +fun SendReplyComponent(room: Room, messageToReplyTo: Message) { + val coroutineScope = rememberCoroutineScope() + + Button(onClick = { + coroutineScope.launch { + val metadata = jsonObject { + putObject("reply") { + put("serial", messageToReplyTo.serial) + put("timestamp", messageToReplyTo.timestamp) + put("clientId", messageToReplyTo.clientId) + put("previewText", messageToReplyTo.text.take(140)) + } + } + + room.messages.send( + text = "My reply", + metadata = metadata + ) + } + }) { + Text("Send Reply") + } +} +``` ## Subscribe to message replies
@@ -79,7 +151,7 @@ When a user replies to a message, extract and store the parent message details: function prepareReply(parentMessage) { return { serial: parentMessage.serial, - createdAt: parentMessage.createdAt.getTime(), + timestamp: parentMessage.timestamp.getTime(), clientId: parentMessage.clientId, previewText: parentMessage.text.substring(0, 140) }; @@ -90,12 +162,46 @@ function prepareReply(parentMessage) { const prepareReply = (parentMessage) => { return { serial: parentMessage.serial, - createdAt: parentMessage.createdAt.getTime(), + timestamp: parentMessage.timestamp.getTime(), clientId: parentMessage.clientId, previewText: parentMessage.text.substring(0, 140) }; }; ``` + +```swift +func prepareReply(parentMessage: Message) -> JSONObject { + return [ + "serial": .string(parentMessage.serial), + "timestamp": .number(Double(parentMessage.timestamp.timeIntervalSince1970 * 1000)), + "clientId": .string(parentMessage.clientID), + "previewText": .string(String(parentMessage.text.prefix(140))) + ] +} +``` + +```kotlin +import com.ably.chat.json.jsonObject + +fun prepareReply(parentMessage: Message) = jsonObject { + put("serial", parentMessage.serial) + put("timestamp", parentMessage.timestamp) + put("clientId", parentMessage.clientId) + put("previewText", parentMessage.text.take(140)) +} +``` + +```jetpack +import com.ably.chat.Message +import com.ably.chat.json.jsonObject + +fun prepareReply(parentMessage: Message) = jsonObject { + put("serial", parentMessage.serial) + put("timestamp", parentMessage.timestamp) + put("clientId", parentMessage.clientId) + put("previewText", parentMessage.text.take(140)) +} +``` If a parent message isn't in local state, fetch it directly using its `serial`: @@ -126,6 +232,48 @@ const FetchParentMessage = ({ replyData }) => { ) : null; }; ``` + +```swift +func fetchParentMessage(replyData: JSONObject) async throws -> Message { + guard let serial = replyData["serial"]?.stringValue else { + throw NSError(domain: "ReplyError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid serial"]) + } + return try await room.messages.get(withSerial: serial) +} +``` + +```kotlin +import com.ably.chat.json.* + +suspend fun fetchParentMessage(replyData: JsonObject): Message { + val serial = (replyData["serial"] as? JsonString)?.value + ?: throw IllegalArgumentException("Invalid serial") + return room.messages.get(serial) +} +``` + +```jetpack +import androidx.compose.material.* +import androidx.compose.runtime.* +import com.ably.chat.* +import com.ably.chat.json.* + +@Composable +fun FetchParentMessageComponent(room: Room, replyData: JsonObject) { + var parentMessage by remember { mutableStateOf(null) } + + LaunchedEffect(replyData) { + val serial = (replyData["serial"] as? JsonString)?.value + if (serial != null) { + parentMessage = room.messages.get(serial) + } + } + + parentMessage?.let { message -> + Text(text = message.text) + } +} +``` ### Display replies @@ -187,6 +335,109 @@ const MessageList = () => { ); }; ``` + +```swift +// Extension to extract reply data from a message +extension Message { + var replySerial: String? { + metadata["reply"]?.objectValue?["serial"]?.stringValue + } + + var replyPreview: (clientId: String, text: String)? { + guard let replyData = metadata["reply"]?.objectValue, + let clientId = replyData["clientId"]?.stringValue, + let previewText = replyData["previewText"]?.stringValue else { + return nil + } + return (clientId, previewText) + } +} + +// Subscribe to messages and handle replies +var localMessages: [Message] = [] + +for await event in room.messages.subscribe() { + let message = event.message + + if let replySerial = message.replySerial { + if let parentMessage = localMessages.first(where: { $0.serial == replySerial }) { + print("Reply to \(parentMessage.clientID): \(parentMessage.text)") + } else if let preview = message.replyPreview { + print("Reply to \(preview.clientId): \(preview.text)") + } + } + + print("Message: \(message.text)") + localMessages.append(message) +} +``` + +```kotlin +import com.ably.chat.json.* + +// Subscribe to messages and handle replies +val localMessages = mutableListOf() + +room.messages.subscribe { event -> + val message = event.message + + val replyData = message.metadata["reply"] as? JsonObject + if (replyData != null) { + val replySerial = (replyData["serial"] as? JsonString)?.value + val parentMessage = localMessages.find { it.serial == replySerial } + + if (parentMessage != null) { + println("Reply to ${parentMessage.clientId}: ${parentMessage.text}") + } else { + val replyClientId = (replyData["clientId"] as? JsonString)?.value + val previewText = (replyData["previewText"] as? JsonString)?.value + println("Reply to $replyClientId: $previewText") + } + } + + println("Message: ${message.text}") + localMessages.add(message) +} +``` + +```jetpack +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import com.ably.chat.* +import com.ably.chat.json.* + +@Composable +fun MessageListComponent(room: Room) { + val messages = remember { mutableStateListOf() } + + DisposableEffect(room) { + val (unsubscribe) = room.messages.subscribe { event -> + messages += event.message + } + + onDispose { + unsubscribe() + } + } + + Column { + messages.forEach { message -> + Column { + // Display reply information if present + val replyData = message.metadata["reply"] as? JsonObject + if (replyData != null) { + val previewText = (replyData["previewText"] as? JsonString)?.value + Text(text = "Replying to: $previewText") + } + + // Display the message text + Text(text = message.text) + } + } + } +} +``` ## Considerations diff --git a/src/pages/docs/chat/rooms/typing.mdx b/src/pages/docs/chat/rooms/typing.mdx index 428c605672..e16a28d269 100644 --- a/src/pages/docs/chat/rooms/typing.mdx +++ b/src/pages/docs/chat/rooms/typing.mdx @@ -31,7 +31,7 @@ import { useTyping } from '@ably/chat/react'; import { TypingSetEvent } from '@ably/chat'; const MyComponent = () => { - const {currentlyTyping, error } = useTyping({ + const { currentlyTyping, roomError } = useTyping({ listener: (typingEvent: TypingSetEvent) => { console.log('Typing event received: ', typingEvent); }, @@ -39,8 +39,8 @@ const MyComponent = () => { return (
- {error &&

Typing Error: {error.message}

} -

Currently typing: {Array.from(currentlyTyping).join(', ')}

+ {roomError &&

Typing Error: {roomError.message}

} +

Currently typing: {Array.from(currentlyTyping).join(', ')}

); }; @@ -169,14 +169,14 @@ await room.typing.keystroke(); import { useTyping } from '@ably/chat/react'; const MyComponent = () => { - const { keystroke, currentlyTyping, error } = useTyping(); + const { keystroke, currentlyTyping, roomError } = useTyping(); const handleKeystrokeClick = () => { keystroke(); }; return (
- {error &&

Typing Error: {error.message}

} + {roomError &&

Typing Error: {roomError.message}

}

Currently typing: {Array.from(currentlyTyping).join(', ')}

@@ -234,14 +234,14 @@ await room.typing.stop(); import { useTyping } from '@ably/chat/react'; const MyComponent = () => { - const { stop, error } = useTyping(); + const { stop, roomError } = useTyping(); const handleStopClick = () => { stop(); }; return (
- {error &&

Typing Error: {error.message}

} + {roomError &&

Typing Error: {roomError.message}

}
); diff --git a/src/pages/docs/chat/setup.mdx b/src/pages/docs/chat/setup.mdx index f5191b4706..fd584e129f 100644 --- a/src/pages/docs/chat/setup.mdx +++ b/src/pages/docs/chat/setup.mdx @@ -266,7 +266,7 @@ import { ChatClientProvider } from '@ably/chat/react'; const ably = new Ably.Realtime({ key: '{{API_KEY}}', clientId: ''}); const chatClient = new ChatClient(ably, {logHandler: logWriteFunc, logLevel: 'debug' }); -const App = => { +const App = () => { return (