feat: chat reactions

This commit is contained in:
gosangam
2026-08-30 01:51:52 +05:30
parent 0f382ed885
commit 8c7afe596d
19 changed files with 662 additions and 53 deletions
+51
View File
@@ -119,6 +119,8 @@ export type ChatMessage = {
reply_to?: ReplyPreview | null;
/** Always an array — media, link cards and shared entities. */
attachments: Attachment[];
/** Emoji chips, grouped with counts. `mine` marks the viewer's own. */
reactions: Reaction[];
/**
* Client-only. Set on a message shown optimistically before the server has
* acknowledged it; never sent by the BFF.
@@ -171,6 +173,21 @@ export type AttachmentKind =
| "entity"
| "quote";
/** One emoji on a message, grouped across everyone who chose it. */
export type Reaction = {
emoji: string;
count: number;
/** The viewer's own choice — highlight the chip, and tapping it removes. */
mine: boolean;
};
/** One person's reaction, for the "who reacted" list. */
export type ReactionDetail = {
user: ChatPeer;
emoji: string;
created_at: string;
};
export type Attachment = {
id: string;
message_id: string;
@@ -411,6 +428,40 @@ export const chat = {
body: JSON.stringify({ seq }),
}),
/** One hydrated message — reaction counts are per viewer, so a client told
* "message X changed" has to ask rather than compute. */
message: (conversationId: string, messageId: string) =>
call<ChatMessage>(
`/chat/conversations/${conversationId}/messages/${messageId}`,
),
/** Tell the thread someone is composing. Ephemeral — nothing is stored. */
typing: (conversationId: string) =>
call<void>(`/chat/conversations/${conversationId}/typing`, {
method: "POST",
}),
/** Who reacted, and with what. Only fetched when the list is opened —
* the thread renders from the grouped counts on each message. */
reactionDetails: (conversationId: string, messageId: string) =>
call<ReactionDetail[]>(
`/chat/conversations/${conversationId}/messages/${messageId}/reactions`,
),
/** Set the caller's reaction, replacing any previous one. */
react: (conversationId: string, messageId: string, emoji: string) =>
call<void>(
`/chat/conversations/${conversationId}/messages/${messageId}/reaction`,
{ method: "PUT", body: JSON.stringify({ emoji }) },
),
/** Remove the caller's reaction. Idempotent. */
unreact: (conversationId: string, messageId: string) =>
call<void>(
`/chat/conversations/${conversationId}/messages/${messageId}/reaction`,
{ method: "DELETE" },
),
markDelivered: (conversationId: string, seq: number) =>
call<void>(`/chat/conversations/${conversationId}/delivered`, {
method: "POST",
+28
View File
@@ -0,0 +1,28 @@
"use client";
/**
* Whether the app's own chrome should step aside for the page.
*
* A chat thread is a full-height surface with its own header and a composer
* pinned to the bottom: the tab bar competes with the composer for the same
* edge, the hamburger duplicates a back button already on screen, and the
* wordmark header stacks a second title bar above the thread's own.
*
* One hook so the header, the nav, the hamburger and the layout's bottom
* padding can never disagree — if they did, hiding one would leave a gap
* where it used to be. The desktop sidebar is deliberately unaffected: it is
* the only way out of a thread on a wide screen.
*/
import { usePathname } from "next/navigation";
/**
* True on a thread itself (`/chats/<id>`) but NOT on the inbox or a chat's
* profile page: those are ordinary scrolling pages where the tab bar belongs.
*/
export function useHidesAppChrome(): boolean {
const pathname = usePathname();
if (!pathname?.startsWith("/chats/")) return false;
// "", "chats", "<id>" — anything deeper is a sub-page like /profile.
return pathname.split("/").filter(Boolean).length === 2;
}