diff --git a/docs.json b/docs.json index cb43da08b..34e0566d3 100644 --- a/docs.json +++ b/docs.json @@ -2263,6 +2263,8 @@ "ui-kit/flutter/message-list", "ui-kit/flutter/message-composer", "ui-kit/flutter/threaded-messages-header", + "ui-kit/flutter/pinned-messages", + "ui-kit/flutter/saved-messages", "ui-kit/flutter/incoming-call", "ui-kit/flutter/outgoing-call", "ui-kit/flutter/call-buttons", @@ -4589,7 +4591,10 @@ "sdk/flutter/edit-message", "sdk/flutter/flag-message", "sdk/flutter/delete-message", + "sdk/flutter/pin-messages", + "sdk/flutter/save-messages", "sdk/flutter/delete-conversation", + "sdk/flutter/pin-conversations", "sdk/flutter/typing-indicators", "sdk/flutter/transient-messages", "sdk/flutter/delivery-read-receipts", diff --git a/notifications/preferences.mdx b/notifications/preferences.mdx index cecbda53b..be3d6aa99 100644 --- a/notifications/preferences.mdx +++ b/notifications/preferences.mdx @@ -164,7 +164,7 @@ CometChatNotifications.fetchPreferences( MessagesOptions? messagesPreference = groupPreferences?.messages; RepliesOptions? repliesPreference = groupPreferences?.replies; - RepliesOptions? quotedRepliesPreference = groupPreferences?.quotedReplies; + QuotedRepliesOptions? quotedRepliesPreference = groupPreferences?.quotedReplies; ReactionsOptions? reactionsPreference = groupPreferences?.reactions; MemberActionsOptions? memberAddedPreference = groupPreferences?.memberAdded; MemberActionsOptions? memberJoinedPreference = groupPreferences?.memberJoined; @@ -323,7 +323,7 @@ NotificationPreferences updatedPreferences = NotificationPreferences(); GroupPreferences groupPreferences = GroupPreferences( messages: MessagesOptions.SUBSCRIBE_TO_MENTIONS, replies: RepliesOptions.SUBSCRIBE_TO_ALL, - quotedReplies: RepliesOptions.SUBSCRIBE_TO_ALL, + quotedReplies: QuotedRepliesOptions.SUBSCRIBE_TO_ALL, reactions: ReactionsOptions.SUBSCRIBE_TO_REACTIONS_ON_ALL_MESSAGES, memberAdded: MemberActionsOptions.SUBSCRIBE, memberJoined: MemberActionsOptions.SUBSCRIBE, @@ -444,7 +444,7 @@ CometChatNotifications.fetchPreferences( MessagesOptions? oneOnOneMessagesPreference = oneOnOnePreferences?.messages; RepliesOptions? oneOnOneRepliesPreference = oneOnOnePreferences?.replies; - RepliesOptions? oneOnOneQuotedRepliesPreference = oneOnOnePreferences?.quotedReplies; + QuotedRepliesOptions? oneOnOneQuotedRepliesPreference = oneOnOnePreferences?.quotedReplies; ReactionsOptions? oneOnOneReactionsPreference = oneOnOnePreferences?.reactions; }, onError: (e) { @@ -567,7 +567,7 @@ NotificationPreferences updatedPreferences = NotificationPreferences(); OneOnOnePreferences oneOnOnePreferences = OneOnOnePreferences( messages: MessagesOptions.SUBSCRIBE_TO_ALL, replies: RepliesOptions.SUBSCRIBE_TO_MENTIONS, - quotedReplies: RepliesOptions.SUBSCRIBE_TO_MENTIONS, + quotedReplies: QuotedRepliesOptions.SUBSCRIBE_TO_MENTIONS, reactions: ReactionsOptions.SUBSCRIBE_TO_REACTIONS_ON_ALL_MESSAGES); // Load the updates in the NotificationPreferences instance. diff --git a/notifications/push-overview.mdx b/notifications/push-overview.mdx index 6d83a545f..2c2ff934b 100644 --- a/notifications/push-overview.mdx +++ b/notifications/push-overview.mdx @@ -76,3 +76,9 @@ UI Kit implementation + + + +There is no separate guide for Flutter web builds yet. A Flutter app running on the web registers its FCM token with `PushPlatforms.FCM_WEB` (wire value `fcm_web`) instead of `FCM_FLUTTER_ANDROID` or the APNs providers, passing the token as the `fcmToken` argument to `CometChatNotifications.registerPushToken()`. Everything else — enabling push and adding an FCM provider in the dashboard — matches the Flutter (Android) guide. + + diff --git a/sdk/flutter/pin-conversations.mdx b/sdk/flutter/pin-conversations.mdx new file mode 100644 index 000000000..fb6218eec --- /dev/null +++ b/sdk/flutter/pin-conversations.mdx @@ -0,0 +1,106 @@ +--- +title: "Pin Conversations" +description: "Pin CometChat conversations to the top of the list in Flutter apps and keep every device in sync through real-time pin events." +--- + + + +Pinning a conversation surfaces it at the top of the logged-in user's conversation list. Conversation pins are **per-user** — pinning a conversation does not affect how the other participants see their lists. A pinned conversation carries a `pinnedAt` timestamp and the `pinnedBy` uid. + +A conversation can also be pinned for the user by an admin surface, in which case `pinnedBy` carries the `app_system` sentinel. System pins rank above the user's own pins and cannot be removed from the client. + +## Pin a Conversation + +In order to pin a conversation, you can use the `pinConversation()` method. This method takes the uid/guid of the conversation counterpart and the conversation type (`user`/`group`). On success it returns the full updated `Conversation` with `pinnedAt` and `pinnedBy` stamped. + + + +```dart +String conversationWith = "cometchat-uid-1"; +String conversationType = CometChatConversationType.user; + +CometChat.pinConversation(conversationWith, conversationType, + onSuccess: (Conversation conversation) { + debugPrint("Conversation pinned at: ${conversation.pinnedAt}"); + }, onError: (CometChatException e) { + debugPrint("Conversation pinning failed with exception: ${e.message}"); +}); +``` + + + + +The call is idempotent — pinning an already-pinned conversation succeeds and returns the current state. + +## Unpin a Conversation + +In order to unpin a conversation, you can use the `unpinConversation()` method. Only a pin placed by the logged-in user can be removed — an `app_system` pin is rejected server-side. The returned `Conversation` carries the pin fields cleared to `null`. + + + +```dart +String conversationWith = "cometchat-uid-1"; +String conversationType = CometChatConversationType.user; + +CometChat.unpinConversation(conversationWith, conversationType, + onSuccess: (Conversation conversation) { + debugPrint("Conversation unpinned"); + }, onError: (CometChatException e) { + debugPrint("Conversation unpinning failed with exception: ${e.message}"); +}); +``` + + + + +## Real-Time Pin Events + +Pin and unpin events are delivered to the logged-in user's devices through the `ConversationListener` class — the acting device receives the callback on success, and the user's other devices receive it over the socket, so lists stay in sync everywhere. Admin (`app_system`) pins applied server-side arrive through the same callbacks. + +To receive them, register a listener using the `addConversationListener()` method and override the `onConversationPinned()` and `onConversationUnpinned()` callbacks. Remove the listener with `removeConversationListener()` when it is no longer needed. + + + +```dart +class Class_Name with ConversationListener { + + //CometChat.addConversationListener("listenerId", this); + + @override + void onConversationPinned(Conversation conversation) { + debugPrint("Conversation pinned: ${conversation.conversationId}"); + } + + @override + void onConversationUnpinned(Conversation conversation) { + debugPrint("Conversation unpinned: ${conversation.conversationId}"); + } +} +``` + + + + +When applying these events to a conversation list, keep the ordering contract: system pins (`pinnedBy == "app_system"`) stay above user pins, and user pins stay above the activity-ordered rest of the list. + +## Fetching and Ordering + +Pinned conversations are returned by the regular `ConversationsRequest` described in [Retrieve Conversations](/sdk/flutter/retrieve-conversations), ordered pinned-first — system pins, then the user's pins, then the remaining conversations by latest activity. Inspect `conversation.pinnedAt` / `conversation.pinnedBy` on the fetched objects to render the pinned state. + +## Feature Availability and Limits + +Whether the Pin Conversation feature is enabled for the logged-in user is served on the user's login payload. You can check it at any time using the synchronous `isPinConversationEnabled()` method — it never throws, and returns `true` when the backend did not serve the flag. + +The maximum number of conversations a user can pin is available through `getPinnedConversationsLimit()`, which returns `null` when the backend did not serve a limit. When a pin call exceeds the cap, it fails with a limit-exceeded error whose `errorParams` map carries the authoritative limit as `{"limit": n}`. + + + +```dart +if (CometChat.isPinConversationEnabled()) { + int? limit = CometChat.getPinnedConversationsLimit(); + debugPrint("Conversation pinning enabled, limit: ${limit ?? "server default"}"); +} +``` + + + diff --git a/sdk/flutter/pin-messages.mdx b/sdk/flutter/pin-messages.mdx new file mode 100644 index 000000000..e6112b4fd --- /dev/null +++ b/sdk/flutter/pin-messages.mdx @@ -0,0 +1,146 @@ +--- +title: "Pin Messages" +description: "Pin and unpin CometChat messages in Flutter apps, listen to pin events in real time, and fetch the pinned messages of a conversation." +--- + + + +Pinning highlights an important message for **everyone in the conversation**. A pinned message carries a `pinnedAt` timestamp and the `pinnedBy` uid of the member who pinned it, and every participant can fetch the conversation's pinned list. + +Pinning is permissioned in groups — only participants with the admin, moderator or owner scope can pin or unpin. In one-to-one conversations both participants can. + +## Pin a Message + +*In other words, as a member of a conversation, how do I pin a message for everyone?* + +In order to pin a message, you can use the `pinMessage()` method. This method takes the id of the message to be pinned. On success it returns the **full updated message** with `pinnedAt` and `pinnedBy` stamped. + + + +```dart +int messageId = 103; + +CometChat.pinMessage(messageId, onSuccess: (BaseMessage message) { + debugPrint("Message pinned successfully: ${message.pinnedAt}"); +}, onError: (CometChatException e) { + debugPrint("Message pinning failed with exception: ${e.message}"); +}); +``` + + + + +The call is idempotent — pinning an already-pinned message succeeds and returns the current state. + +## Unpin a Message + +In order to unpin a message, you can use the `unpinMessage()` method. The returned message carries the pin fields cleared to `null`. The same permission model applies. + + + +```dart +int messageId = 103; + +CometChat.unpinMessage(messageId, onSuccess: (BaseMessage message) { + debugPrint("Message unpinned successfully"); +}, onError: (CometChatException e) { + debugPrint("Message unpinning failed with exception: ${e.message}"); +}); +``` + + + + +## Real-Time Pin Events + +Pin and unpin actions are delivered to all participants through the `MessageListener` class. To receive them, register a listener using the `addMessageListener()` method and override the `onMessagePinned()` and `onMessageUnpinned()` callbacks. Both receive the full updated message object. + + + +```dart +class Class_Name with MessageListener { + + //CometChat.addMessageListener("listenerId", this); + + @override + void onMessagePinned(BaseMessage message) { + debugPrint("Message pinned: ${message.id} by ${message.pinnedBy}"); + } + + @override + void onMessageUnpinned(BaseMessage message) { + debugPrint("Message unpinned: ${message.id}"); + } +} +``` + + + + +The device that performed the action also receives these callbacks on success, so a single code path can update your UI for your own pins and for pins made by other members or your other devices. + +## Fetch Pinned Messages + +You can fetch all the pinned messages of a conversation by using the `MessagesRequest` class with the `pinned` parameter of the `MessagesRequestBuilder` set to `true`. A pinned list belongs to one conversation, so pair it with the `uid` (for a user conversation) or `guid` (for a group). + + + +```dart +String UID = "cometchat-uid-1"; + +MessagesRequest messageRequest = (MessagesRequestBuilder() + ..uid = UID + ..pinned = true + ..limit = 50).build(); + +messageRequest.fetchPrevious(onSuccess: (List list) { + debugPrint("Pinned messages fetched: ${list.length}"); +}, onError: (CometChatException e) { + debugPrint("Pinned message fetching failed with exception: ${e.message}"); +}); +``` + + + + +## Feature Availability and Limits + +Whether the Pin Message feature is enabled for the logged-in user is served on the user's login payload. You can check it at any time using the synchronous `isPinMessageEnabled()` method — it never throws, and returns `true` when the backend did not serve the flag so the feature is not disabled on older backends. + +The maximum number of messages that can be pinned per conversation is also served on the login payload and is available through `getPinnedMessagesLimit()`. It returns `null` when the backend did not serve a limit. + + + +```dart +if (CometChat.isPinMessageEnabled()) { + int? limit = CometChat.getPinnedMessagesLimit(); + debugPrint("Pinning enabled, limit: ${limit ?? "server default"}"); +} +``` + + + + +When a pin call exceeds the cap, it fails with the `ERR_PINNED_MESSAGES_LIMIT_EXCEEDED` error code. The exception's `errorParams` map carries the authoritative limit as `{"limit": n}`, which you can interpolate into your error copy. + + + +```dart +CometChat.pinMessage(messageId, onSuccess: (BaseMessage message) { + debugPrint("Message pinned"); +}, onError: (CometChatException e) { + if (e.code == 'ERR_PINNED_MESSAGES_LIMIT_EXCEEDED') { + final limit = e.errorParams?['limit']; + debugPrint("You can only pin $limit messages. Unpin one to pin another."); + } +}); +``` + + + + + + +Pins placed from an admin surface carry the `app_system` sentinel in `pinnedBy`. Save is the private, per-user counterpart of pinning — see [Save Messages](/sdk/flutter/save-messages). + + diff --git a/sdk/flutter/save-messages.mdx b/sdk/flutter/save-messages.mdx new file mode 100644 index 000000000..5666a6bf0 --- /dev/null +++ b/sdk/flutter/save-messages.mdx @@ -0,0 +1,119 @@ +--- +title: "Save Messages" +description: "Save (bookmark) CometChat messages privately in Flutter apps, sync saves across devices, and fetch the logged-in user's saved messages." +--- + + + +Saving bookmarks a message **privately for the logged-in user**. Unlike [pinning](/sdk/flutter/pin-messages), a save is per-viewer: no other member is notified, nothing changes for the rest of the conversation, and any message the user can read — their own or someone else's — can be saved. A saved message carries a `savedAt` timestamp visible only to the user who saved it. + +## Save a Message + +In order to save a message, you can use the `saveMessage()` method. This method takes the id of the message to be saved. On success it returns the full updated message with `savedAt` stamped. + + + +```dart +int messageId = 103; + +CometChat.saveMessage(messageId, onSuccess: (BaseMessage message) { + debugPrint("Message saved successfully: ${message.savedAt}"); +}, onError: (CometChatException e) { + debugPrint("Message saving failed with exception: ${e.message}"); +}); +``` + + + + +The call is idempotent — saving an already-saved message succeeds and returns the current state. + +## Unsave a Message + +In order to unsave a message, you can use the `unsaveMessage()` method. The returned message carries `savedAt` cleared to `null`. + + + +```dart +int messageId = 103; + +CometChat.unsaveMessage(messageId, onSuccess: (BaseMessage message) { + debugPrint("Message unsaved successfully"); +}, onError: (CometChatException e) { + debugPrint("Message unsaving failed with exception: ${e.message}"); +}); +``` + + + + +## Real-Time Save Events + +Because saves are private, save events are delivered only to the **logged-in user's own devices** — the acting device receives the callback on success, and the user's other devices receive it over the socket for cross-device sync. Register a `MessageListener` using the `addMessageListener()` method and override the `onMessageSaved()` and `onMessageUnsaved()` callbacks. + + + +```dart +class Class_Name with MessageListener { + + //CometChat.addMessageListener("listenerId", this); + + @override + void onMessageSaved(BaseMessage message) { + debugPrint("Message saved: ${message.id}"); + } + + @override + void onMessageUnsaved(BaseMessage message) { + debugPrint("Message unsaved: ${message.id}"); + } +} +``` + + + + +## Fetch Saved Messages + +You can fetch the logged-in user's saved messages by using the `MessagesRequest` class with the `saved` parameter of the `MessagesRequestBuilder` set to `true`. + + + +```dart +MessagesRequest messageRequest = (MessagesRequestBuilder() + ..saved = true + ..limit = 50).build(); + +messageRequest.fetchPrevious(onSuccess: (List list) { + debugPrint("Saved messages fetched: ${list.length}"); +}, onError: (CometChatException e) { + debugPrint("Saved message fetching failed with exception: ${e.message}"); +}); +``` + + + + +## Feature Availability and Limits + +Whether the Save Message feature is enabled for the logged-in user is served on the user's login payload. You can check it at any time using the synchronous `isSaveMessageEnabled()` method — it never throws, and returns `true` when the backend did not serve the flag. + +The maximum number of messages a user can save is available through `getSavedMessagesLimit()`, which returns `null` when the backend did not serve a limit. + +When a save call exceeds the cap, it fails with the `ERR_SAVED_MESSAGES_LIMIT_EXCEEDED` error code. The exception's `errorParams` map carries the authoritative limit as `{"limit": n}`. + + + +```dart +CometChat.saveMessage(messageId, onSuccess: (BaseMessage message) { + debugPrint("Message saved"); +}, onError: (CometChatException e) { + if (e.code == 'ERR_SAVED_MESSAGES_LIMIT_EXCEEDED') { + final limit = e.errorParams?['limit']; + debugPrint("You can only save $limit messages. Unsave one to save another."); + } +}); +``` + + + diff --git a/sdk/flutter/threaded-messages.mdx b/sdk/flutter/threaded-messages.mdx index c796bcd1b..b6ae0f6fb 100644 --- a/sdk/flutter/threaded-messages.mdx +++ b/sdk/flutter/threaded-messages.mdx @@ -149,3 +149,159 @@ messageRequest.fetchNext(onSuccess: (List list) { The above snippet will return messages between the logged in user and `cometchat-uid-1` excluding all the threaded messages belonging to the same conversation. + +## Thread Subscriptions + +Subscribing to (following) a thread lets the logged-in user be notified about new replies in that thread. The SDK exposes the server's answer on the message and two calls to change it — subscription state lives on the message, not in the SDK. + +### Subscribe to a Thread + +In order to subscribe to a thread, you can use the `subscribeToThread()` method. This method takes the id of the thread's parent message. Subscribing is allowed even on a message that has no replies yet. + + + +```dart +int parentMessageId = 103; + +CometChat.subscribeToThread(parentMessageId, onSuccess: (String response) { + debugPrint("Subscribed to thread successfully"); +}, onError: (CometChatException e) { + debugPrint("Thread subscription failed with exception: ${e.message}"); +}); +``` + + + + +The call is idempotent — subscribing to an already-followed thread succeeds and is not an error. **The resolved call is the acknowledgement**: there is no follow-up event. + +### Unsubscribe from a Thread + +In order to unsubscribe from a thread, you can use the `unsubscribeFromThread()` method. + + + +```dart +int parentMessageId = 103; + +CometChat.unsubscribeFromThread(parentMessageId, onSuccess: (String response) { + debugPrint("Unsubscribed from thread successfully"); +}, onError: (CometChatException e) { + debugPrint("Thread unsubscription failed with exception: ${e.message}"); +}); +``` + + + + + + +Unfollowing is not sticky: replying to the thread again, or being @-mentioned in it, re-subscribes the user. Do not promise otherwise in UI copy. Unsubscribing also hard-deletes the subscription server-side — if you are holding a fetched thread list, remove the row locally when the call resolves. + + + +### Read the Subscription State + +Subscription state lives **on the message**. Every message fetch opts in with `withThreadSubscribed=true` automatically, and the server returns the flag on the message payload — the SDK normalises it and exposes it as the `threadSubscribed` boolean on any fetched message. The flag is per-viewer: the same message yields different values for different users. + + + +```dart +if (message.threadSubscribed) { + debugPrint("Following this thread"); +} +``` + + + + +Two rules to internalise: + +1. The flag is only populated on responses to requests that **asked** for it. A message delivered over the socket carries no flag and therefore reads `false` — meaning "the server did not tell me", not "the user is unsubscribed". +2. **A message's author is subscribed to its thread by default.** A fetched message the logged-in user sent reading `false` is therefore not silence — it is an explicit unsubscribe, and nothing should override it. + +Because subscribing is idempotent, rendering an un-told `false` as the un-followed affordance is safe — an unnecessary subscribe is harmless. + +### Re-read the State for One Message + +When you need an authoritative flag for a single message — after acting on a socket-delivered message, or to build a thread row without refetching a list — use `getMessageDetails()`. It takes a message id and carries the subscription opt-in, so the message it resolves with has `threadSubscribed` populated. + + + +```dart +int messageId = 103; + +CometChat.getMessageDetails(messageId, onSuccess: (BaseMessage message) { + debugPrint("Thread subscribed: ${message.threadSubscribed}"); +}, onError: (CometChatException e) { + debugPrint("Message details fetching failed with exception: ${e.message}"); +}); +``` + + + + +### Reacting to Replies + +A thread reply is an ordinary message with `parentMessageId` set, delivered through the standard `MessageListener` alongside every other message — there is no thread-specific event channel. The same listener also delivers `onMessageEdited` and `onMessageDeleted` for replies, which a replies-only channel would miss. + + + +```dart +class Class_Name with MessageListener { + + //CometChat.addMessageListener("listenerId", this); + + @override + void onTextMessageReceived(TextMessage textMessage) { + if (textMessage.parentMessageId != 0) { + debugPrint("A reply landed in thread ${textMessage.parentMessageId}"); + } + } +} +``` + + + + +Your own replies do not arrive on a listener — update your UI from the send call's success callback. + +### Fetch Participated Threads + +You can fetch the threads the logged-in user participates in by using the `ThreadsRequest` class. The `ThreadsRequestBuilder` builds the request using `setLimit()` and `setParticipatedByMe()`; once you have the `ThreadsRequest` object, call `fetchNext()` to get the next set of threads. Presence in this list *is* a subscription — every row is subscribed. + + + +```dart +ThreadsRequest threadsRequest = (ThreadsRequestBuilder() + ..setParticipatedByMe(true) + ..setLimit(30)).build(); + +threadsRequest.fetchNext(onSuccess: (List threads) { + debugPrint("Fetched ${threads.length} threads"); +}, onError: (CometChatException e) { + debugPrint("Thread fetching failed with exception: ${e.message}"); +}); +``` + + + + +To scope the list to a single conversation, add `setUid()` for a 1-1 counterpart or `setGuid()` for a group. The two are mutually exclusive — setting both is a validation error raised when you call `fetchNext()`. + + + +```dart +ThreadsRequest threadsRequest = (ThreadsRequestBuilder() + ..setGuid("cometchat-guid-1") + ..setLimit(30)).build(); +``` + + + + + + +A `ThreadsRequest` is single-use and one-directional: it accumulates its paging state internally and has no reset. To refresh a list, build a new request from the builder and replace the list rather than re-running an exhausted one. This is the same contract as `ConversationsRequest` and `MessagesRequest`. + + diff --git a/ui-kit/flutter/conversations.mdx b/ui-kit/flutter/conversations.mdx index bda98e431..fee7f0dbf 100644 --- a/ui-kit/flutter/conversations.mdx +++ b/ui-kit/flutter/conversations.mdx @@ -263,6 +263,7 @@ The component listens to these SDK events internally. No manual setup needed. | `hideSearch` | `bool?` | `null` | Toggle search bar | | `searchReadOnly` | `bool` | `false` | Make search bar read-only (tap opens custom search) | | `deleteConversationOptionVisibility` | `bool?` | `true` | Show delete option on long press | +| `pinConversationOptionVisibility` | `bool?` | `true` | Show pin/unpin option on long press | | `groupTypeVisibility` | `bool?` | `true` | Show group type icon on avatar | | `usersStatusVisibility` | `bool?` | `true` | Show online/offline status indicator | | `receiptsVisibility` | `bool?` | `true` | Show message receipts | @@ -428,6 +429,26 @@ CometChatConversations( --- +## Pin Conversations + +The long-press menu offers **Pin conversation** / **Unpin conversation** alongside Delete. Pinned conversations form a shelf at the top of the list, ordered system pins first (placed by an admin surface, `pinnedBy == "app_system"`), then the user's own pins, then the remaining conversations by latest activity — and real-time events keep that ordering: new activity floats a conversation to the top of **its own section only**, never above a pin. + +Everything is wired internally through the SDK's [Pin Conversations](/sdk/flutter/pin-conversations) APIs: the component performs the pin/unpin call, shows a confirmation toast (or the cap error with the server's limit interpolated), and reorders the list from the SDK's `ConversationListener` events — including pins made from the user's other devices. + +To hide the option, set `pinConversationOptionVisibility` to `false`. The option also hides itself when the feature is disabled for the logged-in user (`CometChat.isPinConversationEnabled()`), and for rows pinned by `app_system`, which are not user-removable. + + + +```dart +CometChatConversations( + pinConversationOptionVisibility: false, // hide pin/unpin from the long-press menu +) +``` + + + +--- + ## Common Patterns ### Minimal list — hide all chrome diff --git a/ui-kit/flutter/events.mdx b/ui-kit/flutter/events.mdx index aafde12a6..ba3b04fe9 100644 --- a/ui-kit/flutter/events.mdx +++ b/ui-kit/flutter/events.mdx @@ -113,6 +113,18 @@ class _YourScreenState extends State with CometChatGroupEventListene `CometChatMessageEvents` emits events related to messages. +Pin, save and thread-subscription actions taken on a Kit surface are announced here, each carrying the full updated message so a custom listing can add or drop a row without a refetch: + +1. `ccMessagePinned` / `ccMessageUnpinned`: Triggered when the logged-in user pins or unpins a message. +2. `ccMessageSaved` / `ccMessageUnsaved`: Triggered when the logged-in user saves or unsaves a message. +3. `ccThreadSubscriptionChanged`: Triggered when the logged-in user follows or unfollows a thread. Carries `(int parentMessageId, bool subscribed)` rather than a message. An unfollow also hard-deletes the thread's row server-side, so remove it locally. + + + +All five are default no-ops on `CometChatMessageEventListener`, so existing listeners need no change to keep compiling. + + + ```dart diff --git a/ui-kit/flutter/guide-threaded-messages.mdx b/ui-kit/flutter/guide-threaded-messages.mdx index d63735c6d..b5ba44934 100644 --- a/ui-kit/flutter/guide-threaded-messages.mdx +++ b/ui-kit/flutter/guide-threaded-messages.mdx @@ -102,6 +102,31 @@ CometChatMessageComposer( +## Thread Subscriptions (Follow / Unfollow) + +Users can follow a thread to be notified about new replies, and unfollow to mute it. The UI Kit wires this end to end on top of the SDK's [Thread Subscriptions](/sdk/flutter/threaded-messages#thread-subscriptions) APIs — subscriptions also happen automatically when the user replies to a thread or is @-mentioned in one. + +Enable the feature through `UIKitSettings` when initializing the UI Kit: + + + +```dart +UIKitSettings uiKitSettings = (UIKitSettingsBuilder() + ..appId = "APP_ID" + ..region = "REGION" + ..authKey = "AUTH_KEY" + ..enableThreadSubscription = true +).build(); +``` + + + +With the gate on: + +- The message action menu offers **Subscribe to thread** / **Unsubscribe from thread** on threaded messages, with confirmation toasts ("Subscribed. You'll be notified about new replies in this thread."). +- The thread screen's header shows a **notification bell** reflecting the live subscription state — tapping it toggles the subscription. Pass the thread's root message as `parentMessage` to `CometChatMessageHeader` to render it; on `CometChatThreadedHeader`, the bell can be hidden with `threadSubscriptionVisibility: false` when the header above it already carries one. +- All copy is localized through the UI Kit's translations. + ## Customization Options - Header Styling: Customize `CometChatThreadedHeader` appearance diff --git a/ui-kit/flutter/message-composer.mdx b/ui-kit/flutter/message-composer.mdx index 9862351e8..5aaf28f85 100644 --- a/ui-kit/flutter/message-composer.mdx +++ b/ui-kit/flutter/message-composer.mdx @@ -459,6 +459,63 @@ CometChatMessageComposer( +### Trailing toolbar actions + +`richTextToolbarActions` appends your own buttons at the trailing end of the built-in toolbar — after the format buttons, separated by a divider — without replacing the toolbar the way `richTextToolbarView` does. It takes the same `ComposerActionsBuilder` shape as `attachmentOptions` and returns a list of `CometChatMessageComposerAction` items. + +For toolbar actions, set the action's `onToolbarTap` callback. It receives the `BuildContext` and the composer's active `TextEditingController`, so the action can read the current text and selection and mutate the draft — the composer stays the owner of the field. + + + +```dart +CometChatMessageComposer( + user: user, + richTextToolbarActions: (context, user, group, id) => [ + CometChatMessageComposerAction( + id: 'insert_greeting', + title: 'Greeting', + icon: const Icon(Icons.waving_hand_outlined), + onToolbarTap: (context, controller) { + final selection = controller.selection; + final offset = selection.isValid ? selection.start : controller.text.length; + controller.text = controller.text.replaceRange(offset, offset, 'Hello! '); + }, + ), + ], +) +``` + + + + + +On web, tapping a toolbar button can blur the text field and collapse the selection before your handler runs. When the controller is a `RichTextEditingController`, fall back to its `lastNonCollapsedSelection` — the most recent valid selection the field held — and bounds-check it against the current text. + + + +#### Leaving mentions alone + +A toolbar action that restyles a range will happily run over a mention, which should keep the styling the mentions formatter gives it. `RichTextEditingController.getMentionRanges()` returns the mention ranges currently tracked in the text, sorted by start offset, so an action can skip them. + +The ranges come from the attached `CometChatMentionsFormatter` — the same source the field styles mentions from — so they stay in step with the text as it is edited. The list is empty when no mentions formatter is attached, which is also what you get on a plain `TextEditingController`, so guard the cast. + + + +```dart +onToolbarTap: (context, controller) { + final mentions = controller is RichTextEditingController + ? controller.getMentionRanges() + : const []; + + bool overlapsMention(int start, int end) => + mentions.any((r) => start < r.end && end > r.start); + + // ...apply your styling only where overlapsMention() is false +}, +``` + + + *** ## Multiple Attachments diff --git a/ui-kit/flutter/message-header.mdx b/ui-kit/flutter/message-header.mdx index f723cfbb7..3c8ed43a7 100644 --- a/ui-kit/flutter/message-header.mdx +++ b/ui-kit/flutter/message-header.mdx @@ -145,6 +145,35 @@ The component listens to these SDK events internally. No manual setup needed. | `appBarOptions` | `List?` | `null` | Additional widgets in the app bar (e.g., call buttons, menu) | | `hideUserStatus` | `bool?` | `false` | Hide online/offline status for users | | `disableTypingIndicator` | `bool?` | `false` | Disable typing indicator display | +| `pinnedMessagesVisibility` | `bool?` | `true` | Show the pinned-messages entry in the ⋯ overflow menu | +| `onPinnedMessagesTap` | `VoidCallback?` | `null` | Handle the pinned-messages entry yourself instead of letting the Kit push the screen | +| `onPinnedMessageItemTap` | `Function(BaseMessage)?` | `null` | Forwarded to the pinned list — fires when a pinned row is tapped | +| `pinnedMessagesStyle` | `CometChatPinnedMessagesStyle?` | `null` | Styling for the pinned list the header opens | +| `parentMessage` | `BaseMessage?` | `null` | Puts the header in thread mode and renders the subscription bell | +| `threadSubscriptionVisibility` | `bool?` | `true` | Show the thread subscription bell (requires `parentMessage`) | + +--- + +### Pinned messages + +When `pinnedMessagesVisibility` is not `false`, the ⋯ overflow menu carries an entry that opens the conversation's [Pinned Messages](/ui-kit/flutter/pinned-messages). The entry additionally requires the server Pin feature flag (`CometChat.isPinMessageEnabled()`), and never renders in thread mode — that is, when `parentMessage` is set. + +By default the Kit pushes the screen itself. Set `onPinnedMessagesTap` to take over presentation — a desktop layout that shows the list in a side panel rather than a pushed route, say. When the Kit does present it, `onPinnedMessageItemTap` forwards row taps so you can jump the message list. + + + +```dart +CometChatMessageHeader( + group: group, + onPinnedMessageItemTap: (message) => _controller.jumpToMessage(message.id), +) +``` + + + +### Thread subscription + +Passing the thread's root message as `parentMessage` puts the header in thread mode and renders a notification bell reflecting the live subscription state. The bell is gated on `UIKitSettings.enableThreadSubscription`, which is `false` by default — see the [Threaded Messages guide](/ui-kit/flutter/guide-threaded-messages). --- diff --git a/ui-kit/flutter/message-list.mdx b/ui-kit/flutter/message-list.mdx index 449301f06..783494637 100644 --- a/ui-kit/flutter/message-list.mdx +++ b/ui-kit/flutter/message-list.mdx @@ -204,6 +204,8 @@ The component listens to SDK message events internally. No manual setup needed. | `onMessagesDelivered` / `onMessagesRead` | Updates receipt status via ValueNotifier | | `onTypingStarted` / `onTypingEnded` | Updates typing indicator | | `onMessageReactionAdded` / `onMessageReactionRemoved` | Updates reaction counts | +| `onMessagePinned` / `onMessageUnpinned` | Restamps the message's pin state in-place | +| `onMessageSaved` / `onMessageUnsaved` | Restamps the message's saved state (own devices only) | | Connection reconnected | Triggers silent sync to fetch missed messages | --- @@ -450,6 +452,11 @@ To stage and send multiple attachments, see [Message Composer](/ui-kit/flutter/m | `hideDeleteMessageOption` | `false` | Hide "Delete Message" | | `hideEditMessageOption` | `false` | Hide "Edit Message" | | `hideMessageInfoOption` | `false` | Hide "Message Info" | +| `hidePinMessageOption` | `false` | Hide "Pin" (shown while the message is unpinned) | +| `hideUnpinMessageOption` | `false` | Hide "Unpin" (shown while the message is pinned) | +| `hideSaveMessageOption` | `false` | Hide "Save" (shown while the message is unsaved) | +| `hideUnsaveMessageOption` | `false` | Hide "Unsave" (shown while the message is saved) | +| `hideThreadSubscriptionOption` | `false` | Hide "Subscribe/Unsubscribe to thread" (requires the feature gate, below) | | `hideMessagePrivatelyOption` | `false` | Hide "Message Privately" | | `hideReactionOption` | `false` | Hide "Reaction" | | `hideReplyInThreadOption` | `false` | Hide "Reply in Thread" | @@ -544,6 +551,59 @@ CometChatMessageList( +### Pin or save a message + +The action menu offers **Pin** / **Unpin** and **Save** / **Unsave** on every message, wired internally to the SDK's [Pin Messages](/sdk/flutter/pin-messages) and [Save Messages](/sdk/flutter/save-messages) APIs. No setup is needed: the component performs the call, shows a confirmation toast, updates the bubble through the SDK's listener fan-out (so pins made by other members — and saves made on your other devices — land too), and maps the cap errors (`ERR_PINNED_MESSAGES_LIMIT_EXCEEDED` / `ERR_SAVED_MESSAGES_LIMIT_EXCEEDED`) to localized error copy with the server's limit interpolated. + +Pin respects the conversation's permission model (group pinning requires admin/moderator/owner scope), and both options hide themselves when the feature is disabled for the logged-in user. + +To hide them regardless, note that each state has its own flag: the menu shows **Pin** or **Unpin** depending on the message, so hiding pinning entirely takes `hidePinMessageOption` *and* `hideUnpinMessageOption`. Save works the same way. + + + +```dart +CometChatMessageList( + user: user, + // both flags per feature — one per rendered state + hidePinMessageOption: true, + hideUnpinMessageOption: true, + hideSaveMessageOption: true, + hideUnsaveMessageOption: true, +) +``` + + + +### Jump to a message + +`goToMessageId` is read once when the list mounts, so it cannot re-aim a list that is already on screen — the case you hit when a pinned or saved row is tapped and the conversation is already open. + +`CometChatMessageListController` covers that. Create it in your `State`, pass it as `controller`, and call `jumpToMessage`: the list scrolls to the message and highlights it, fetching the page around it first when it is not loaded yet. + + + +```dart +final _controller = CometChatMessageListController(); + +CometChatMessageList( + user: user, + controller: _controller, +) + +// later — e.g. from CometChatPinnedMessages.onItemTap +await _controller.jumpToMessage(message.id); +``` + + + +It attaches on mount and detaches on dispose. Calls made while no list is attached return `false` rather than throwing, and `isAttached` reports the current state. + + + +A different class of the same name existed in v5, where it exposed list state. That one is replaced by `MessageListBloc` — see [Upgrading from v5](/ui-kit/flutter/upgrading-from-v5). The v6 `CometChatMessageListController` does only imperative jumps. + + + ### Mark a message as unread Expose the "Mark as Unread" option in the long-press menu: diff --git a/ui-kit/flutter/pinned-messages.mdx b/ui-kit/flutter/pinned-messages.mdx new file mode 100644 index 000000000..3d69453bf --- /dev/null +++ b/ui-kit/flutter/pinned-messages.mdx @@ -0,0 +1,96 @@ +--- +title: "Pinned Messages" +description: "A screen listing every pinned message in one conversation, with an unpin affordance and tap-to-jump." +--- + +`CometChatPinnedMessages` lists the pinned messages of a single conversation. Rows are read-only previews ordered newest-pinned-first; each carries an Unpin affordance (permission-gated) and reports taps through `onItemTap` so the host can jump its message list to the tapped message. + +--- + +## Where It Fits + +The screen opens on the **nearest** `Navigator`, the same navigation threads use. On a desktop side-by-side layout that replaces only the chat column; on mobile it is a full-screen route. + +The usual entry point is the pinned-messages item in `CometChatMessageHeader`'s ⋯ overflow menu, which pushes this screen for you. See [Message Header](/ui-kit/flutter/message-header) for the props that control it. + +--- + +## Quick Start + +Either call the `show` helper, which pushes the screen with the kit's own transition, or embed the widget directly. + + + +```dart +CometChatPinnedMessages.show( + context, + group: group, // or: user: user + onItemTap: (message) => _controller.jumpToMessage(message.id), +); +``` + + + +Exactly one of `user` or `group` must be passed — the constructor asserts on it. + +--- + +## Actions and Events + +### Callback Methods + +| Property | Description | +| --- | --- | +| `onItemTap` | Fires when a row is tapped, **after** the screen pops. Use it to jump the message list to that message. | + +Pair it with [`CometChatMessageListController.jumpToMessage`](/ui-kit/flutter/message-list) to land on the message without remounting the list. + +### Real-Time Updates (Automatic) + +The list stays current without a refetch. No setup needed. + +| Source | Internal behavior | +| --- | --- | +| `ccMessagePinned` / `ccMessageUnpinned` (UI Kit events) | Adds or drops the row for actions taken elsewhere in the app | +| `onMessagePinned` / `onMessageUnpinned` (SDK listener) | Reflects pins and unpins made by other members | + +--- + +## Functionality + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `user` | `User?` | - | 1:1 conversation scope. Mutually exclusive with `group`. | +| `group` | `Group?` | - | Group conversation scope. Mutually exclusive with `user`. | +| `onItemTap` | `Function(BaseMessage)?` | - | Fires on row tap, after the screen pops | +| `hideUnpinOption` | `bool?` | `false` | Hides the per-row unpin affordance even when the user has permission | +| `showBackButton` | `bool` | `true` | Toggles the header's close affordance. Turn it off when a host-owned panel supplies its own. | +| `hideAppBar` | `bool` | `false` | Drops the header entirely. Set it when the host already renders a title bar — a desktop side panel does, and two stacked headers is the result otherwise. | +| `style` | `CometChatPinnedMessagesStyle?` | - | Styling overrides | + +--- + +## Style + +`CometChatPinnedMessagesStyle` accepts `backgroundColor`, `appBarColor`, `titleTextStyle`, `itemTitleTextStyle`, `itemSubtitleTextStyle`, `itemDateTextStyle`, `iconColor`, `unpinIconColor`, `separatorColor` and `borderRadius`. + + + +```dart +CometChatPinnedMessages( + group: group, + style: CometChatPinnedMessagesStyle( + unpinIconColor: Colors.redAccent, + ), +) +``` + + + +--- + +## Next Steps + +- [Saved Messages](/ui-kit/flutter/saved-messages) +- [Message List](/ui-kit/flutter/message-list) +- [Pin Messages (SDK)](/sdk/flutter/pin-messages) diff --git a/ui-kit/flutter/saved-messages.mdx b/ui-kit/flutter/saved-messages.mdx new file mode 100644 index 000000000..246eb32bf --- /dev/null +++ b/ui-kit/flutter/saved-messages.mdx @@ -0,0 +1,92 @@ +--- +title: "Saved Messages" +description: "A screen listing the logged-in user's saved messages across every conversation." +--- + +`CometChatSavedMessages` lists the logged-in user's saved (bookmarked) messages across **every** conversation — unlike [Pinned Messages](/ui-kit/flutter/pinned-messages), it is not scoped to one chat. Rows are read-only previews ordered newest-saved-first, each with a conversation-context line and an Unsave affordance. + +Saves are private to the logged-in user and sync across their own devices. + +--- + +## Quick Start + + + +```dart +Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => CometChatSavedMessages( + onItemTap: (message) { + // open the message's conversation, then jump to it + }, + ), + ), +); +``` + + + +Because a saved message can belong to any conversation, `onItemTap` usually has to open the conversation first and then jump — see [`jumpToMessage`](/ui-kit/flutter/message-list). + +--- + +## Actions and Events + +### Callback Methods + +| Property | Description | +| --- | --- | +| `onItemTap` | Fires when a row is tapped. Use it to open the message's conversation and jump to it. | + +### Real-Time Updates (Automatic) + +| Source | Internal behavior | +| --- | --- | +| `ccMessageSaved` / `ccMessageUnsaved` (UI Kit events) | Adds or drops the row for actions taken elsewhere in the app | +| `onMessageSaved` / `onMessageUnsaved` (SDK listener) | Reflects saves made on the user's other devices | + +--- + +## Functionality + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `onItemTap` | `Function(BaseMessage)?` | - | Fires on row tap | +| `hideUnsaveOption` | `bool?` | `false` | Hides the per-row unsave affordance | +| `popOnItemTap` | `bool` | `true` | Pops this screen **before** `onItemTap` fires. Right for a pushed mobile route; set `false` when the list lives in a persistent panel that should survive the jump. | +| `showBackButton` | `bool` | `true` | Shows the app-bar back button | +| `useCloseButton` | `bool` | `false` | Swaps the leading back arrow for a trailing ✕ with the title left-aligned — the chrome a desktop panel uses, where the screen is dismissed rather than navigated back from. Only read when `showBackButton` is `true`. | +| `style` | `CometChatSavedMessagesStyle?` | - | Styling overrides | + +### Responsive presentation + +`popOnItemTap` and `useCloseButton` exist to let one component serve both layouts: a pushed mobile route that pops on jump, and a desktop panel that stays put and closes with a ✕. + + + +```dart +CometChatSavedMessages( + onItemTap: _openSavedMessage, + popOnItemTap: !isDesktop, + useCloseButton: isDesktop, +) +``` + + + +--- + +## Style + +`CometChatSavedMessagesStyle` accepts `backgroundColor`, `appBarColor`, `titleTextStyle`, `itemTitleTextStyle`, `itemSubtitleTextStyle`, `itemContextTextStyle`, `itemDateTextStyle`, `iconColor`, `unsaveIconColor` and `separatorColor`. + +`itemContextTextStyle` styles the conversation-context line, which has no counterpart in the pinned list. + +--- + +## Next Steps + +- [Pinned Messages](/ui-kit/flutter/pinned-messages) +- [Message List](/ui-kit/flutter/message-list) +- [Save Messages (SDK)](/sdk/flutter/save-messages) diff --git a/ui-kit/flutter/threaded-messages-header.mdx b/ui-kit/flutter/threaded-messages-header.mdx index 0d0c357b0..d616c63f6 100644 --- a/ui-kit/flutter/threaded-messages-header.mdx +++ b/ui-kit/flutter/threaded-messages-header.mdx @@ -90,6 +90,26 @@ Prerequisites: CometChat SDK initialized, a user logged in, and a valid `BaseMes ### Callback Methods +#### `onThreadSubscriptionChange` + +Fires after the follow/unfollow toggle **succeeds**, with the parent message id and the new subscribed state. Use it to keep your own thread list in step — an unfollow also hard-deletes the thread's row server-side, so drop it locally. + +The bell renders only when `UIKitSettings.enableThreadSubscription` is `true`; it is `false` by default. Set `threadSubscriptionVisibility: false` to hide it when the header above already carries one. + + + +```dart +CometChatThreadedHeader( + parentMessage: parentMessage, + loggedInUser: loggedInUser, + onThreadSubscriptionChange: (parentMessageId, subscribed) { + if (!subscribed) _removeThreadRow(parentMessageId); + }, +) +``` + + + #### `onBack` Fires when the user presses the back button. @@ -145,6 +165,8 @@ CometChatThreadedHeader( | `showBackButton` | `bool?` | `true` | Toggle back button visibility | | `title` | `String?` | `null` | Custom title text | | `hideMessageComposer` | `bool?` | `false` | Hide the message composer | +| `threadSubscriptionVisibility` | `bool?` | `true` | Show the follow/unfollow bell. Requires `UIKitSettings.enableThreadSubscription`. | +| `onThreadSubscriptionChange` | `Function(int, bool)?` | `null` | Called after a successful toggle with the parent message id and the new state | ---