feat: chat reactions
This commit is contained in:
@@ -30,6 +30,7 @@ import {
|
|||||||
type MenuPoint,
|
type MenuPoint,
|
||||||
} from "@/components/chat/message-menu";
|
} from "@/components/chat/message-menu";
|
||||||
import { ForwardDialog } from "@/components/chat/forward-dialog";
|
import { ForwardDialog } from "@/components/chat/forward-dialog";
|
||||||
|
import { ReactionsDrawer } from "@/components/chat/reactions-drawer";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { channelAuthorizer } from "@/lib/realtime";
|
import { channelAuthorizer } from "@/lib/realtime";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -48,6 +49,11 @@ import {
|
|||||||
type UploadedAttachment,
|
type UploadedAttachment,
|
||||||
} from "@/lib/chat";
|
} 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;
|
const PAGE_SIZE = 30;
|
||||||
|
|
||||||
export default function ChatThreadPage() {
|
export default function ChatThreadPage() {
|
||||||
@@ -66,6 +72,10 @@ export default function ChatThreadPage() {
|
|||||||
const [menu, setMenu] = useState<{ message: ChatMessage; point: MenuPoint } | null>(null);
|
const [menu, setMenu] = useState<{ message: ChatMessage; point: MenuPoint } | null>(null);
|
||||||
const [forwarding, setForwarding] = useState<ChatMessage | null>(null);
|
const [forwarding, setForwarding] = useState<ChatMessage | null>(null);
|
||||||
const [starredIds, setStarredIds] = useState<Set<string>>(new Set());
|
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
|
// 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.
|
// 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,
|
client_msg_id: clientMsgId,
|
||||||
reply_to_id: replyToId ?? null,
|
reply_to_id: replyToId ?? null,
|
||||||
attachments: [],
|
attachments: [],
|
||||||
|
reactions: [],
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
pending: true,
|
pending: true,
|
||||||
};
|
};
|
||||||
@@ -322,6 +333,88 @@ export default function ChatThreadPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
/** Mark everything visible as read, so the sender's ticks turn blue. */
|
/** 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(
|
const markRead = useCallback(
|
||||||
(seq: number) => {
|
(seq: number) => {
|
||||||
if (!seq) return;
|
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", () => {
|
channel.bind("receipt", () => {
|
||||||
chat.receipts(id).then(setReceipts).catch(() => {});
|
chat.receipts(id).then(setReceipts).catch(() => {});
|
||||||
});
|
});
|
||||||
@@ -413,7 +514,7 @@ export default function ChatThreadPage() {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
cleanup?.();
|
cleanup?.();
|
||||||
};
|
};
|
||||||
}, [id, user]);
|
}, [id, user, refreshMessage]);
|
||||||
|
|
||||||
if (loading || !user) {
|
if (loading || !user) {
|
||||||
return (
|
return (
|
||||||
@@ -452,8 +553,9 @@ export default function ChatThreadPage() {
|
|||||||
// collapses this to height:auto.
|
// collapses this to height:auto.
|
||||||
// Only while the keyboard is closed; otherwise the inline style below
|
// Only while the keyboard is closed; otherwise the inline style below
|
||||||
// takes over and these would fight it.
|
// takes over and these would fight it.
|
||||||
!keyboardOpen &&
|
// The full viewport: a thread hides the app's header AND its tab bar,
|
||||||
"h-[calc(100dvh_-_3rem_-_4rem)] sm:h-[calc(100dvh_-_3.5rem_-_4rem)] md:h-[calc(100dvh_-_3.5rem)]",
|
// so there is no longer any chrome to subtract.
|
||||||
|
!keyboardOpen && "h-[100dvh]",
|
||||||
)}
|
)}
|
||||||
style={
|
style={
|
||||||
keyboardOpen && viewport
|
keyboardOpen && viewport
|
||||||
@@ -548,6 +650,9 @@ export default function ChatThreadPage() {
|
|||||||
starredIds={starredIds}
|
starredIds={starredIds}
|
||||||
onRetry={retryMessage}
|
onRetry={retryMessage}
|
||||||
nameFor={nameFor}
|
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)}
|
replyToName={nameFor(replyTo?.sender_id)}
|
||||||
onCancelReply={() => setReplyTo(null)}
|
onCancelReply={() => setReplyTo(null)}
|
||||||
onSend={sendMessage}
|
onSend={sendMessage}
|
||||||
|
onTyping={notifyTyping}
|
||||||
disabled={state !== "ready"}
|
disabled={state !== "ready"}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -570,6 +676,8 @@ export default function ChatThreadPage() {
|
|||||||
<MessageMenu
|
<MessageMenu
|
||||||
point={menu.point}
|
point={menu.point}
|
||||||
starred={starredIds.has(menu.message.id)}
|
starred={starredIds.has(menu.message.id)}
|
||||||
|
myReaction={menu.message.reactions?.find((r) => r.mine)?.emoji}
|
||||||
|
onReact={(emoji) => react(menu.message, emoji)}
|
||||||
onClose={() => setMenu(null)}
|
onClose={() => setMenu(null)}
|
||||||
actions={{
|
actions={{
|
||||||
onReply: () => setReplyTo(menu.message),
|
onReply: () => setReplyTo(menu.message),
|
||||||
@@ -591,6 +699,14 @@ export default function ChatThreadPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{reactionsFor && (
|
||||||
|
<ReactionsDrawer
|
||||||
|
conversationId={id}
|
||||||
|
messageId={reactionsFor}
|
||||||
|
onClose={() => setReactionsFor(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{forwarding && (
|
{forwarding && (
|
||||||
<ForwardDialog
|
<ForwardDialog
|
||||||
message={forwarding}
|
message={forwarding}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { SiteHeader } from "@/components/layout/site-header";
|
import { SiteHeader } from "@/components/layout/site-header";
|
||||||
import { SiteSidebar } from "@/components/layout/site-sidebar";
|
import { SiteSidebar } from "@/components/layout/site-sidebar";
|
||||||
import { SiteBottomNav } from "@/components/layout/site-bottom-nav";
|
import { SiteBottomNav } from "@/components/layout/site-bottom-nav";
|
||||||
|
import { AppShell } from "@/components/layout/app-shell";
|
||||||
import { SiteFooter } from "@/components/layout/site-footer";
|
import { SiteFooter } from "@/components/layout/site-footer";
|
||||||
import { VerifyEmailBanner } from "@/components/auth/verify-email-banner";
|
import { VerifyEmailBanner } from "@/components/auth/verify-email-banner";
|
||||||
import { PushPrompt } from "@/components/push/push-prompt";
|
import { PushPrompt } from "@/components/push/push-prompt";
|
||||||
@@ -28,11 +29,11 @@ export default function AppLayout({
|
|||||||
<>
|
<>
|
||||||
<VerifyEmailBanner />
|
<VerifyEmailBanner />
|
||||||
<SiteSidebar />
|
<SiteSidebar />
|
||||||
<div className="flex-1 flex flex-col md:pl-16 pb-16 md:pb-0">
|
<AppShell>
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
{children}
|
{children}
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
</div>
|
</AppShell>
|
||||||
<SiteBottomNav />
|
<SiteBottomNav />
|
||||||
<PushPrompt />
|
<PushPrompt />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export function ChatComposer({
|
|||||||
replyTo,
|
replyTo,
|
||||||
onCancelReply,
|
onCancelReply,
|
||||||
onSend,
|
onSend,
|
||||||
|
onTyping,
|
||||||
conversationId,
|
conversationId,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
placeholder = "Message…",
|
placeholder = "Message…",
|
||||||
@@ -45,6 +46,8 @@ export function ChatComposer({
|
|||||||
conversationId: string;
|
conversationId: string;
|
||||||
replyTo: ChatMessage | null;
|
replyTo: ChatMessage | null;
|
||||||
onCancelReply: () => void;
|
onCancelReply: () => void;
|
||||||
|
/** Called on each keystroke; throttled by the caller. */
|
||||||
|
onTyping?: () => void;
|
||||||
onSend: (
|
onSend: (
|
||||||
body: string,
|
body: string,
|
||||||
replyToId?: string,
|
replyToId?: string,
|
||||||
@@ -257,7 +260,13 @@ export function ChatComposer({
|
|||||||
value={value}
|
value={value}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
placeholder={placeholder}
|
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) => {
|
onKeyDown={(e) => {
|
||||||
// Enter sends on a physical keyboard; Shift+Enter makes a newline.
|
// Enter sends on a physical keyboard; Shift+Enter makes a newline.
|
||||||
// On touch keyboards Enter must insert a newline instead — there is
|
// On touch keyboards Enter must insert a newline instead — there is
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { LinkPreviewCard } from "@/components/chat/link-preview-card";
|
|||||||
import { MediaGrid } from "@/components/chat/media-grid";
|
import { MediaGrid } from "@/components/chat/media-grid";
|
||||||
import { MediaLightbox } from "@/components/chat/media-lightbox";
|
import { MediaLightbox } from "@/components/chat/media-lightbox";
|
||||||
import { QuoteCard } from "@/components/chat/quote-card";
|
import { QuoteCard } from "@/components/chat/quote-card";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { SharedPostCard } from "@/components/chat/shared-post-card";
|
import { SharedPostCard } from "@/components/chat/shared-post-card";
|
||||||
import { attachmentUrl, type Attachment } from "@/lib/chat";
|
import { attachmentUrl, type Attachment } from "@/lib/chat";
|
||||||
|
|
||||||
@@ -52,7 +53,14 @@ export function MessageAttachments({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mt-1 flex flex-col gap-1.5">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-1.5",
|
||||||
|
// Separates attachments from body text above them. A shared post
|
||||||
|
// leads its bubble, so there is nothing to separate from.
|
||||||
|
attachments.some((a) => a.kind === "entity") ? undefined : "mt-1",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{media.length > 0 && (
|
{media.length > 0 && (
|
||||||
<MediaGrid items={media} onOpen={(i) => setLightboxAt(i)} />
|
<MediaGrid items={media} onOpen={(i) => setLightboxAt(i)} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -40,16 +40,23 @@ const CLAIM_PX = 10;
|
|||||||
const LONG_PRESS_MS = 450;
|
const LONG_PRESS_MS = 450;
|
||||||
/** Movement that cancels a long press — a drag is not a hold. */
|
/** Movement that cancels a long press — a drag is not a hold. */
|
||||||
const LONG_PRESS_SLOP_PX = 8;
|
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({
|
export function MessageGestures({
|
||||||
onReply,
|
onReply,
|
||||||
onMenu,
|
onMenu,
|
||||||
|
onDoubleTap,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
onReply: () => void;
|
onReply: () => void;
|
||||||
/** Opens the context menu at the given viewport point. */
|
/** Opens the context menu at the given viewport point. */
|
||||||
onMenu: (point: { x: number; y: number }) => void;
|
onMenu: (point: { x: number; y: number }) => void;
|
||||||
|
/** Two quick taps in the same spot — sends a heart. */
|
||||||
|
onDoubleTap?: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
@@ -61,6 +68,8 @@ export function MessageGestures({
|
|||||||
const holdTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const holdTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
// Set once a long press fires, so the release doesn't also trigger a reply.
|
// Set once a long press fires, so the release doesn't also trigger a reply.
|
||||||
const consumed = useRef(false);
|
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(() => {
|
const cancelHold = useCallback(() => {
|
||||||
if (holdTimer.current) {
|
if (holdTimer.current) {
|
||||||
@@ -124,10 +133,29 @@ export function MessageGestures({
|
|||||||
setOffset(Math.max(0, Math.min(travel, MAX_PX)));
|
setOffset(Math.max(0, Math.min(travel, MAX_PX)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPointerUp = () => {
|
const onPointerUp = (e: React.PointerEvent) => {
|
||||||
cancelHold();
|
cancelHold();
|
||||||
// A long press already acted; releasing must not also fire a reply.
|
// 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();
|
reset();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { MessageText } from "@/components/chat/message-text";
|
|||||||
import { MessageAttachments } from "@/components/chat/message-attachments";
|
import { MessageAttachments } from "@/components/chat/message-attachments";
|
||||||
import { MessageTicks } from "@/components/chat/message-ticks";
|
import { MessageTicks } from "@/components/chat/message-ticks";
|
||||||
import { tickState } from "@/lib/chat-format";
|
import { tickState } from "@/lib/chat-format";
|
||||||
|
import { HEART_EMOJI } from "@/components/chat/reaction-row";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ChatMessage, ChatPeer, Receipts, ReplyPreview } from "@/lib/chat";
|
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 {
|
function bubbleCorners(mine: boolean, index: number, total: number): string {
|
||||||
if (total === 1) return "rounded-2xl";
|
if (total === 1) return "rounded-2xl";
|
||||||
const first = index === 0;
|
const first = index === 0;
|
||||||
@@ -45,12 +62,10 @@ function bubbleCorners(mine: boolean, index: number, total: number): string {
|
|||||||
*/
|
*/
|
||||||
function QuotedReply({
|
function QuotedReply({
|
||||||
reply,
|
reply,
|
||||||
mine,
|
|
||||||
selfId,
|
selfId,
|
||||||
nameFor,
|
nameFor,
|
||||||
}: {
|
}: {
|
||||||
reply: ReplyPreview;
|
reply: ReplyPreview;
|
||||||
mine: boolean;
|
|
||||||
selfId: string;
|
selfId: string;
|
||||||
nameFor: (senderId: string | null | undefined) => string;
|
nameFor: (senderId: string | null | undefined) => string;
|
||||||
}) {
|
}) {
|
||||||
@@ -63,12 +78,11 @@ function QuotedReply({
|
|||||||
: reply.sender_name || nameFor(reply.sender_id);
|
: reply.sender_name || nameFor(reply.sender_id);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
// Neutral on both sides: this now sits on the page background rather
|
||||||
"mb-1 rounded-lg border-l-2 px-2 py-1 text-xs",
|
// than inside a bubble, so the old primary-foreground treatment (white
|
||||||
mine
|
// text, for a primary bubble) would be invisible in light mode.
|
||||||
? "border-primary-foreground/50 bg-black/10 text-primary-foreground/90 dark:text-white/90"
|
// Spacing comes from the column's gap — no margin of its own.
|
||||||
: "border-primary/50 bg-background/60 text-muted-foreground",
|
className="w-full rounded-lg border-l-2 border-primary/40 bg-muted/60 px-2 py-1 text-xs text-muted-foreground"
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<span className="block font-medium">{who}</span>
|
<span className="block font-medium">{who}</span>
|
||||||
<span className="line-clamp-1 break-words">
|
<span className="line-clamp-1 break-words">
|
||||||
@@ -90,6 +104,8 @@ export function MessageGroup({
|
|||||||
starredIds,
|
starredIds,
|
||||||
onRetry,
|
onRetry,
|
||||||
nameFor,
|
nameFor,
|
||||||
|
onReact,
|
||||||
|
onTrailClick,
|
||||||
}: {
|
}: {
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
mine: boolean;
|
mine: boolean;
|
||||||
@@ -109,6 +125,10 @@ export function MessageGroup({
|
|||||||
onRetry: (message: ChatMessage) => void;
|
onRetry: (message: ChatMessage) => void;
|
||||||
/** Names a quoted sender; supplied by the thread's participant list. */
|
/** Names a quoted sender; supplied by the thread's participant list. */
|
||||||
nameFor: (senderId: string | null | undefined) => string;
|
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];
|
const last = messages[messages.length - 1];
|
||||||
|
|
||||||
@@ -153,7 +173,27 @@ export function MessageGroup({
|
|||||||
disabled={!!m.deleted_at || !!m.pending}
|
disabled={!!m.deleted_at || !!m.pending}
|
||||||
onReply={() => onReply(m)}
|
onReply={() => onReply(m)}
|
||||||
onMenu={(point) => onMenu(m, point)}
|
onMenu={(point) => onMenu(m, point)}
|
||||||
|
onDoubleTap={() => onReact(m, HEART_EMOJI)}
|
||||||
>
|
>
|
||||||
|
{m.reply_to && !m.deleted_at && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex w-full",
|
||||||
|
mine ? "justify-end" : "justify-start",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* 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. */}
|
||||||
|
<div className="max-w-[78%]">
|
||||||
|
<QuotedReply
|
||||||
|
reply={m.reply_to}
|
||||||
|
selfId={selfId}
|
||||||
|
nameFor={nameFor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full",
|
"flex w-full",
|
||||||
@@ -162,31 +202,51 @@ export function MessageGroup({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/bubble relative max-w-[78%] px-3 py-2 text-sm",
|
"group/bubble relative max-w-[78%] text-sm",
|
||||||
bubbleCorners(mine, i, messages.length),
|
bubbleCorners(mine, i, messages.length),
|
||||||
m.deleted_at
|
// A shared post is a card in its own right: it brings its
|
||||||
? "bg-muted italic text-muted-foreground"
|
// own border, rounding and background, so the bubble drops
|
||||||
: mine
|
// its chrome rather than drawing a second frame around it.
|
||||||
? "bg-primary text-primary-foreground dark:text-white"
|
// Any caption gets the padding back on its own line below.
|
||||||
: "bg-muted text-foreground",
|
sharesAnEntity(m)
|
||||||
|
? "overflow-hidden bg-transparent"
|
||||||
|
: cn(
|
||||||
|
"px-3 py-2",
|
||||||
|
m.deleted_at
|
||||||
|
? "bg-muted italic text-muted-foreground"
|
||||||
|
: mine
|
||||||
|
? "bg-primary text-primary-foreground dark:text-white"
|
||||||
|
: "bg-muted text-foreground",
|
||||||
|
),
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{m.reply_to && !m.deleted_at && (
|
|
||||||
<QuotedReply
|
|
||||||
reply={m.reply_to}
|
|
||||||
mine={mine}
|
|
||||||
selfId={selfId}
|
|
||||||
nameFor={nameFor}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{m.deleted_at ? (
|
{m.deleted_at ? (
|
||||||
<span>This message was deleted</span>
|
<span>This message was deleted</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{/* 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) && (
|
||||||
|
<MessageAttachments attachments={m.attachments} />
|
||||||
|
)}
|
||||||
{m.body && (
|
{m.body && (
|
||||||
<MessageText body={m.body} entities={m.entities} />
|
<span
|
||||||
|
className={cn(
|
||||||
|
"block",
|
||||||
|
// The bubble gave up its padding for the card, so a
|
||||||
|
// caption supplies its own.
|
||||||
|
sharesAnEntity(m)
|
||||||
|
? "rounded-b-lg border border-t-0 border-border px-3 py-2"
|
||||||
|
: undefined,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MessageText body={m.body} entities={m.entities} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!sharesAnEntity(m) && (
|
||||||
|
<MessageAttachments attachments={m.attachments} />
|
||||||
)}
|
)}
|
||||||
<MessageAttachments attachments={m.attachments} />
|
|
||||||
{/* An optimistic bubble for a captionless attachment has
|
{/* An optimistic bubble for a captionless attachment has
|
||||||
nothing to draw until the server answers — say so rather
|
nothing to draw until the server answers — say so rather
|
||||||
than render an empty box. */}
|
than render an empty box. */}
|
||||||
@@ -224,6 +284,46 @@ export function MessageGroup({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{m.reactions?.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
// ONE control, not one per emoji. Two different jobs depending
|
||||||
|
// on whether you are in the reaction list already: if you have
|
||||||
|
// reacted, this retracts it; if you have not, it opens the
|
||||||
|
// list of who reacted. Per-chip toggling would make "see who
|
||||||
|
// reacted" unreachable for anyone who had.
|
||||||
|
onClick={() => onTrailClick(m)}
|
||||||
|
aria-label={
|
||||||
|
m.reactions.some((r) => r.mine)
|
||||||
|
? "Remove your reaction"
|
||||||
|
: "See who reacted"
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"-mt-1 inline-flex items-center gap-0.5 rounded-full border bg-background px-1.5 py-0.5 text-xs",
|
||||||
|
mine ? "self-end mr-1" : "self-start ml-1",
|
||||||
|
m.reactions.some((r) => r.mine)
|
||||||
|
? "border-primary/40 bg-primary/10"
|
||||||
|
: "border-border",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{m.reactions.slice(0, MAX_TRAIL_EMOJI).map((r) => (
|
||||||
|
<span key={r.emoji}>{r.emoji}</span>
|
||||||
|
))}
|
||||||
|
{m.reactions.length > MAX_TRAIL_EMOJI && (
|
||||||
|
<span className="tabular-nums text-muted-foreground">
|
||||||
|
+{m.reactions.length - MAX_TRAIL_EMOJI}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Total people, not distinct emoji — "😂❤️ 5" reads as five
|
||||||
|
reactions, which is what it is. */}
|
||||||
|
{totalReactions(m) > 1 && (
|
||||||
|
<span className="ml-0.5 tabular-nums text-muted-foreground">
|
||||||
|
{totalReactions(m)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</MessageGestures>
|
</MessageGestures>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ export function MessageList({
|
|||||||
starredIds,
|
starredIds,
|
||||||
onRetry,
|
onRetry,
|
||||||
nameFor,
|
nameFor,
|
||||||
|
typingNames,
|
||||||
|
onReact,
|
||||||
|
onTrailClick,
|
||||||
}: {
|
}: {
|
||||||
/** Ascending by seq — oldest first. */
|
/** Ascending by seq — oldest first. */
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
@@ -71,6 +74,11 @@ export function MessageList({
|
|||||||
starredIds: Set<string>;
|
starredIds: Set<string>;
|
||||||
onRetry: (message: ChatMessage) => void;
|
onRetry: (message: ChatMessage) => void;
|
||||||
nameFor: (senderId: string | null | undefined) => string;
|
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<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const atBottomRef = useRef(true);
|
const atBottomRef = useRef(true);
|
||||||
@@ -173,10 +181,25 @@ export function MessageList({
|
|||||||
starredIds={starredIds}
|
starredIds={starredIds}
|
||||||
onRetry={onRetry}
|
onRetry={onRetry}
|
||||||
nameFor={nameFor}
|
nameFor={nameFor}
|
||||||
|
onReact={onReact}
|
||||||
|
onTrailClick={onTrailClick}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{typingNames.length > 0 && (
|
||||||
|
<p
|
||||||
|
aria-live="polite"
|
||||||
|
className="px-1 pt-2 text-xs italic text-muted-foreground"
|
||||||
|
>
|
||||||
|
{typingNames.length === 1
|
||||||
|
? `${typingNames[0]} is typing…`
|
||||||
|
: `${typingNames.slice(0, 2).join(", ")}${
|
||||||
|
typingNames.length > 2 ? " and others" : ""
|
||||||
|
} are typing…`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
StarOff,
|
StarOff,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { ReactionRow } from "@/components/chat/reaction-row";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export type MenuPoint = { x: number; y: number };
|
export type MenuPoint = { x: number; y: number };
|
||||||
@@ -38,11 +39,17 @@ const MARGIN = 8;
|
|||||||
export function MessageMenu({
|
export function MessageMenu({
|
||||||
point,
|
point,
|
||||||
starred,
|
starred,
|
||||||
|
myReaction,
|
||||||
|
onReact,
|
||||||
actions,
|
actions,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
point: MenuPoint;
|
point: MenuPoint;
|
||||||
starred: boolean;
|
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;
|
actions: MessageMenuActions;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -89,8 +96,19 @@ export function MessageMenu({
|
|||||||
// min-w-56 rather than something tighter: "Delete for everyone" is the
|
// min-w-56 rather than something tighter: "Delete for everyone" is the
|
||||||
// longest label and, with its icon and padding, was wrapping or sitting
|
// longest label and, with its icon and padding, was wrapping or sitting
|
||||||
// flush against the edge at a narrower width.
|
// 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. */}
|
||||||
|
<ReactionRow
|
||||||
|
current={myReaction}
|
||||||
|
onPick={(emoji) => {
|
||||||
|
onReact(emoji);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<Item icon={<Reply className="size-4" />} onClick={run(actions.onReply)}>
|
<Item icon={<Reply className="size-4" />} onClick={run(actions.onReply)}>
|
||||||
Reply
|
Reply
|
||||||
</Item>
|
</Item>
|
||||||
|
|||||||
@@ -9,10 +9,16 @@
|
|||||||
*
|
*
|
||||||
* The path is always app-relative — the BFF rejects anything else — so this
|
* The path is always app-relative — the BFF rejects anything else — so this
|
||||||
* can navigate with the router rather than leaving the app.
|
* 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 { useRouter } from "next/navigation";
|
||||||
import { Quote } from "lucide-react";
|
|
||||||
import type { Attachment } from "@/lib/chat";
|
import type { Attachment } from "@/lib/chat";
|
||||||
|
|
||||||
export function QuoteCard({ attachment }: { attachment: Attachment }) {
|
export function QuoteCard({ attachment }: { attachment: Attachment }) {
|
||||||
@@ -25,23 +31,16 @@ export function QuoteCard({ attachment }: { attachment: Attachment }) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => path && router.push(path)}
|
onClick={() => path && router.push(path)}
|
||||||
disabled={!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"
|
||||||
>
|
>
|
||||||
<span className="flex items-start gap-2">
|
{/* The passage leads; the citation follows in the quieter role a
|
||||||
<Quote className="mt-0.5 size-3.5 shrink-0 text-primary" aria-hidden />
|
reference normally takes. */}
|
||||||
<span className="min-w-0 flex-1">
|
<span className="block text-sm italic leading-snug">{passage}</span>
|
||||||
{/* The passage is the point, so it leads; the reference follows in
|
{attachment.title && (
|
||||||
the quieter role a citation normally takes. */}
|
<span className="mt-0.5 block truncate text-xs opacity-70">
|
||||||
<span className="block text-sm italic leading-relaxed text-foreground">
|
{attachment.title}
|
||||||
{passage}
|
|
||||||
</span>
|
|
||||||
{attachment.title && (
|
|
||||||
<span className="mt-1 block truncate text-xs font-medium text-primary">
|
|
||||||
{attachment.title}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-1 border-b border-border px-2 py-2">
|
||||||
|
{QUICK_REACTIONS.map((emoji) => (
|
||||||
|
<button
|
||||||
|
key={emoji}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPick(emoji)}
|
||||||
|
aria-label={`React ${emoji}`}
|
||||||
|
aria-pressed={current === emoji}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex size-9 items-center justify-center rounded-full text-lg transition-colors",
|
||||||
|
current === emoji ? "bg-primary/15" : "hover:bg-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{emoji}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBrowsing(true)}
|
||||||
|
aria-label="More reactions"
|
||||||
|
className="inline-flex size-9 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{browsing && (
|
||||||
|
<ResponsiveDialog
|
||||||
|
title="Pick a reaction"
|
||||||
|
description="Choose any emoji to react with."
|
||||||
|
onClose={() => setBrowsing(false)}
|
||||||
|
>
|
||||||
|
<div className="space-y-4 p-4">
|
||||||
|
{EMOJI_GROUPS.map((group) => (
|
||||||
|
<section key={group.label}>
|
||||||
|
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
{group.label}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-8 gap-1">
|
||||||
|
{group.emojis.map((emoji) => (
|
||||||
|
<button
|
||||||
|
key={emoji}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setBrowsing(false);
|
||||||
|
onPick(emoji);
|
||||||
|
}}
|
||||||
|
aria-label={`React ${emoji}`}
|
||||||
|
className="inline-flex size-9 items-center justify-center rounded-md text-lg hover:bg-muted"
|
||||||
|
>
|
||||||
|
{emoji}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<ReactionDetail[] | null>(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 (
|
||||||
|
<ResponsiveDialog
|
||||||
|
title="Reactions"
|
||||||
|
description={
|
||||||
|
rows === null
|
||||||
|
? "Loading…"
|
||||||
|
: `${rows.length} ${rows.length === 1 ? "person" : "people"} reacted.`
|
||||||
|
}
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
{rows === null ? (
|
||||||
|
<p className="p-4 text-sm text-muted-foreground">Loading…</p>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<p className="p-4 text-sm text-muted-foreground">No reactions.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-border">
|
||||||
|
{rows.map((r) => (
|
||||||
|
<li
|
||||||
|
key={r.user.id}
|
||||||
|
className="flex items-center gap-3 px-4 py-2.5"
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
src={r.user.avatar_url}
|
||||||
|
name={r.user.name}
|
||||||
|
className="size-9 shrink-0"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm text-foreground">{r.user.name}</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
@{r.user.handle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 text-lg" aria-label={r.emoji}>
|
||||||
|
{r.emoji}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ export function SharedPostCard({ attachment }: { attachment: Attachment }) {
|
|||||||
|
|
||||||
if (!post || attachment.entity_unavailable) {
|
if (!post || attachment.entity_unavailable) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-1 flex items-center gap-2 rounded-lg border border-border bg-muted/30 px-3 py-2.5">
|
<div className="flex items-center gap-2 rounded-lg border border-border bg-muted/30 px-3 py-2.5">
|
||||||
<ImageOff className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
<ImageOff className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
This post is no longer available
|
This post is no longer available
|
||||||
@@ -31,7 +31,7 @@ export function SharedPostCard({ attachment }: { attachment: Attachment }) {
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/u/${encodeURIComponent(post.author_handle)}/posts#post-${post.id}`}
|
href={`/u/${encodeURIComponent(post.author_handle)}/posts#post-${post.id}`}
|
||||||
className="mt-1 block overflow-hidden rounded-lg border border-border bg-card transition-colors hover:bg-muted/40"
|
className="block overflow-hidden rounded-lg border border-border bg-card transition-colors hover:bg-muted/40"
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2 px-3 py-2">
|
<span className="flex items-center gap-2 px-3 py-2">
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The column that holds the header, page and footer.
|
||||||
|
*
|
||||||
|
* Exists as a client component for one reason: the bottom padding that
|
||||||
|
* reserves space for the mobile tab bar has to disappear wherever the tab bar
|
||||||
|
* does, and that decision depends on the route. Left in the server layout, the
|
||||||
|
* padding would survive the bar and leave a 64px gap under a chat composer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useHidesAppChrome } from "@/lib/use-chrome-visibility";
|
||||||
|
|
||||||
|
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||||
|
const noTabBar = useHidesAppChrome();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 flex-col md:pl-16",
|
||||||
|
// Matches SiteBottomNav's own visibility exactly.
|
||||||
|
noTabBar ? "pb-0" : "pb-16 md:pb-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
useChatUnread,
|
useChatUnread,
|
||||||
} from "@/components/layout/nav-unread-badge";
|
} from "@/components/layout/nav-unread-badge";
|
||||||
import { useAuth } from "@/components/auth/auth-provider";
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
|
import { useHidesAppChrome } from "@/lib/use-chrome-visibility";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type Item = {
|
type Item = {
|
||||||
@@ -31,6 +32,7 @@ type Item = {
|
|||||||
|
|
||||||
export function SiteBottomNav() {
|
export function SiteBottomNav() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const hidden = useHidesAppChrome();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
// Read here rather than inside the badge so the count can also reach the
|
// Read here rather than inside the badge so the count can also reach the
|
||||||
// link's accessible name — the badge itself is decorative.
|
// link's accessible name — the badge itself is decorative.
|
||||||
@@ -97,6 +99,9 @@ export function SiteBottomNav() {
|
|||||||
return pathname === item.href || pathname.startsWith(`${item.href}/`);
|
return pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A chat thread owns the bottom edge — its composer lives there.
|
||||||
|
if (hidden) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
aria-label="Primary"
|
aria-label="Primary"
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import Link from "next/link";
|
|||||||
import { ReadingSettings } from "@/components/reading-settings";
|
import { ReadingSettings } from "@/components/reading-settings";
|
||||||
import { NotificationBell } from "@/components/layout/notification-bell";
|
import { NotificationBell } from "@/components/layout/notification-bell";
|
||||||
import { useAuth } from "@/components/auth/auth-provider";
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
|
import { useHidesAppChrome } from "@/lib/use-chrome-visibility";
|
||||||
|
|
||||||
export function SiteHeader() {
|
export function SiteHeader() {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
@@ -33,6 +34,10 @@ export function SiteHeader() {
|
|||||||
// auth check we treat `loading` as anonymous so the gear doesn't flash
|
// auth check we treat `loading` as anonymous so the gear doesn't flash
|
||||||
// in and out — once auth resolves, it hides for signed-in users.
|
// in and out — once auth resolves, it hides for signed-in users.
|
||||||
const showMobileGear = !loading && !user;
|
const showMobileGear = !loading && !user;
|
||||||
|
// A chat thread has its own header — back button, name, avatar — so the
|
||||||
|
// wordmark bar would stack a second title above it.
|
||||||
|
const hidden = useHidesAppChrome();
|
||||||
|
if (hidden) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-30 bg-background/60 backdrop-blur-md">
|
<header className="sticky top-0 z-30 bg-background/60 backdrop-blur-md">
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { useState } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Menu } from "lucide-react";
|
import { Menu } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useHidesAppChrome } from "@/lib/use-chrome-visibility";
|
||||||
import {
|
import {
|
||||||
Drawer,
|
Drawer,
|
||||||
DrawerContent,
|
DrawerContent,
|
||||||
@@ -43,6 +44,10 @@ export function MobileNavToggle({
|
|||||||
items: ReadonlyArray<NavItem>;
|
items: ReadonlyArray<NavItem>;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
// A thread already has a back button in its own header; a hamburger beside
|
||||||
|
// it offers a second, conflicting way out.
|
||||||
|
const hidden = useHidesAppChrome();
|
||||||
|
if (hidden) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer open={open} onOpenChange={setOpen} direction="left">
|
<Drawer open={open} onOpenChange={setOpen} direction="left">
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { cn } from "@/lib/utils";
|
|||||||
|
|
||||||
// A curated, broadly-useful set — enough to feel like a picker without
|
// A curated, broadly-useful set — enough to feel like a picker without
|
||||||
// shipping a full emoji database. Grouped for easy scanning.
|
// shipping a full emoji database. Grouped for easy scanning.
|
||||||
const EMOJI_GROUPS: { label: string; emojis: string[] }[] = [
|
export const EMOJI_GROUPS: { label: string; emojis: string[] }[] = [
|
||||||
{
|
{
|
||||||
label: "Smileys",
|
label: "Smileys",
|
||||||
emojis: [
|
emojis: [
|
||||||
|
|||||||
+51
@@ -119,6 +119,8 @@ export type ChatMessage = {
|
|||||||
reply_to?: ReplyPreview | null;
|
reply_to?: ReplyPreview | null;
|
||||||
/** Always an array — media, link cards and shared entities. */
|
/** Always an array — media, link cards and shared entities. */
|
||||||
attachments: Attachment[];
|
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
|
* Client-only. Set on a message shown optimistically before the server has
|
||||||
* acknowledged it; never sent by the BFF.
|
* acknowledged it; never sent by the BFF.
|
||||||
@@ -171,6 +173,21 @@ export type AttachmentKind =
|
|||||||
| "entity"
|
| "entity"
|
||||||
| "quote";
|
| "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 = {
|
export type Attachment = {
|
||||||
id: string;
|
id: string;
|
||||||
message_id: string;
|
message_id: string;
|
||||||
@@ -411,6 +428,40 @@ export const chat = {
|
|||||||
body: JSON.stringify({ seq }),
|
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) =>
|
markDelivered: (conversationId: string, seq: number) =>
|
||||||
call<void>(`/chat/conversations/${conversationId}/delivered`, {
|
call<void>(`/chat/conversations/${conversationId}/delivered`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user