TeamChatChannel
Team chat channel feed for React Native with an inverted list, day separators, an unread marker, a keyboard-docked composer with @ mentions, emoji, attachments and entity links, long-press reactions and thread sheets.
Installation
pnpm dlx @docyrus/cli add @docyrus/rn-team-chat-channelpnpm add @shopify/flash-list expo-document-picker (optional) expo-image-picker (optional)The attach button offers Files when expo-document-picker is installed and Photo library / Camera when expo-image-picker is installed. With neither, the button is hidden. Everything else is pure JS and runs in Expo Go.
Usage
import { TeamChatChannel } from '@/components/docyrus-native/team-chat-channel';
<TeamChatChannel
channelName="# product-team"
posts={posts}
users={users}
currentUser={currentUser}
lastReadAt={membership.last_read_at}
isCreatePending={createPost.isPending}
isDeletePending={deletePost.isPending}
onCreatePost={payload => createPost.mutateAsync(payload)}
onUpdatePost={(id, content, options) => updatePost.mutateAsync({ id, content, ...options })}
onDeletePost={id => deletePost.mutateAsync(id)}
onToggleReaction={(id, emoji) => toggleReaction.mutateAsync({ id, emoji })}
onLoadReplies={id => fetchReplies(id)}
dataSources={dataSources}
onSearchEntity={(dataSourceId, query) => searchRecords(dataSourceId, query)}
onLoadMore={fetchOlderPosts}
hasMore={hasOlderPosts}
isLoadingMore={isFetchingOlder} />Without maxHeight the channel is flex-1 and fills its parent — give the parent a bounded height (a screen or a sized View) and do not nest it in a vertical ScrollView. When the channel sits below a navigation header, pass that header's height as keyboardVerticalOffset so the composer clears the keyboard.
Feed
- The feed is an inverted FlashList: the newest post is anchored at the bottom, like every mobile chat. Only top-level posts (
parent_id === null) are listed. - Day separators (
Today,Yesterday, then the date — throughDateFormatProviderwhen mounted) split the feed. lastReadAtinserts a "New messages (n)" divider above the oldest post by someone else that is newer than that timestamp.onLoadMorefires when the user scrolls to the top whilehasMoreistrue;isLoadingMoreshows a spinner there.
Composer
The composer is docked at the bottom inside a KeyboardAvoidingView:
@mentions — typing@opens a user list above the input. The input shows@Name; on submit the draft becomes the stored[@Name](mention://<userId>)markdown (the format web renders) andmentioned_user_idsis filled. The @ button inserts the trigger.#hashtagsare collected intohashtags.- Emoji — the smiley opens the
EmojiPickersheet and inserts at the caret. - Attachments — the paperclip picks files (
ChatFile={ uri, name, type, size? }) that arrive inCreatePostPayload.attachments. - Entity links — with
dataSources+onSearchEntity, the link button opens a two-step sheet (data source → debounced record search). Picked records arrive inlinked_entities.
Web fills neither mentioned_user_ids nor hashtags; native does.
Reactions, actions and editing
- Reaction chips toggle on tap. The
+/ smiley buttons open the reaction sheet: the preset emoji row plus More for the fullEmojiPicker. - Long-press a post (or tap "⋯" on your own post) for the action sheet: reactions, Reply, Edit post, Delete post.
- Edit turns the bottom composer into the edit bar: the text is loaded (mentions included), existing attachments can be removed and new files added. Save calls
onUpdatePost(id, content, { removedAttachmentIds, newFiles }). - Delete asks for confirmation, then calls
onDeletePost(id).
Threads
Tapping "N replies" opens the thread in a full-height sheet: the root post, the replies from onLoadReplies(postId) (oldest first) and a reply composer. Reply opens the same sheet with the composer focused. Replies can be reacted to, edited and deleted inside the sheet; replies are reloaded after each change. Reply payloads carry parent_id = postId.
API Reference
TeamChatChannelProps
| Prop | Type | Default | Description |
|---|---|---|---|
posts | ChatPost[] | - | Required. Posts of the channel (top level; replies are loaded per thread) |
currentUser | ChatUser | - | Signed-in user. Own posts get Edit / Delete |
users | ChatUser[] | - | Users for names, avatars and @ mentions |
channelName | string | - | Header title with the post count. The header is hidden when omitted |
isLoading | boolean | false | Show the loading skeleton |
isCreatePending | boolean | false | Pending state of the composers |
isDeletePending | boolean | false | Pending state of the delete confirmation |
onCreatePost | (payload: CreatePostPayload) => void | Promise<void> | - | Create a post or a reply. The composer is shown only when set |
onUpdatePost | (postId: string, content: string, options?: UpdatePostOptions) => void | Promise<void> | - | Save an edited post. Enables Edit |
onDeletePost | (postId: string) => void | Promise<void> | - | Delete a post after confirmation. Enables Delete |
onToggleReaction | (postId: string, emoji: string) => void | Promise<void> | - | Toggle a reaction. Enables the reaction chips and sheet |
onUploadFile | (file: ChatFile) => Promise<PostAttachment> | - | Accepted for API parity. Picked files arrive through onCreatePost / onUpdatePost |
onFetchLinkPreview | (url: string) => Promise<LinkPreview | null> | - | Accepted for API parity (unused, as on web). Render ChatPost.link_previews instead |
dataSources | DataSourceOption[] | - | Data sources offered by the composer's link button |
onSearchEntity | (dataSourceId: string, query: string) => Promise<EntitySearchResult[]> | - | Record search for entity linking (300 ms debounce) |
onLoadReplies | (postId: string) => Promise<ChatPost[]> | - | Load a thread's replies when its sheet opens |
maxHeight | number | - | Fixed height in dp. Otherwise the channel is flex-1 |
className | string | - | Container classes |
lastReadAt | string | null | - | Native. ISO timestamp of the last read — newer posts by others get a "New messages" divider |
onOpenAttachment | (attachment: PostAttachment) => void | Linking.openURL(signed_url) | Native. Open an attachment |
onOpenEntity | (entity: LinkedEntity) => void | - | Native. Tap on a linked-entity chip |
onMentionPress | (userId: string) => void | - | Native. Tap on a mention chip in a post |
onLoadMore | () => void | Promise<void> | - | Native. Load older posts when the user reaches the top |
hasMore | boolean | false | Native. Older posts exist (gates onLoadMore) |
isLoadingMore | boolean | false | Native. Spinner at the top while older posts load |
enableMentions | boolean | true | Native. @ mentions in the composer (needs users) |
enableEmoji | boolean | true | Native. Emoji button in the composer |
enableAttachments | boolean | true | Native. Attach button in the composer |
keyboardVerticalOffset | number | 0 | Native. KeyboardAvoidingView offset (height of headers above the channel) |
ChatPost
| Field | Type | Description |
|---|---|---|
id | string | Post id |
content | string | Markdown body ([@Name](mention://<id>) tokens render as chips) |
attachments | PostAttachment[] | null | Files — images render as thumbnails, others as chips |
reactions | PostReaction[] | Reaction chips |
link_previews | LinkPreview[] | Link preview cards |
linked_entities | LinkedEntity[] | Linked record chips |
hashtags | string[] | Hashtags |
mentioned_user_ids | string[] | Mentioned users |
reply_count | number | Replies count ("N replies" link) |
created_on | string | ISO timestamp |
created_by | string | Author id (resolved against users) |
last_modified_on | string | Shows "(edited)" when it differs from created_on |
parent_id | string | null | null for top-level posts |
Other types
| Type | Fields |
|---|---|
ChatUser | id, firstname?, lastname?, avatar_url? |
ChatFile | uri, name, type, size? (same as NativeFile) |
CreatePostPayload | content, parent_id?, attachments?: ChatFile[], linked_entities?, hashtags?, mentioned_user_ids? |
UpdatePostOptions | removedAttachmentIds?: string[], newFiles?: ChatFile[] |
PostAttachment | id, file_name, file_type, file_size, signed_url |
PostReaction | emoji, user_ids, count |
LinkPreview | url, title?, description?, image_url?, site_name? |
LinkedEntity | id, data_source_id, data_source_name, record_id, display_value, icon? |
EntitySearchResult | record_id, display_value, icon? |
DataSourceOption | id, name, icon? |
PresetEmoji | '👍' | '❤️' | '😂' | '😮' | '🎉' | '🔥' | '👀' | '💡' |
Components
| Component | Description |
|---|---|
TeamChatChannel | The channel: header, feed, composer, thread sheet, delete confirmation |
Internal building blocks (PostCard, PostComposer, PostThread, TeamChatProvider, …) are importable from their files — ContactActivityPanel uses PostCard + PostComposer for its reply thread, exactly like web.
Exports
| Export | Description |
|---|---|
TeamChatChannel | Component |
PRESET_EMOJIS | The eight preset reaction emoji |
Type Exports
| Type | Description |
|---|---|
TeamChatChannelProps | Props of TeamChatChannel |
ChatUser, ChatPost, ChatFile, PostAttachment, PostReaction, LinkPreview, LinkedEntity, EntitySearchResult, DataSourceOption | Data types |
CreatePostPayload, UpdatePostOptions | Callback payloads |
PresetEmoji | Preset reaction emoji union |
Translation Keys
| Key | Fallback |
|---|---|
ui.teamChat.writePost | Write a post... |
ui.teamChat.edited | edited |
ui.teamChat.editPost | Edit post |
ui.teamChat.deletePost | Delete post |
ui.teamChat.reply | Reply |
ui.teamChat.replies | replies |
ui.teamChat.react | React |
ui.teamChat.noPostsYet | No posts yet |
ui.teamChat.startConversation | Start a conversation |
ui.teamChat.selectDataSource | Select data source |
ui.teamChat.searchEntity | Search entity... |
ui.teamChat.searching | Searching... |
ui.teamChat.noResults | No results |
ui.teamChat.send | Send (native) |
ui.teamChat.mention | Mention someone (native) |
ui.teamChat.linkEntity | Link a record (native) |
ui.teamChat.moreReactions | More reactions (native) |
ui.teamChat.moreActions | More actions (native) |
ui.teamChat.longPressHint | Long-press for reactions and actions (native) |
ui.teamChat.thread | Thread (native) |
ui.teamChat.loadingReplies | Loading replies… (native) |
ui.teamChat.today | Today (native) |
ui.teamChat.yesterday | Yesterday (native) |
ui.teamChat.newMessages | New messages (native) |
Shared keys: ui.common.cancel, ui.common.save, ui.common.back, ui.emailComposer.insertEmoji, ui.commentsPanel.attachFile, ui.commentsPanel.sourceFiles, ui.commentsPanel.sourcePhotos, ui.commentsPanel.sourceCamera, ui.commentsPanel.removeFile.
Differences from web
File→ChatFile({ uri, name, type, size? }) inCreatePostPayload.attachments,UpdatePostOptions.newFilesandonUploadFile.maxHeightis a number.- Newest post at the bottom (inverted list) and the composer below the feed; web lists newest first under a top composer.
- Threads open in a sheet instead of expanding inline; editing happens in the bottom composer instead of inside the card.
- Hover toolbars / dropdowns / popovers are replaced by long-press and bottom sheets.
- Native-only:
lastReadAt, day separators,onLoadMorepaging,onOpenAttachment,onOpenEntity,onMentionPress, theenable*flags,keyboardVerticalOffset, and filledmentioned_user_ids/hashtags.