feat: chat reactions
This commit is contained in:
@@ -30,6 +30,7 @@ import {
|
||||
type MenuPoint,
|
||||
} from "@/components/chat/message-menu";
|
||||
import { ForwardDialog } from "@/components/chat/forward-dialog";
|
||||
import { ReactionsDrawer } from "@/components/chat/reactions-drawer";
|
||||
import { toast } from "sonner";
|
||||
import { channelAuthorizer } from "@/lib/realtime";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -48,6 +49,11 @@ import {
|
||||
type UploadedAttachment,
|
||||
} from "@/lib/chat";
|
||||
|
||||
/** How long a typing indicator survives its last ping. */
|
||||
const TYPING_TTL_MS = 5000;
|
||||
/** How often we tell the thread we are typing, while typing. */
|
||||
const TYPING_PING_MS = 3000;
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
export default function ChatThreadPage() {
|
||||
@@ -66,6 +72,10 @@ export default function ChatThreadPage() {
|
||||
const [menu, setMenu] = useState<{ message: ChatMessage; point: MenuPoint } | null>(null);
|
||||
const [forwarding, setForwarding] = useState<ChatMessage | null>(null);
|
||||
const [starredIds, setStarredIds] = useState<Set<string>>(new Set());
|
||||
// user id → when they last typed. Kept as timestamps rather than a boolean
|
||||
// so the indicator expires on its own: there is no "stopped typing" event,
|
||||
// and inventing one would still be lost on a dropped connection.
|
||||
const [typingAt, setTypingAt] = useState<Record<string, number>>({});
|
||||
|
||||
// While the keyboard is up the thread is sized from the visual viewport
|
||||
// instead of dvh — see useVisualViewport for why dvh can't do this on iOS.
|
||||
@@ -171,6 +181,7 @@ export default function ChatThreadPage() {
|
||||
client_msg_id: clientMsgId,
|
||||
reply_to_id: replyToId ?? null,
|
||||
attachments: [],
|
||||
reactions: [],
|
||||
created_at: new Date().toISOString(),
|
||||
pending: true,
|
||||
};
|
||||
@@ -322,6 +333,88 @@ export default function ChatThreadPage() {
|
||||
);
|
||||
|
||||
/** Mark everything visible as read, so the sender's ticks turn blue. */
|
||||
// Expire stale typers. A ticking clock rather than a timer per person: one
|
||||
// interval regardless of how many are in the room, and it stops entirely
|
||||
// once nobody is typing.
|
||||
const [now, setNow] = useState(0);
|
||||
useEffect(() => {
|
||||
if (Object.keys(typingAt).length === 0) return;
|
||||
const t = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, [typingAt]);
|
||||
|
||||
// Ids only. Resolving names here would call nameFor before it is declared
|
||||
// further down — a temporal-dead-zone crash that TypeScript does not catch,
|
||||
// because the call would sit inside a .map callback.
|
||||
// `now || Date.now()` keeps the first render correct while still depending
|
||||
// on the ticking clock above.
|
||||
const typingIds = Object.entries(typingAt)
|
||||
.filter(([, at]) => (now || Date.now()) - at < TYPING_TTL_MS)
|
||||
.map(([uid]) => uid);
|
||||
|
||||
/** Tell the thread we are composing, at most once every few seconds. */
|
||||
const typingSentAt = useRef(0);
|
||||
const notifyTyping = useCallback(() => {
|
||||
const t = Date.now();
|
||||
if (t - typingSentAt.current < TYPING_PING_MS) return;
|
||||
typingSentAt.current = t;
|
||||
chat.typing(id).catch(() => {});
|
||||
}, [id]);
|
||||
|
||||
/** Replace one message in place, after its reactions changed. */
|
||||
const refreshMessage = useCallback(
|
||||
async (messageId: string) => {
|
||||
try {
|
||||
const fresh = await chat.message(id, messageId);
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === messageId ? { ...m, ...fresh } : m)),
|
||||
);
|
||||
} catch {
|
||||
/* a stale chip is not worth an error surface */
|
||||
}
|
||||
},
|
||||
[id],
|
||||
);
|
||||
|
||||
/**
|
||||
* Set or toggle a reaction. Picking the emoji you already chose removes it,
|
||||
* which is what makes the chip and the double-tap heart both behave as
|
||||
* toggles.
|
||||
*/
|
||||
const react = useCallback(
|
||||
async (message: ChatMessage, emoji: string) => {
|
||||
const mine = message.reactions?.find((r) => r.mine)?.emoji;
|
||||
try {
|
||||
if (mine === emoji) await chat.unreact(id, message.id);
|
||||
else await chat.react(id, message.id, emoji);
|
||||
// The server publishes a `reaction` event, but the actor is on the
|
||||
// same channel and would otherwise wait a round trip to see it.
|
||||
await refreshMessage(message.id);
|
||||
} catch {
|
||||
toast.error("Could not react");
|
||||
}
|
||||
},
|
||||
[id, refreshMessage],
|
||||
);
|
||||
|
||||
// Which message's reaction list is open, if any.
|
||||
const [reactionsFor, setReactionsFor] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* The trail does one of two things, never both: retract your reaction if
|
||||
* you have one, otherwise show who reacted. Rolling both into one control
|
||||
* keeps the bubble uncluttered, at the cost that seeing the list means
|
||||
* removing your own reaction first.
|
||||
*/
|
||||
const onTrailClick = useCallback(
|
||||
(message: ChatMessage) => {
|
||||
const mine = message.reactions?.find((r) => r.mine)?.emoji;
|
||||
if (mine) void react(message, mine);
|
||||
else setReactionsFor(message.id);
|
||||
},
|
||||
[react],
|
||||
);
|
||||
|
||||
const markRead = useCallback(
|
||||
(seq: number) => {
|
||||
if (!seq) return;
|
||||
@@ -398,6 +491,14 @@ export default function ChatThreadPage() {
|
||||
),
|
||||
);
|
||||
});
|
||||
channel.bind("reaction", ({ message_id }: { message_id: string }) => {
|
||||
void refreshMessage(message_id);
|
||||
});
|
||||
channel.bind("typing", ({ user_id }: { user_id: string }) => {
|
||||
// Ignore your own keystrokes echoing back off the channel.
|
||||
if (!user_id || user_id === user?.id) return;
|
||||
setTypingAt((prev) => ({ ...prev, [user_id]: Date.now() }));
|
||||
});
|
||||
channel.bind("receipt", () => {
|
||||
chat.receipts(id).then(setReceipts).catch(() => {});
|
||||
});
|
||||
@@ -413,7 +514,7 @@ export default function ChatThreadPage() {
|
||||
cancelled = true;
|
||||
cleanup?.();
|
||||
};
|
||||
}, [id, user]);
|
||||
}, [id, user, refreshMessage]);
|
||||
|
||||
if (loading || !user) {
|
||||
return (
|
||||
@@ -452,8 +553,9 @@ export default function ChatThreadPage() {
|
||||
// collapses this to height:auto.
|
||||
// Only while the keyboard is closed; otherwise the inline style below
|
||||
// takes over and these would fight it.
|
||||
!keyboardOpen &&
|
||||
"h-[calc(100dvh_-_3rem_-_4rem)] sm:h-[calc(100dvh_-_3.5rem_-_4rem)] md:h-[calc(100dvh_-_3.5rem)]",
|
||||
// The full viewport: a thread hides the app's header AND its tab bar,
|
||||
// so there is no longer any chrome to subtract.
|
||||
!keyboardOpen && "h-[100dvh]",
|
||||
)}
|
||||
style={
|
||||
keyboardOpen && viewport
|
||||
@@ -548,6 +650,9 @@ export default function ChatThreadPage() {
|
||||
starredIds={starredIds}
|
||||
onRetry={retryMessage}
|
||||
nameFor={nameFor}
|
||||
typingNames={typingIds.map(nameFor).filter(Boolean)}
|
||||
onReact={react}
|
||||
onTrailClick={onTrailClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -562,6 +667,7 @@ export default function ChatThreadPage() {
|
||||
replyToName={nameFor(replyTo?.sender_id)}
|
||||
onCancelReply={() => setReplyTo(null)}
|
||||
onSend={sendMessage}
|
||||
onTyping={notifyTyping}
|
||||
disabled={state !== "ready"}
|
||||
/>
|
||||
)}
|
||||
@@ -570,6 +676,8 @@ export default function ChatThreadPage() {
|
||||
<MessageMenu
|
||||
point={menu.point}
|
||||
starred={starredIds.has(menu.message.id)}
|
||||
myReaction={menu.message.reactions?.find((r) => r.mine)?.emoji}
|
||||
onReact={(emoji) => react(menu.message, emoji)}
|
||||
onClose={() => setMenu(null)}
|
||||
actions={{
|
||||
onReply: () => setReplyTo(menu.message),
|
||||
@@ -591,6 +699,14 @@ export default function ChatThreadPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{reactionsFor && (
|
||||
<ReactionsDrawer
|
||||
conversationId={id}
|
||||
messageId={reactionsFor}
|
||||
onClose={() => setReactionsFor(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{forwarding && (
|
||||
<ForwardDialog
|
||||
message={forwarding}
|
||||
|
||||
Reference in New Issue
Block a user