Skip to content

Community and Chat

import { useState } from "react";
import { createSnapshot } from "@lastshotlabs/snapshot";
import {
ChatWindowBase,
MessageThreadBase,
TypingIndicatorBase,
InputField,
ButtonBase,
} from "@lastshotlabs/snapshot/ui";
const snap = createSnapshot({ apiUrl: "/api" });
function ChatRoom({ containerId }: { containerId: string }) {
const { data } = snap.useContainerThreads({ containerId });
const thread = data?.items?.[0];
const { data: replies } = snap.useThreadReplies({
threadId: thread?.id ?? "",
});
const { mutate: createReply, isPending } = snap.useCreateReply();
const [message, setMessage] = useState("");
return (
<ChatWindowBase
title="Chat"
threadSlot={
<MessageThreadBase
messages={replies?.items ?? []}
contentField="body"
authorNameField="author.name"
authorAvatarField="author.avatar"
timestampField="createdAt"
showTimestamps
groupByDate
/>
}
typingSlot={<TypingIndicatorBase users={[]} />}
inputSlot={
<form
onSubmit={(e) => {
e.preventDefault();
if (!thread || !message.trim()) return;
createReply({ threadId: thread.id, body: message });
setMessage("");
}}
style={{ display: "flex", gap: "0.5rem" }}
>
<InputField
label=""
value={message}
onChange={setMessage}
placeholder="Type a message..."
/>
<ButtonBase
label="Send"
type="submit"
disabled={isPending || !message.trim()}
/>
</form>
}
/>
);
}

Snapshot provides 49 community hooks and 7 communication components. The hooks handle CRUD for containers, threads, replies, reactions, moderation, and notifications. The components render chat windows, message threads, comment sections, typing indicators, presence, reactions, and emoji pickers.

Containers are top-level groupings (forums, channels, rooms). Threads live inside containers. Replies live inside threads.

function ForumChannel({ containerId }: { containerId: string }) {
const { data, isLoading } = snap.useContainerThreads({ containerId });
const { mutate: createThread, isPending } = snap.useCreateThread();
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
if (isLoading) return <p>Loading threads...</p>;
return (
<div>
<h2>Threads</h2>
<ul>
{data?.items.map((thread) => (
<li key={thread.id}>
<strong>{thread.title}</strong>{thread.replyCount} replies
{thread.isPinned && " (pinned)"}
{thread.isLocked && " (locked)"}
</li>
))}
</ul>
<form
onSubmit={(e) => {
e.preventDefault();
createThread({ containerId, title, body });
setTitle("");
setBody("");
}}
>
<InputField label="Title" value={title} onChange={setTitle} />
<InputField label="Body" value={body} onChange={setBody} />
<ButtonBase label="Post Thread" type="submit" disabled={isPending} />
</form>
</div>
);
}
function ThreadDetail({ threadId }: { threadId: string }) {
const { data: thread } = snap.useContainerThread(threadId);
const { data: replies, isLoading } = snap.useThreadReplies({ threadId });
const { mutate: createReply, isPending } = snap.useCreateReply();
const [body, setBody] = useState("");
return (
<div>
<h2>{thread?.title}</h2>
<p>{thread?.body}</p>
<CommentSectionBase
comments={replies?.items ?? []}
loading={isLoading}
contentField="body"
authorNameField="author.name"
authorAvatarField="author.avatar"
timestampField="createdAt"
sortOrder="oldest"
emptyText="No replies yet — be the first!"
inputSlot={
<form
onSubmit={(e) => {
e.preventDefault();
if (!body.trim()) return;
createReply({ threadId, body });
setBody("");
}}
style={{ display: "flex", gap: "0.5rem" }}
>
<InputField
label=""
value={body}
onChange={setBody}
placeholder="Write a reply..."
/>
<ButtonBase label="Reply" type="submit" disabled={isPending} />
</form>
}
/>
</div>
);
}
const { data } = snap.useContainers();
const { mutate: create } = snap.useCreateContainer();
const { mutate: update } = snap.useUpdateContainer();
const { mutate: remove } = snap.useDeleteContainer();
create({ slug: "general", name: "General", description: "General discussion" });
update({ containerId: "abc", name: "Renamed" });
remove({ containerId: "abc" });
const { mutate: publish } = snap.usePublishThread();
const { mutate: lock } = snap.useLockThread();
const { mutate: pin } = snap.usePinThread();
const { mutate: unpin } = snap.useUnpinThread();
const { mutate: deleteThread } = snap.useDeleteThread();
import { ReactionBarBase } from "@lastshotlabs/snapshot/ui";
function ThreadReactions({
threadId,
containerId,
}: {
threadId: string;
containerId: string;
}) {
const { data: reactions } = snap.useThreadReactions(threadId);
const { mutate: addReaction } = snap.useAddThreadReaction();
const { mutate: removeReaction } = snap.useRemoveThreadReaction();
// Build reaction counts from raw emoji list
const counts = new Map<string, { count: number; active: boolean }>();
reactions?.forEach((r) => {
const existing = counts.get(r.emoji);
if (existing) existing.count++;
else counts.set(r.emoji, { count: 1, active: false });
});
return (
<ReactionBarBase
reactions={[...counts.entries()].map(([emoji, { count, active }]) => ({
emoji,
count,
active,
}))}
showAddButton
onReactionClick={(emoji, wasActive) => {
if (wasActive) removeReaction({ threadId, containerId, emoji });
else addReaction({ threadId, containerId, emoji });
}}
onEmojiSelect={({ emoji }) =>
addReaction({ threadId, containerId, emoji })
}
/>
);
}

Reply reactions work the same way with useReplyReactions, useAddReplyReaction, and useRemoveReplyReaction:

const { data: replyReactions } = snap.useReplyReactions(replyId);
const { mutate: addReplyReaction } = snap.useAddReplyReaction();
const { mutate: removeReplyReaction } = snap.useRemoveReplyReaction();

Container for chat UI with slots for thread, input, and typing indicator:

<ChatWindowBase
title="Support Chat"
subtitle="3 members online"
height="500px"
showHeader
showTypingIndicator
threadSlot={<MessageThreadBase messages={messages} />}
inputSlot={<ChatInput />}
typingSlot={<TypingIndicatorBase users={typingUsers} />}
/>
PropTypeDefaultDescription
titlestringHeader title
subtitlestringHeader subtitle
heightstring"clamp(300px, 70vh, 500px)"Chat window height
showHeaderbooleantrueShow header bar
threadSlotReactNoderequiredMessage thread content
inputSlotReactNoderequiredInput area content
typingSlotReactNodeTyping indicator content
showTypingIndicatorbooleantrueShow typing area

Scrollable message list with avatars, date separators, auto-scroll, and consecutive-message grouping:

<MessageThreadBase
messages={[
{
id: "1",
author: { name: "Alice", avatar: "/alice.jpg" },
content: "Hello!",
timestamp: "2026-01-15T10:00:00Z",
},
{
id: "2",
author: { name: "Bob" },
content: "Hi there!",
timestamp: "2026-01-15T10:01:00Z",
},
]}
showTimestamps
groupByDate
onMessageClick={(msg) => openThread(msg)}
/>
PropTypeDefaultDescription
messagesRecord<string, unknown>[][]Message records
contentFieldstring"content"Field name for message body
authorNameFieldstring"author.name"Field name for author name
authorAvatarFieldstring"author.avatar"Field name for avatar URL
timestampFieldstring"timestamp"Field name for timestamp
showTimestampsbooleantrueShow timestamps
groupByDatebooleantrueGroup messages by date
loadingbooleanfalseShow skeleton state
errorReactNodeError message
emptyTextstring"No messages yet"Empty state text
maxHeightstringScrollable area max height
onMessageClick(msg) => voidMessage click handler

Comment list with avatars, timestamps, delete actions, and an input slot:

import { CommentSectionBase } from "@lastshotlabs/snapshot/ui";
<CommentSectionBase
comments={[
{
id: "1",
author: { name: "Alice" },
content: "Great work!",
timestamp: "2026-01-15T10:00:00Z",
},
]}
sortOrder="newest"
showDelete
onDelete={(comment) => deleteReply({ replyId: comment.id as string })}
inputSlot={<ReplyInput />}
/>;
PropTypeDefaultDescription
commentsRecord<string, unknown>[][]Comment records
sortOrder"newest" | "oldest""newest"Sort direction
showDeletebooleanfalseShow delete button
onDelete(comment) => voidDelete handler
inputSlotReactNodeInput area at bottom
loadingbooleanfalseShow skeleton state
errorReactNodeError message
emptyTextstring"No comments yet"Empty state text

Shows animated dots with user names. Pass an empty array to hide automatically:

import { TypingIndicatorBase } from "@lastshotlabs/snapshot/ui";
<TypingIndicatorBase
users={[{ name: "Alice", avatar: "/alice.jpg" }, { name: "Bob" }]}
maxDisplay={3}
/>;
// Renders: "Alice and Bob are typing" with bouncing dots

Shows user status with a colored dot and label. Supports "online", "offline", "away", "busy", and "dnd":

import { PresenceIndicatorBase } from "@lastshotlabs/snapshot/ui";
<PresenceIndicatorBase
status="online"
label="Alice"
size="md"
showDot
showLabel
/>;
function MemberList({ containerId }: { containerId: string }) {
const { data: members } = snap.useContainerMembers(containerId);
return (
<ul>
{members?.map((member) => (
<li
key={member.id}
style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}
>
<PresenceIndicatorBase
status={member.isOnline ? "online" : "offline"}
label={member.name}
showDot
showLabel
size="sm"
/>
</li>
))}
</ul>
);
}

Row of emoji reaction pills with counts. The active flag highlights reactions the current user has applied:

import { ReactionBarBase } from "@lastshotlabs/snapshot/ui";
<ReactionBarBase
reactions={[
{ emoji: "👍", count: 3, active: true },
{ emoji: "❤️", count: 1, active: false },
]}
showAddButton
onReactionClick={(emoji, wasActive) => toggleReaction(emoji, wasActive)}
onEmojiSelect={({ emoji }) => addReaction(emoji)}
/>;

Full emoji picker panel for standalone use:

import { EmojiPickerBase } from "@lastshotlabs/snapshot/ui";
<EmojiPickerBase
perRow={8}
maxHeight="300px"
onSelect={({ emoji, name }) => insertEmoji(emoji)}
/>;

Connect the picker’s shortcode output to RichInputBase through its existing imperative handle. resolveEmoji turns known shortcodes into inline atoms in the editor; send payloads still contain :shortcode: in both text and markdown:

import {
EmojiPickerBase,
RichInputBase,
buildEmojiMap,
type CustomEmoji,
type RichInputBaseHandle,
} from "@lastshotlabs/snapshot/ui";
import { useCallback, useMemo, useRef } from "react";
function Composer({ customEmojis }: { customEmojis: CustomEmoji[] }) {
const editorRef = useRef<RichInputBaseHandle>(null);
const emojiByShortcode = useMemo(
() => buildEmojiMap(customEmojis),
[customEmojis],
);
const resolveEmoji = useCallback(
(shortcode: string) => {
const emoji = emojiByShortcode.get(shortcode);
return emoji ? { src: emoji.url, name: emoji.name } : null;
},
[emojiByShortcode],
);
return (
<>
<RichInputBase
ref={editorRef}
emitMarkdown
resolveEmoji={resolveEmoji}
onSend={({ markdown }) => sendMessage(markdown ?? "")}
/>
<EmojiPickerBase
customEmojis={customEmojis}
onSelect={({ emoji }) => editorRef.current?.insertText(emoji)}
/>
</>
);
}

The resolver may return trusted data:image/ URLs for application-owned inline SVG packs. Unknown shortcodes and shortcodes inside inline or block code remain literal text.

resolveEmoji covers one shape: a :shortcode: becoming a line-sized image. It does not cover the other shape a composer needs — content the author placed in the body that is too big, too structured or too remote to draw inline. A GIF, an uploaded image, a quoted post, a link embed.

Applications store those as a token of their own. Without a seam, that token is what the AUTHOR sees mid-sentence while typing:

Look at this %%media:Good Morning GIF|https://cdn.example/a.gif%% then this

tokenPattern + resolveToken render each match as a compact chip instead:

import { RichInputBase } from "@lastshotlabs/snapshot/ui";
const MEDIA_TOKEN = /%%media:[^|]*?\|https?:\/\/.+?%%/g;
function Composer() {
return (
<RichInputBase
emitMarkdown
tokenPattern={MEDIA_TOKEN}
resolveToken={(raw) => {
const match = /%%media:([^|]*?)\|(https?:\/\/.+?)%%/.exec(raw);
if (!match) return null; // leave it as plain text
return { label: match[1] || "Media", icon: "🖼", kind: "media" };
}}
onSend={({ markdown }) => post(markdown ?? "")}
/>
);
}

The document keeps your token, not the chip. renderText and the markdown serializer both emit the original raw match, so send payloads are byte-for-byte what you would have stored without this feature. A chip that serialized to its label would destroy the URL inside it.

Four things worth knowing:

  • Return null to decline. An unresolved match stays plain text, so a malformed or untrusted token is never laundered into a chip that looks legitimate.
  • tokenPattern must be global. It is used with matchAll.
  • Tokens inside inline or block code stay literal, like shortcodes — someone documenting your token format is showing source, not placing media.
  • Chips are selectable. A chip stands for a whole piece of content, so selecting it before deleting is the expected gesture.

Style them with the .sn-rich-input-token class; data-kind carries whatever kind your resolver returned, so different token types can look different without this component knowing what any of them mean.

Users can report content. Moderators review, resolve, or dismiss:

function ModerationPanel() {
const { data: reports, isLoading } = snap.useReports();
const { mutate: resolve } = snap.useResolveReport();
const { mutate: dismiss } = snap.useDismissReport();
if (isLoading) return <p>Loading reports...</p>;
return (
<div>
<h2>Open Reports</h2>
{reports?.items.map((report) => (
<CardBase key={report.id} title={`Report: ${report.targetType}`}>
<p>
<strong>Reason:</strong> {report.reason}
</p>
<p>
<strong>Status:</strong> {report.status}
</p>
<div style={{ display: "flex", gap: "0.5rem" }}>
<ButtonBase
label="Resolve"
onClick={() => resolve({ reportId: report.id, action: "warn" })}
/>
<ButtonBase
label="Dismiss"
variant="secondary"
onClick={() => dismiss({ reportId: report.id })}
/>
</div>
</CardBase>
))}
</div>
);
}
function BanManager({ userId }: { userId: string }) {
const { data: banCheck } = snap.useCheckBan(userId);
const { mutate: ban } = snap.useCreateBan();
const { mutate: unban } = snap.useRemoveBan();
if (banCheck?.banned) {
return (
<div>
<p>User is banned. Reason: {banCheck.ban?.reason}</p>
<ButtonBase
label="Unban"
onClick={() => unban({ banId: banCheck.ban!.id, userId })}
/>
</div>
);
}
return (
<ButtonBase
label="Ban User"
variant="destructive"
onClick={() => ban({ userId, reason: "Repeated violations" })}
/>
);
}
const { data: members } = snap.useContainerMembers(containerId);
const { data: moderators } = snap.useContainerModerators(containerId);
const { data: owners } = snap.useContainerOwners(containerId);
const { mutate: addMember } = snap.useAddMember();
const { mutate: removeMember } = snap.useRemoveMember();
const { mutate: assignMod } = snap.useAssignModerator();
const { mutate: removeMod } = snap.useRemoveModerator();
const { mutate: assignOwner } = snap.useAssignOwner();
const { mutate: removeOwner } = snap.useRemoveOwner();
function NotificationBell() {
const { data: unread } = snap.useNotificationsUnreadCount();
const { data: notifications } = snap.useNotifications();
const { mutate: markRead } = snap.useMarkNotificationRead();
const { mutate: markAllRead } = snap.useMarkAllNotificationsRead();
return (
<div>
<ButtonBase
label={`Notifications ${unread ? `(${unread})` : ""}`}
onClick={() => markAllRead()}
/>
<ul>
{notifications?.items.map((n) => (
<li
key={n.id}
style={{ opacity: n.read ? 0.6 : 1 }}
onClick={() => markRead({ notificationId: n.id })}
>
{n.type}: {String(n.payload)}
</li>
))}
</ul>
</div>
);
}
function ThreadSearch({ containerId }: { containerId: string }) {
const [query, setQuery] = useState("");
const { data: results, isLoading } = snap.useSearchThreads({
q: query,
containerId,
limit: 20,
});
return (
<div>
<InputField
label="Search"
value={query}
onChange={setQuery}
placeholder="Search threads..."
/>
{isLoading && <p>Searching...</p>}
{results?.threads?.items.map((thread) => (
<div key={thread.id}>
<strong>{thread.title}</strong>{thread.replyCount} replies
</div>
))}
</div>
);
}
DomainHooks
ContainersuseContainers, useContainer, useCreateContainer, useUpdateContainer, useDeleteContainer
ThreadsuseContainerThreads, useContainerThread, useCreateThread, useUpdateThread, useDeleteThread, usePublishThread, useLockThread, usePinThread, useUnpinThread
RepliesuseThreadReplies, useReply, useCreateReply, useUpdateReply, useDeleteReply
ReactionsuseThreadReactions, useAddThreadReaction, useRemoveThreadReaction, useReplyReactions, useAddReplyReaction, useRemoveReplyReaction
ModerationuseContainerMembers, useContainerModerators, useContainerOwners, useAddMember, useRemoveMember, useAssignModerator, useRemoveModerator, useAssignOwner, useRemoveOwner
ReportsuseReports, useReport, useCreateReport, useResolveReport, useDismissReport
BansuseBans, useCheckBan, useCreateBan, useRemoveBan
NotificationsuseNotifications, useNotificationsUnreadCount, useMarkNotificationRead, useMarkAllNotificationsRead
SearchuseSearchThreads, useSearchReplies