diff --git a/app/(app)/chats/[id]/page.tsx b/app/(app)/chats/[id]/page.tsx index 363085d2..a6856221 100644 --- a/app/(app)/chats/[id]/page.tsx +++ b/app/(app)/chats/[id]/page.tsx @@ -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(null); const [starredIds, setStarredIds] = useState>(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>({}); // 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(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() { 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 && ( + setReactionsFor(null)} + /> + )} + {forwarding && ( -
+ {children} -
+ diff --git a/components/chat/chat-composer.tsx b/components/chat/chat-composer.tsx index 18e2d0dd..fa13ba39 100644 --- a/components/chat/chat-composer.tsx +++ b/components/chat/chat-composer.tsx @@ -37,6 +37,7 @@ export function ChatComposer({ replyTo, onCancelReply, onSend, + onTyping, conversationId, disabled = false, placeholder = "Message…", @@ -45,6 +46,8 @@ export function ChatComposer({ conversationId: string; replyTo: ChatMessage | null; onCancelReply: () => void; + /** Called on each keystroke; throttled by the caller. */ + onTyping?: () => void; onSend: ( body: string, replyToId?: string, @@ -257,7 +260,13 @@ export function ChatComposer({ value={value} disabled={disabled} placeholder={placeholder} - onChange={(e) => setValue(e.target.value)} + onChange={(e) => { + setValue(e.target.value); + // Fired on every keystroke; the handler throttles itself, so the + // thread hears "still typing" every few seconds rather than per + // character. + onTyping?.(); + }} onKeyDown={(e) => { // Enter sends on a physical keyboard; Shift+Enter makes a newline. // On touch keyboards Enter must insert a newline instead — there is diff --git a/components/chat/message-attachments.tsx b/components/chat/message-attachments.tsx index 0ae6a2ce..a00525a9 100644 --- a/components/chat/message-attachments.tsx +++ b/components/chat/message-attachments.tsx @@ -18,6 +18,7 @@ import { LinkPreviewCard } from "@/components/chat/link-preview-card"; import { MediaGrid } from "@/components/chat/media-grid"; import { MediaLightbox } from "@/components/chat/media-lightbox"; import { QuoteCard } from "@/components/chat/quote-card"; +import { cn } from "@/lib/utils"; import { SharedPostCard } from "@/components/chat/shared-post-card"; import { attachmentUrl, type Attachment } from "@/lib/chat"; @@ -52,7 +53,14 @@ export function MessageAttachments({ return ( <> -
+
a.kind === "entity") ? undefined : "mt-1", + )} + > {media.length > 0 && ( setLightboxAt(i)} /> )} diff --git a/components/chat/message-gestures.tsx b/components/chat/message-gestures.tsx index 9bb5bb02..613cfcd7 100644 --- a/components/chat/message-gestures.tsx +++ b/components/chat/message-gestures.tsx @@ -40,16 +40,23 @@ const CLAIM_PX = 10; const LONG_PRESS_MS = 450; /** Movement that cancels a long press — a drag is not a hold. */ const LONG_PRESS_SLOP_PX = 8; +/** Longest gap between two taps still read as a double tap. */ +const DOUBLE_TAP_MS = 300; +/** How far the second tap may land from the first. */ +const DOUBLE_TAP_SLOP_PX = 24; export function MessageGestures({ onReply, onMenu, + onDoubleTap, disabled = false, children, }: { onReply: () => void; /** Opens the context menu at the given viewport point. */ onMenu: (point: { x: number; y: number }) => void; + /** Two quick taps in the same spot — sends a heart. */ + onDoubleTap?: () => void; disabled?: boolean; children: React.ReactNode; }) { @@ -61,6 +68,8 @@ export function MessageGestures({ const holdTimer = useRef | null>(null); // Set once a long press fires, so the release doesn't also trigger a reply. const consumed = useRef(false); + // When and where the last tap landed, for pairing it with the next one. + const lastTap = useRef<{ t: number; x: number; y: number } | null>(null); const cancelHold = useCallback(() => { if (holdTimer.current) { @@ -124,10 +133,29 @@ export function MessageGestures({ setOffset(Math.max(0, Math.min(travel, MAX_PX))); }; - const onPointerUp = () => { + const onPointerUp = (e: React.PointerEvent) => { cancelHold(); // A long press already acted; releasing must not also fire a reply. - if (!consumed.current && claimed.current && offset >= TRIGGER_PX) onReply(); + if (!consumed.current && claimed.current && offset >= TRIGGER_PX) { + onReply(); + lastTap.current = null; + } else if (!consumed.current && !claimed.current && onDoubleTap) { + // A stationary tap. Paired with a recent nearby one, it is a double tap. + // Tracked here rather than with onDoubleClick so touch and mouse behave + // the same, and so a swipe or a long press can never count as a tap. + const now = Date.now(); + const prev = lastTap.current; + const near = + prev !== null && + Math.abs(e.clientX - prev.x) < DOUBLE_TAP_SLOP_PX && + Math.abs(e.clientY - prev.y) < DOUBLE_TAP_SLOP_PX; + if (prev && near && now - prev.t < DOUBLE_TAP_MS) { + lastTap.current = null; + onDoubleTap(); + } else { + lastTap.current = { t: now, x: e.clientX, y: e.clientY }; + } + } reset(); }; diff --git a/components/chat/message-group.tsx b/components/chat/message-group.tsx index 6d4dce3b..5a2ee6e4 100644 --- a/components/chat/message-group.tsx +++ b/components/chat/message-group.tsx @@ -13,6 +13,7 @@ import { MessageText } from "@/components/chat/message-text"; import { MessageAttachments } from "@/components/chat/message-attachments"; import { MessageTicks } from "@/components/chat/message-ticks"; import { tickState } from "@/lib/chat-format"; +import { HEART_EMOJI } from "@/components/chat/reaction-row"; import { cn } from "@/lib/utils"; import type { ChatMessage, ChatPeer, Receipts, ReplyPreview } from "@/lib/chat"; @@ -24,6 +25,22 @@ function clockTime(iso: string): string { }); } +/** + * True when a message carries a shared post, which renders as a standalone + * card rather than bubble content. + */ +function sharesAnEntity(m: ChatMessage): boolean { + return (m.attachments ?? []).some((a) => a.kind === "entity"); +} + +/** Distinct emoji shown before the rest collapse into "+N". */ +const MAX_TRAIL_EMOJI = 4; + +/** People who reacted, across all emoji. */ +function totalReactions(m: ChatMessage): number { + return (m.reactions ?? []).reduce((n, r) => n + r.count, 0); +} + function bubbleCorners(mine: boolean, index: number, total: number): string { if (total === 1) return "rounded-2xl"; const first = index === 0; @@ -45,12 +62,10 @@ function bubbleCorners(mine: boolean, index: number, total: number): string { */ function QuotedReply({ reply, - mine, selfId, nameFor, }: { reply: ReplyPreview; - mine: boolean; selfId: string; nameFor: (senderId: string | null | undefined) => string; }) { @@ -63,12 +78,11 @@ function QuotedReply({ : reply.sender_name || nameFor(reply.sender_id); return (
{who} @@ -90,6 +104,8 @@ export function MessageGroup({ starredIds, onRetry, nameFor, + onReact, + onTrailClick, }: { messages: ChatMessage[]; mine: boolean; @@ -109,6 +125,10 @@ export function MessageGroup({ onRetry: (message: ChatMessage) => void; /** Names a quoted sender; supplied by the thread's participant list. */ nameFor: (senderId: string | null | undefined) => string; + /** Set or toggle a reaction. Picking an existing one removes it. */ + onReact: (message: ChatMessage, emoji: string) => void; + /** Tapping the trail: retract if you reacted, otherwise show who did. */ + onTrailClick: (message: ChatMessage) => void; }) { const last = messages[messages.length - 1]; @@ -153,7 +173,27 @@ export function MessageGroup({ disabled={!!m.deleted_at || !!m.pending} onReply={() => onReply(m)} onMenu={(point) => onMenu(m, point)} + onDoubleTap={() => onReact(m, HEART_EMOJI)} > + {m.reply_to && !m.deleted_at && ( +
+ {/* Outside the bubble, above it: the quote is context for the + reply, not part of what was said. Capped to the bubble's + own width so the two read as one stack. */} +
+ +
+
+ )}
- {m.reply_to && !m.deleted_at && ( - - )} {m.deleted_at ? ( This message was deleted ) : ( <> + {/* A shared post leads and its caption follows, the way + the post itself reads. Everything else keeps text + first, with attachments beneath it. */} + {sharesAnEntity(m) && ( + + )} {m.body && ( - + + + + )} + {!sharesAnEntity(m) && ( + )} - {/* An optimistic bubble for a captionless attachment has nothing to draw until the server answers — say so rather than render an empty box. */} @@ -224,6 +284,46 @@ export function MessageGroup({ )}
+ + {m.reactions?.length > 0 && ( + + )} ))} diff --git a/components/chat/message-list.tsx b/components/chat/message-list.tsx index 550ca31a..81c6111a 100644 --- a/components/chat/message-list.tsx +++ b/components/chat/message-list.tsx @@ -55,6 +55,9 @@ export function MessageList({ starredIds, onRetry, nameFor, + typingNames, + onReact, + onTrailClick, }: { /** Ascending by seq — oldest first. */ messages: ChatMessage[]; @@ -71,6 +74,11 @@ export function MessageList({ starredIds: Set; onRetry: (message: ChatMessage) => void; nameFor: (senderId: string | null | undefined) => string; + /** Who is composing right now. Empty when nobody is. */ + typingNames: string[]; + onReact: (message: ChatMessage, emoji: string) => void; + /** Tapping the reaction trail. */ + onTrailClick: (message: ChatMessage) => void; }) { const scrollRef = useRef(null); const atBottomRef = useRef(true); @@ -173,10 +181,25 @@ export function MessageList({ starredIds={starredIds} onRetry={onRetry} nameFor={nameFor} + onReact={onReact} + onTrailClick={onTrailClick} /> ), )}
+ + {typingNames.length > 0 && ( +

+ {typingNames.length === 1 + ? `${typingNames[0]} is typing…` + : `${typingNames.slice(0, 2).join(", ")}${ + typingNames.length > 2 ? " and others" : "" + } are typing…`} +

+ )}
); } diff --git a/components/chat/message-menu.tsx b/components/chat/message-menu.tsx index f37b00bd..c9acebc0 100644 --- a/components/chat/message-menu.tsx +++ b/components/chat/message-menu.tsx @@ -18,6 +18,7 @@ import { StarOff, Trash2, } from "lucide-react"; +import { ReactionRow } from "@/components/chat/reaction-row"; import { cn } from "@/lib/utils"; export type MenuPoint = { x: number; y: number }; @@ -38,11 +39,17 @@ const MARGIN = 8; export function MessageMenu({ point, starred, + myReaction, + onReact, actions, onClose, }: { point: MenuPoint; starred: boolean; + /** The viewer's current emoji on this message, if any. */ + myReaction?: string; + /** Picking the same emoji again is a toggle — the caller decides. */ + onReact: (emoji: string) => void; actions: MessageMenuActions; onClose: () => void; }) { @@ -89,8 +96,19 @@ export function MessageMenu({ // min-w-56 rather than something tighter: "Delete for everyone" is the // longest label and, with its icon and padding, was wrapping or sitting // flush against the edge at a narrower width. - className="fixed z-50 min-w-56 overflow-hidden rounded-xl border border-border bg-background py-1 shadow-lg" + className="fixed z-50 min-w-56 overflow-hidden rounded-xl border border-border bg-background pb-1 shadow-lg" > + {/* Reactions lead: they are the most common thing to want from a + long press, and putting them in this menu keeps one gesture + responsible for one surface. */} + { + onReact(emoji); + onClose(); + }} + /> + } onClick={run(actions.onReply)}> Reply diff --git a/components/chat/quote-card.tsx b/components/chat/quote-card.tsx index 2077386b..66b6ab71 100644 --- a/components/chat/quote-card.tsx +++ b/components/chat/quote-card.tsx @@ -9,10 +9,16 @@ * * The path is always app-relative — the BFF rejects anything else — so this * can navigate with the router rather than leaving the app. + * + * Styling is deliberately bare. This sits INSIDE a chat bubble, which already + * supplies the padding, the background and the rounding; giving the quote its + * own would draw a second bubble within the first. A left rule and italics are + * enough to say "quotation", and every colour is inherited so the same markup + * reads correctly on your own primary-coloured bubble and on the muted one + * opposite. */ import { useRouter } from "next/navigation"; -import { Quote } from "lucide-react"; import type { Attachment } from "@/lib/chat"; export function QuoteCard({ attachment }: { attachment: Attachment }) { @@ -25,23 +31,16 @@ export function QuoteCard({ attachment }: { attachment: Attachment }) { type="button" onClick={() => path && router.push(path)} disabled={!path} - className="mt-1 block w-full rounded-lg border-l-2 border-primary bg-primary/5 px-3 py-2 text-left transition-colors hover:bg-primary/10 disabled:cursor-default disabled:hover:bg-primary/5" + className="block w-full border-l-2 border-current/30 pl-2.5 text-left transition-opacity hover:opacity-80 disabled:cursor-default disabled:hover:opacity-100" > - - - - {/* The passage is the point, so it leads; the reference follows in - the quieter role a citation normally takes. */} - - {passage} - - {attachment.title && ( - - {attachment.title} - - )} + {/* The passage leads; the citation follows in the quieter role a + reference normally takes. */} + {passage} + {attachment.title && ( + + {attachment.title} - + )} ); } diff --git a/components/chat/reaction-row.tsx b/components/chat/reaction-row.tsx new file mode 100644 index 00000000..8242b24f --- /dev/null +++ b/components/chat/reaction-row.tsx @@ -0,0 +1,101 @@ +"use client"; + +/** + * The emoji row at the top of a message's long-press menu. + * + * Five quick reactions plus a "+" that opens the full set — a drawer on + * mobile, a modal on desktop, reusing ResponsiveDialog so it behaves like + * every other chat surface. + * + * It sits inside the context menu rather than being its own popover: long + * press is one gesture, and two surfaces competing for it would mean neither + * felt reliable. + */ + +import { useState } from "react"; +import { Plus } from "lucide-react"; +import { EMOJI_GROUPS } from "@/components/ui/emoji-picker"; +import { ResponsiveDialog } from "@/components/chat/responsive-dialog"; +import { cn } from "@/lib/utils"; + +/** What a double-tap sends. Matches the server's own default. */ +export const HEART_EMOJI = "❤️"; + +/** The five offered without opening the full set. */ +export const QUICK_REACTIONS = [HEART_EMOJI, "😂", "🙏", "😮", "😢"]; + +export function ReactionRow({ + current, + onPick, +}: { + /** The viewer's existing reaction, so it can be shown as selected. */ + current?: string; + /** Picking the current emoji again clears it — the caller decides. */ + onPick: (emoji: string) => void; +}) { + const [browsing, setBrowsing] = useState(false); + + return ( + <> +
+ {QUICK_REACTIONS.map((emoji) => ( + + ))} + +
+ + {browsing && ( + setBrowsing(false)} + > +
+ {EMOJI_GROUPS.map((group) => ( +
+

+ {group.label} +

+
+ {group.emojis.map((emoji) => ( + + ))} +
+
+ ))} +
+
+ )} + + ); +} diff --git a/components/chat/reactions-drawer.tsx b/components/chat/reactions-drawer.tsx new file mode 100644 index 00000000..976e5db6 --- /dev/null +++ b/components/chat/reactions-drawer.tsx @@ -0,0 +1,83 @@ +"use client"; + +/** + * Who reacted to a message, and with what. + * + * Fetched on open rather than shipped with every message: the thread renders + * from grouped counts, and sending the full membership of every reaction on + * every page would grow with the room. + */ + +import { useEffect, useState } from "react"; +import { UserAvatar } from "@/components/ui/user-avatar"; +import { ResponsiveDialog } from "@/components/chat/responsive-dialog"; +import { chat, type ReactionDetail } from "@/lib/chat"; + +export function ReactionsDrawer({ + conversationId, + messageId, + onClose, +}: { + conversationId: string; + messageId: string; + onClose: () => void; +}) { + const [rows, setRows] = useState(null); + + useEffect(() => { + let cancelled = false; + chat + .reactionDetails(conversationId, messageId) + .then((r) => { + if (!cancelled) setRows(r); + }) + .catch(() => { + if (!cancelled) setRows([]); + }); + return () => { + cancelled = true; + }; + }, [conversationId, messageId]); + + return ( + + {rows === null ? ( +

Loading…

+ ) : rows.length === 0 ? ( +

No reactions.

+ ) : ( +
    + {rows.map((r) => ( +
  • + +
    +

    {r.user.name}

    +

    + @{r.user.handle} +

    +
    + + {r.emoji} + +
  • + ))} +
+ )} +
+ ); +} diff --git a/components/chat/shared-post-card.tsx b/components/chat/shared-post-card.tsx index deee33b2..afe47a99 100644 --- a/components/chat/shared-post-card.tsx +++ b/components/chat/shared-post-card.tsx @@ -19,7 +19,7 @@ export function SharedPostCard({ attachment }: { attachment: Attachment }) { if (!post || attachment.entity_unavailable) { return ( -
+
This post is no longer available @@ -31,7 +31,7 @@ export function SharedPostCard({ attachment }: { attachment: Attachment }) { return ( + {children} +
+ ); +} diff --git a/components/layout/site-bottom-nav.tsx b/components/layout/site-bottom-nav.tsx index ec346170..015f4519 100644 --- a/components/layout/site-bottom-nav.tsx +++ b/components/layout/site-bottom-nav.tsx @@ -19,6 +19,7 @@ import { useChatUnread, } from "@/components/layout/nav-unread-badge"; import { useAuth } from "@/components/auth/auth-provider"; +import { useHidesAppChrome } from "@/lib/use-chrome-visibility"; import { cn } from "@/lib/utils"; type Item = { @@ -31,6 +32,7 @@ type Item = { export function SiteBottomNav() { const pathname = usePathname(); + const hidden = useHidesAppChrome(); const { user } = useAuth(); // Read here rather than inside the badge so the count can also reach the // link's accessible name — the badge itself is decorative. @@ -97,6 +99,9 @@ export function SiteBottomNav() { return pathname === item.href || pathname.startsWith(`${item.href}/`); } + // A chat thread owns the bottom edge — its composer lives there. + if (hidden) return null; + return (