10 Commits
Author SHA1 Message Date
gosangam 2597c6f2ec feat: chat stickers 2026-08-30 02:19:31 +05:30
gosangam 8c7afe596d feat: chat reactions 2026-08-30 01:51:52 +05:30
gosangam 0f382ed885 fix: no attachment broadcast fix 2026-08-29 18:56:45 +05:30
gosangam 03c8799f22 feat: broadcast 2026-08-29 18:34:21 +05:30
gosangam 011b5bba91 fix: chat UI 2026-08-29 17:30:32 +05:30
gosangam 3e6a1c6f39 fix: chat accepts posts 2026-08-29 16:26:53 +05:30
gosangam 76cd106ef7 fix: chat accepts quotes now 2026-08-29 11:58:33 +05:30
gosangam 028a8d7e1c fix: chat UI fixes 2026-08-29 04:12:30 +05:30
gosangam 290ceb048f fix: chat URL fix in bottom nav 2026-08-29 04:02:07 +05:30
gosangam 523b6fa7e8 feat: chat 2026-08-29 03:49:58 +05:30
63 changed files with 7552 additions and 73 deletions
+725
View File
@@ -0,0 +1,725 @@
"use client";
/**
* /chats/[id] — a single thread.
*
* History pages upward from the newest message using `before_seq` (a keyset
* cursor, not an offset), so paging stays stable while new messages keep
* arriving at the top.
*
* Live updates come from the thread's own private channel. Subscribing is
* gated by the BFF: it only signs `private-conversation.{id}` for an actual
* participant, so an unauthorised subscription is refused at the server.
*
* Composer, attachments and the message context menu land in later phases —
* this screen is read + receipts.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { ArrowLeft, BellOff } from "lucide-react";
import { useAuth } from "@/components/auth/auth-provider";
import { useAuthGate } from "@/components/auth/auth-gate";
import { UserAvatar } from "@/components/ui/user-avatar";
import { KautilyaIcon } from "@/components/selection-toolbar/shared";
import { MessageList } from "@/components/chat/message-list";
import { ChatComposer } from "@/components/chat/chat-composer";
import {
MessageMenu,
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";
import { useVisualViewport } from "@/lib/use-visual-viewport";
import { useNotifications } from "@/components/notifications/notifications-provider";
import {
chat,
conversationTitle,
groupAvatarUrl,
isMuted,
senderName,
senderPeer,
type ChatMessage,
type ConversationDetail,
type Receipts,
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() {
const { id } = useParams<{ id: string }>();
const { user, loading } = useAuth();
const { requireAuth } = useAuthGate();
const router = useRouter();
const [meta, setMeta] = useState<ConversationDetail | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [receipts, setReceipts] = useState<Receipts | null>(null);
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
const [replyTo, setReplyTo] = useState<ChatMessage | null>(null);
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.
// Declared with the other hooks: the early return below means anything
// placed after it would run conditionally.
const { refreshChatUnread } = useNotifications();
const viewport = useVisualViewport();
const keyboardOpen = viewport?.keyboardOpen ?? false;
// Guards against two loads racing (scroll handler + the short-page effect).
const loadingRef = useRef(false);
useEffect(() => {
if (loading || user) return;
let cancelled = false;
(async () => {
const ok = await requireAuth();
if (!ok && !cancelled) router.replace("/");
})();
return () => {
cancelled = true;
};
}, [loading, user, requireAuth, router]);
/** Newest page first; messages are held ascending for rendering. */
const loadNewest = useCallback(async () => {
try {
const page = await chat.messages(id, 0, PAGE_SIZE);
setMessages([...page].reverse());
setHasMore(page.length === PAGE_SIZE);
setState("ready");
} catch {
setState("error");
}
}, [id]);
const loadOlder = useCallback(async () => {
if (loadingRef.current || !hasMore) return;
const oldest = messages[0]?.seq;
if (!oldest) return;
loadingRef.current = true;
setLoadingMore(true);
try {
const page = await chat.messages(id, oldest, PAGE_SIZE);
if (page.length > 0) {
setMessages((prev) => [...[...page].reverse(), ...prev]);
}
setHasMore(page.length === PAGE_SIZE);
} catch {
// Leave hasMore alone — a transient failure shouldn't permanently end
// history; the next scroll retries.
} finally {
loadingRef.current = false;
setLoadingMore(false);
}
}, [id, hasMore, messages]);
// One request for the thread: title, avatar, mute state, and the
// participant list the reply banner and quoted lines need to name people.
useEffect(() => {
if (!user) return;
let cancelled = false;
chat
.detail(id)
.then((d) => {
if (!cancelled) setMeta(d);
})
.catch(() => {
/* the message list surfaces the failure */
});
return () => {
cancelled = true;
};
}, [id, user]);
useEffect(() => {
if (!user) return;
void loadNewest();
chat.receipts(id).then(setReceipts).catch(() => {});
}, [id, user, loadNewest]);
/**
* Send optimistically: the bubble appears immediately keyed by a
* client-generated id, then the server's version replaces it. That id is
* also what makes a retry idempotent server-side, so a flaky connection
* can't produce two bubbles.
*/
const sendMessage = useCallback(
(
body: string,
replyToId?: string,
previewUrl?: string,
attachments?: UploadedAttachment[],
stickerKey?: string,
) => {
const clientMsgId = crypto.randomUUID();
const optimistic: ChatMessage = {
id: clientMsgId,
conversation_id: id,
seq: Number.MAX_SAFE_INTEGER,
sender_id: user?.id ?? null,
kind: "text",
body,
entities: [],
client_msg_id: clientMsgId,
reply_to_id: replyToId ?? null,
attachments: [],
reactions: [],
created_at: new Date().toISOString(),
pending: true,
};
setMessages((prev) => [...prev, optimistic]);
chat
.send(id, body, {
clientMsgId,
replyToId,
previewUrl,
attachments,
stickerKey,
})
.then((saved) => {
setMessages((prev) => {
// Drop the placeholder and any realtime echo of the same message.
const without = prev.filter(
(m) => m.id !== clientMsgId && m.id !== saved.id,
);
return [...without, saved];
});
})
.catch(() => {
// Keep the bubble and its text — discarding it loses what the user
// wrote. Marking it failed offers a retry instead.
setMessages((prev) =>
prev.map((m) =>
m.id === clientMsgId ? { ...m, pending: false, failed: true } : m,
),
);
});
},
[id, user?.id],
);
/**
* Retry a failed send, REUSING its client_msg_id. If the original actually
* reached the server and only the response was lost, the server returns the
* stored message instead of writing a second one.
*/
const retryMessage = useCallback(
(failed: ChatMessage) => {
const clientMsgId = failed.client_msg_id ?? failed.id;
setMessages((prev) =>
prev.map((m) =>
m.id === failed.id ? { ...m, pending: true, failed: false } : m,
),
);
chat
.send(id, failed.body, {
clientMsgId,
replyToId: failed.reply_to_id ?? undefined,
})
.then((saved) => {
setMessages((prev) => [
...prev.filter((m) => m.id !== failed.id && m.id !== saved.id),
saved,
]);
})
.catch(() => {
setMessages((prev) =>
prev.map((m) =>
m.id === failed.id ? { ...m, pending: false, failed: true } : m,
),
);
});
},
[id],
);
// Which messages the caller has starred, so bubbles can show it.
const loadStars = useCallback(() => {
chat
.starred(id)
.then((rows) => setStarredIds(new Set((rows ?? []).map((m) => m.id))))
.catch(() => {});
}, [id]);
useEffect(() => {
if (!user) return;
loadStars();
}, [user, loadStars]);
const toggleStar = useCallback(
async (m: ChatMessage) => {
const starred = starredIds.has(m.id);
try {
await chat.setStar(id, m.id, !starred);
setStarredIds((prev) => {
const next = new Set(prev);
if (starred) next.delete(m.id);
else next.add(m.id);
return next;
});
} catch {
toast.error("Could not update the star");
}
},
[id, starredIds],
);
const copyMessage = useCallback((m: ChatMessage) => {
// The stored body is clean text — no markers, no markup — so this is
// exactly what the reader sees.
navigator.clipboard
.writeText(m.body)
.then(() => toast.success("Copied"))
.catch(() => toast.error("Could not copy"));
}, []);
const deleteMessage = useCallback(
async (m: ChatMessage, scope: "me" | "everyone") => {
if (
scope === "everyone" &&
!window.confirm("Delete this message for everyone?")
) {
return;
}
try {
await chat.deleteMessage(id, m.id, scope);
if (scope === "me") {
setMessages((prev) => prev.filter((x) => x.id !== m.id));
} else {
// Tombstone in place — the realtime event does the same for others.
setMessages((prev) =>
prev.map((x) =>
x.id === m.id
? { ...x, deleted_at: new Date().toISOString(), body: "", attachments: [] }
: x,
),
);
}
} catch {
toast.error("Could not delete the message");
}
},
[id],
);
const reportMessage = useCallback(
async (m: ChatMessage) => {
const reason = window.prompt(
"Report this message. What's wrong with it?",
"",
);
if (reason === null) return;
try {
await chat.report(id, m.id, "abuse", reason);
toast.success("Reported. Our moderators will review it.");
} catch {
toast.error("Could not send the report");
}
},
[id],
);
/** 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;
// Refresh the nav badge afterwards: without this it stays lit until the
// next incoming message, since nothing else tells it this thread was
// read.
chat
.markRead(id, seq)
.then(refreshChatUnread)
.catch(() => {});
},
[id, refreshChatUnread],
);
useEffect(() => {
// Skip the optimistic sentinel — only a real server seq is worth acking.
const real = messages.filter((m) => !m.pending && !m.failed);
const newest = real[real.length - 1]?.seq;
if (newest) markRead(newest);
}, [messages, markRead]);
// Live thread updates.
useEffect(() => {
if (!user) return;
const key = process.env.NEXT_PUBLIC_SOKETI_KEY;
const host = process.env.NEXT_PUBLIC_SOKETI_HOST;
if (!key || !host) return;
const port = Number(process.env.NEXT_PUBLIC_SOKETI_PORT || "443");
const forceTLS =
(process.env.NEXT_PUBLIC_SOKETI_FORCE_TLS ?? "true") !== "false";
const channelName = `private-conversation.${id}`;
let cancelled = false;
let cleanup: (() => void) | undefined;
void import("pusher-js").then(({ default: Pusher }) => {
if (cancelled) return;
const pusher = new Pusher(key, {
wsHost: host,
wsPort: port,
wssPort: port,
forceTLS,
enabledTransports: ["ws", "wss"],
disableStats: true,
cluster: "",
channelAuthorization: { customHandler: channelAuthorizer },
});
const channel = pusher.subscribe(channelName);
channel.bind("message", (m: ChatMessage) => {
setMessages((prev) => {
// The echo of our own send may arrive before or after the HTTP
// response, so match on both ids: the server's and the one we
// generated for the optimistic bubble.
if (
prev.some(
(x) => x.id === m.id || (!!m.client_msg_id && x.id === m.client_msg_id),
)
) {
return prev.map((x) =>
m.client_msg_id && x.id === m.client_msg_id ? m : x,
);
}
return [...prev, m];
});
});
channel.bind("message_deleted", ({ message_id }: { message_id: string }) => {
setMessages((prev) =>
prev.map((x) =>
x.id === message_id
? { ...x, deleted_at: new Date().toISOString(), body: "", attachments: [] }
: x,
),
);
});
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(() => {});
});
cleanup = () => {
channel.unbind_all();
pusher.unsubscribe(channelName);
pusher.disconnect();
};
});
return () => {
cancelled = true;
cleanup?.();
};
}, [id, user, refreshMessage]);
if (loading || !user) {
return (
<main className="flex-1 px-4 py-6">
<p className="text-sm text-muted-foreground">Loading…</p>
</main>
);
}
const nameFor = (senderId: string | null | undefined) =>
senderName(senderId, meta?.participants ?? [], user.id);
// Groups need the actual sender, not the conversation's `peer` — `peer` is
// only set on DMs, so a group has no single counterpart to read from.
const senderFor = (senderId: string | null | undefined) =>
senderPeer(senderId, meta?.participants ?? []);
const title = meta ? conversationTitle(meta) : "Chat";
const muted = meta ? isMuted(meta) : false;
return (
<main
className={cn(
// Match the inbox and profile widths — a thread stretched across a
// desktop monitor gives bubbles an unreadable line length, and the
// eye has to travel between the avatar and the timestamp.
"mx-auto flex w-full max-w-2xl flex-col",
// Side rules so the narrower column reads as a panel rather than
// text floating in space.
"md:border-x md:border-border",
// Fill the viewport MINUS the chrome around it: a sticky h-12/sm:h-14
// header above, and on small screens the h-16 bottom nav the app
// layout reserves with pb-16. Plain 100dvh ignores both and pushes the
// composer below the fold.
// NB: the underscores are load-bearing. Tailwind turns `_` into a
// space, and calc() requires whitespace around a binary `-` — written
// without it the whole declaration is invalid and dropped, which
// collapses this to height:auto.
// Only while the keyboard is closed; otherwise the inline style below
// takes over and these would fight it.
// 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
? {
// Pinned to the visible strip above the keyboard. Fixed rather
// than in-flow because iOS scrolls the layout viewport instead
// of resizing it, so an in-flow column keeps its old height and
// hides the composer underneath. left/right 0 with the existing
// mx-auto keeps the desktop column centred.
position: "fixed",
top: viewport.offsetTop,
left: 0,
right: 0,
height: viewport.height,
// Above the app's fixed bottom nav (z-40) so the composer can
// never be painted over by it.
zIndex: 50,
}
: undefined
}
>
<header className="flex items-center gap-3 border-b border-border px-4 py-3">
<Link
href="/chats"
aria-label="Back to chats"
className="-ml-1 rounded-md p-1 text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-5" />
</Link>
{meta?.kind === "ai" ? (
<span
aria-hidden
className="inline-flex size-9 items-center justify-center rounded-full bg-primary/10 text-primary"
>
<KautilyaIcon className="size-4" />
</span>
) : (
<UserAvatar
src={
meta?.kind === "group"
? groupAvatarUrl(id, meta?.avatar_key)
: meta?.peer?.avatar_url
}
name={title}
className="size-9"
/>
)}
<Link
href={`/chats/${id}/profile`}
className="flex min-w-0 flex-1 flex-col text-left hover:opacity-80"
aria-label="Chat details"
>
<div className="flex items-center gap-2">
<h1 className="truncate text-sm font-semibold text-foreground">
{title}
</h1>
{muted && (
<BellOff
className="size-3.5 text-muted-foreground"
aria-label="Muted"
/>
)}
</div>
{meta?.peer?.handle && (
<p className="truncate text-xs text-muted-foreground">
@{meta.peer.handle}
</p>
)}
</Link>
</header>
{state === "error" ? (
<p className="p-4 text-sm text-destructive">
Could not load this conversation.
</p>
) : state === "loading" ? (
<p className="p-4 text-sm text-muted-foreground">Loading…</p>
) : (
<MessageList
messages={messages}
selfId={user.id}
senderFor={senderFor}
receipts={receipts}
isGroup={meta?.kind === "group"}
hasMore={hasMore}
loadingMore={loadingMore}
onLoadMore={loadOlder}
onReply={setReplyTo}
onMenu={(message, point) => setMenu({ message, point })}
starredIds={starredIds}
onRetry={retryMessage}
nameFor={nameFor}
typingNames={typingIds.map(nameFor).filter(Boolean)}
onReact={react}
onTrailClick={onTrailClick}
/>
)}
{meta?.kind === "ai" ? (
<div className="border-t border-border px-4 py-3 text-center text-xs text-muted-foreground">
Kautilya is coming soon.
</div>
) : (
<ChatComposer
conversationId={id}
replyTo={replyTo}
replyToName={nameFor(replyTo?.sender_id)}
onCancelReply={() => setReplyTo(null)}
onSend={sendMessage}
onTyping={notifyTyping}
disabled={state !== "ready"}
/>
)}
{menu && (
<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),
onCopy: () => copyMessage(menu.message),
onStar: () => void toggleStar(menu.message),
onForward: () => setForwarding(menu.message),
onDeleteForMe: () => void deleteMessage(menu.message, "me"),
// Only the sender may erase a message for everyone.
onDeleteForEveryone:
menu.message.sender_id === user.id
? () => void deleteMessage(menu.message, "everyone")
: undefined,
// Reporting your own message is meaningless.
onReport:
menu.message.sender_id === user.id
? undefined
: () => void reportMessage(menu.message),
}}
/>
)}
{reactionsFor && (
<ReactionsDrawer
conversationId={id}
messageId={reactionsFor}
onClose={() => setReactionsFor(null)}
/>
)}
{forwarding && (
<ForwardDialog
message={forwarding}
onClose={() => setForwarding(null)}
/>
)}
</main>
);
}
+455
View File
@@ -0,0 +1,455 @@
"use client";
/**
* /chats/[id]/profile — the chat profile.
*
* Shows who you're talking to and how you're connected, then three tabs over
* everything shared in the thread: media, links, and the messages you starred.
*
* The media and links tabs are one index scan each — `conversation_id` is
* denormalized onto attachments precisely so this screen doesn't join back
* through the message table.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import {
ArrowLeft,
BellOff,
Camera,
Link2,
Settings,
Star,
Images,
Users,
} from "lucide-react";
import { toast } from "sonner";
import { useAuth } from "@/components/auth/auth-provider";
import { useAuthGate } from "@/components/auth/auth-gate";
import { UserAvatar } from "@/components/ui/user-avatar";
import { KautilyaIcon } from "@/components/selection-toolbar/shared";
import { MessageText } from "@/components/chat/message-text";
import { MessageAttachments } from "@/components/chat/message-attachments";
import { MediaLightbox } from "@/components/chat/media-lightbox";
import { ChatProfileHeader } from "@/components/chat/chat-profile-header";
import { GroupMembersTab } from "@/components/chat/group-members-tab";
import { GroupSettingsTab } from "@/components/chat/group-settings-tab";
import {
attachmentUrl,
chat,
conversationTitle,
groupAvatarUrl,
isGroupAdmin,
type Attachment,
type ChatMessage,
type ConversationDetail,
} from "@/lib/chat";
import { cn } from "@/lib/utils";
type Tab = "members" | "media" | "links" | "starred" | "settings";
/** Tabs that load their own data; the rest render from the thread detail. */
type FetchingTab = "media" | "links" | "starred";
const FETCHING_TABS = new Set<Tab>(["media", "links", "starred"]);
/** Type guard, so narrowing survives the Set lookup. */
function isFetchingTab(t: Tab): t is FetchingTab {
return FETCHING_TABS.has(t);
}
export default function ChatProfilePage() {
const { id } = useParams<{ id: string }>();
const { user, loading } = useAuth();
const { requireAuth } = useAuthGate();
const router = useRouter();
const [meta, setMeta] = useState<ConversationDetail | null>(null);
// Groups open on Members — who is in the room is the first question there.
// DMs have no member list, so they open on Media.
const [tab, setTab] = useState<Tab>("media");
const [items, setItems] = useState<Attachment[]>([]);
const [starred, setStarred] = useState<ChatMessage[]>([]);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
const [lightboxAt, setLightboxAt] = useState<number | null>(null);
// Set once the user picks a tab, so a metadata refresh never overrides them.
const tabChosen = useRef(false);
useEffect(() => {
if (loading || user) return;
let cancelled = false;
(async () => {
const ok = await requireAuth();
if (!ok && !cancelled) router.replace("/");
})();
return () => {
cancelled = true;
};
}, [loading, user, requireAuth, router]);
const loadMeta = useCallback(() => {
chat
.detail(id)
.then((d) => {
setMeta(d);
// Only on first load: don't yank the user off a tab they chose.
setTab((current) =>
current === "media" && d.kind === "group" && !tabChosen.current
? "members"
: current,
);
})
.catch(() => setState("error"));
}, [id]);
useEffect(() => {
if (!user) return;
loadMeta();
}, [user, loadMeta]);
// Switching tabs sets "loading" from the click handler rather than from the
// effect, so the effect only ever reports a result. It also means the old
// tab's rows are never briefly shown under the new tab's heading.
const changeTab = (next: Tab) => {
if (next === tab) return;
tabChosen.current = true;
if (FETCHING_TABS.has(next)) setState("loading");
setTab(next);
};
useEffect(() => {
if (!user) return;
// Members and Settings render straight from `meta` and fetch nothing, so
// they never enter the loading state to leave (see changeTab).
if (!isFetchingTab(tab)) return;
let cancelled = false;
const request =
tab === "starred"
? chat.starred(id).then((rows) => {
if (!cancelled) setStarred(rows ?? []);
})
: chat.gallery(id, tab).then((rows) => {
if (!cancelled) setItems(rows ?? []);
});
request
.then(() => !cancelled && setState("ready"))
.catch(() => !cancelled && setState("error"));
return () => {
cancelled = true;
};
}, [id, tab, user]);
const unstar = async (messageId: string) => {
try {
await chat.setStar(id, messageId, false);
setStarred((prev) => prev.filter((m) => m.id !== messageId));
} catch {
toast.error("Could not remove the star");
}
};
if (loading || !user) {
return (
<main className="flex-1 px-4 py-6">
<p className="text-sm text-muted-foreground">Loading…</p>
</main>
);
}
const isGroup = meta?.kind === "group";
const title = meta ? conversationTitle(meta) : "Chat";
return (
<main className="flex-1 w-full max-w-2xl mx-auto px-4 sm:px-6 py-6 space-y-6">
<div className="flex items-center gap-3">
<Link
href={`/chats/${id}`}
aria-label="Back to conversation"
className="-ml-1 rounded-md p-1 text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-5" />
</Link>
<h1 className="text-sm font-medium text-muted-foreground">
Chat details
</h1>
</div>
<div className="flex flex-col items-center gap-3 text-center">
{meta?.kind === "ai" ? (
<span
aria-hidden
className="inline-flex size-20 items-center justify-center rounded-full bg-primary/10 text-primary"
>
<KautilyaIcon className="size-9" />
</span>
) : (
<div className="relative">
<UserAvatar
src={
isGroup
? groupAvatarUrl(id, meta?.avatar_key)
: meta?.peer?.avatar_url
}
name={title}
className="size-20"
textClassName="text-xl"
/>
{/* Admins get a hint here; the control itself lives in Settings so
there is only one place that changes the photo. */}
{isGroup && isGroupAdmin(meta?.my_role) && (
<button
type="button"
onClick={() => changeTab("settings")}
aria-label="Change group photo"
className="absolute -bottom-1 -right-1 rounded-full border border-border bg-background p-1.5 text-muted-foreground shadow-sm hover:text-foreground"
>
<Camera className="size-3.5" />
</button>
)}
</div>
)}
<div>
<div className="flex items-center justify-center gap-2">
<h2 className="text-xl font-semibold text-foreground">{title}</h2>
{meta?.muted_until && (
<BellOff
className="size-4 text-muted-foreground"
aria-label="Muted"
/>
)}
</div>
{meta?.peer?.handle && (
<Link
href={`/u/${meta.peer.handle}`}
className="text-sm text-muted-foreground hover:underline"
>
@{meta.peer.handle}
</Link>
)}
</div>
</div>
{meta && !isGroup && (
<ChatProfileHeader
conversationId={id}
detail={meta}
onChanged={loadMeta}
/>
)}
{meta && isGroup && (
<p className="text-center text-sm text-muted-foreground">
{meta.participants.length}{" "}
{meta.participants.length === 1 ? "member" : "members"}
{meta.send_policy === "admins" && " · only admins can send"}
</p>
)}
<div className="flex gap-1 overflow-x-auto rounded-lg bg-muted p-1">
{isGroup && (
<TabButton
active={tab === "members"}
onClick={() => changeTab("members")}
>
<Users className="size-4" /> Members
</TabButton>
)}
<TabButton active={tab === "media"} onClick={() => changeTab("media")}>
<Images className="size-4" /> Media
</TabButton>
<TabButton active={tab === "links"} onClick={() => changeTab("links")}>
<Link2 className="size-4" /> Links
</TabButton>
<TabButton
active={tab === "starred"}
onClick={() => changeTab("starred")}
>
<Star className="size-4" /> Starred
</TabButton>
{isGroup && (
<TabButton
active={tab === "settings"}
onClick={() => changeTab("settings")}
>
<Settings className="size-4" /> Settings
</TabButton>
)}
</div>
{state === "error" ? (
<p className="text-sm text-destructive">Could not load this tab.</p>
) : tab === "members" && meta ? (
<GroupMembersTab
conversationId={id}
detail={meta}
selfId={user.id}
onChanged={loadMeta}
/>
) : tab === "settings" && meta ? (
<GroupSettingsTab
conversationId={id}
detail={meta}
selfHandle={user.handle}
onChanged={loadMeta}
/>
) : state === "loading" ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : tab === "starred" ? (
starred.length === 0 ? (
<Empty text="No starred messages yet. Star a message to keep it here." />
) : (
<ul className="divide-y divide-border rounded-2xl border border-border overflow-hidden">
{starred.map((m) => (
<li key={m.id} className="flex items-start gap-3 px-4 py-3">
<div className="min-w-0 flex-1 text-sm">
{m.body && (
<MessageText body={m.body} entities={m.entities} />
)}
<MessageAttachments attachments={m.attachments} />
<p className="mt-1 text-xs text-muted-foreground">
{new Date(m.created_at).toLocaleString()}
</p>
</div>
<button
type="button"
onClick={() => unstar(m.id)}
aria-label="Remove star"
className="shrink-0 rounded-md p-1 text-primary hover:opacity-80"
>
<Star className="size-4 fill-current" />
</button>
</li>
))}
</ul>
)
) : items.length === 0 ? (
<Empty
text={
tab === "media"
? "No photos, videos or files shared yet."
: "No links shared yet."
}
/>
) : tab === "media" ? (
<div className="grid grid-cols-3 gap-1 sm:grid-cols-4">
{items.map((a, i) =>
a.kind === "image" || a.kind === "video" ? (
<button
key={a.id}
type="button"
// Index into the media-only list, so the lightbox opens on the
// tile that was tapped.
onClick={() => setLightboxAt(viewableIndex(items, i))}
className="aspect-square overflow-hidden rounded-md"
>
{a.kind === "video" ? (
<video
src={attachmentUrl(a.id)}
preload="metadata"
muted
playsInline
className="size-full object-cover"
/>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={attachmentUrl(a.id)}
alt={a.file_name ?? "Shared media"}
loading="lazy"
className="size-full object-cover"
/>
)}
</button>
) : (
<a
key={a.id}
href={attachmentUrl(a.id)}
target="_blank"
rel="noopener noreferrer"
className="flex aspect-square items-center justify-center rounded-md bg-muted p-2 text-center text-[11px] text-muted-foreground hover:opacity-80"
>
<span className="line-clamp-3 break-all">
{a.file_name ?? a.kind}
</span>
</a>
),
)}
</div>
) : (
<ul className="divide-y divide-border rounded-2xl border border-border overflow-hidden">
{items.map((a) => (
<li key={a.id}>
<a
href={a.url ?? "#"}
target="_blank"
rel="noopener noreferrer"
className="block px-4 py-3 hover:bg-muted/60"
>
<p className="truncate text-sm font-medium text-foreground">
{a.title || a.url}
</p>
{a.site_name && (
<p className="truncate text-xs text-muted-foreground">
{a.site_name}
</p>
)}
</a>
</li>
))}
</ul>
)}
{lightboxAt !== null && (
<MediaLightbox
items={viewable(items)}
startIndex={lightboxAt}
onClose={() => setLightboxAt(null)}
/>
)}
</main>
);
}
/** The gallery mixes files in with media; only media is viewable. */
function viewable(items: Attachment[]): Attachment[] {
return items.filter((a) => a.kind === "image" || a.kind === "video");
}
/** Maps a position in the full gallery to its position among viewable items. */
function viewableIndex(items: Attachment[], galleryIndex: number): number {
return viewable(items.slice(0, galleryIndex)).length;
}
function TabButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex-1 inline-flex items-center justify-center gap-1.5 h-9 rounded-md text-sm font-medium transition-colors",
active
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{children}
</button>
);
}
function Empty({ text }: { text: string }) {
return (
<p className="py-12 text-center text-sm text-muted-foreground">{text}</p>
);
}
+238
View File
@@ -0,0 +1,238 @@
"use client";
/**
* /chats — the chat inbox, split into two tabs:
*
* • "Chats" — DMs and groups.
* • "Kautilya" — threads with the AI study companion. The companion itself is
* still coming soon, so the tab renders its own empty state.
*
* Both tabs read the same endpoint with a different `tab` filter: server-side
* they are one pipeline over `conversations.kind`, not two implementations.
*
* New messages arrive as realtime `chat_activity` and highlight the affected
* row in place. They deliberately never reach the notification tray.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { MessageCircle, Users } from "lucide-react";
import { useAuth } from "@/components/auth/auth-provider";
import { useAuthGate } from "@/components/auth/auth-gate";
import { useNotifications } from "@/components/notifications/notifications-provider";
import { KautilyaIcon } from "@/components/selection-toolbar/shared";
import { ChatListItem } from "@/components/chat/chat-list-item";
import { NewGroupDialog } from "@/components/chat/new-group-dialog";
import { chat, type InboxItem, type InboxTab } from "@/lib/chat";
import { cn } from "@/lib/utils";
/** How long a row stays highlighted after a message lands. */
const HIGHLIGHT_MS = 4000;
export default function ChatsPage() {
const { user, loading } = useAuth();
const { requireAuth } = useAuthGate();
const { lastChatActivity } = useNotifications();
const router = useRouter();
const [tab, setTab] = useState<InboxTab>("chats");
const [items, setItems] = useState<InboxItem[]>([]);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
const [highlighted, setHighlighted] = useState<Set<string>>(new Set());
const [creatingGroup, setCreatingGroup] = useState(false);
const timers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
// Auth gate — same pattern as /notifications: offer the modal in place, and
// only leave the page if they dismiss it.
useEffect(() => {
if (loading || user) return;
let cancelled = false;
(async () => {
const ok = await requireAuth();
if (!ok && !cancelled) router.replace("/");
})();
return () => {
cancelled = true;
};
}, [loading, user, requireAuth, router]);
const load = useCallback(
(which: InboxTab) => {
if (!user) return;
setState("loading");
chat
.inbox(which)
.then((rows) => {
setItems(rows ?? []);
setState("ready");
})
.catch(() => setState("error"));
},
[user],
);
useEffect(() => {
load(tab);
}, [tab, load]);
// Clear pending highlight timers on unmount so they can't fire into an
// unmounted tree.
useEffect(() => {
const pending = timers.current;
return () => {
pending.forEach(clearTimeout);
pending.clear();
};
}, []);
// A message landed: refresh the list so its preview, order and unread count
// are authoritative, and flash the row it belongs to.
useEffect(() => {
if (!lastChatActivity) return;
const { conversation_id } = lastChatActivity;
load(tab);
setHighlighted((prev) => new Set(prev).add(conversation_id));
const existing = timers.current.get(conversation_id);
if (existing) clearTimeout(existing);
timers.current.set(
conversation_id,
setTimeout(() => {
setHighlighted((prev) => {
const next = new Set(prev);
next.delete(conversation_id);
return next;
});
timers.current.delete(conversation_id);
}, HIGHLIGHT_MS),
);
// `tab`/`load` are intentionally excluded: this should run when activity
// arrives, not when the tab changes (which reloads on its own).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lastChatActivity]);
if (loading || !user) {
return (
<main className="flex-1 max-w-2xl w-full mx-auto px-4 sm:px-6 py-6">
<p className="text-sm text-muted-foreground">Loading…</p>
</main>
);
}
return (
<main className="flex-1 max-w-2xl w-full mx-auto px-4 sm:px-6 py-6 space-y-6">
<div className="flex items-center justify-between gap-3">
<h1 className="font-[family-name:var(--font-title)] text-3xl font-semibold text-foreground">
Chats
</h1>
{tab === "chats" && (
<button
type="button"
onClick={() => setCreatingGroup(true)}
className="inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-foreground hover:bg-muted/60"
>
<Users className="size-4" /> New group
</button>
)}
</div>
<div className="flex gap-1 rounded-lg bg-muted p-1">
<TabButton active={tab === "chats"} onClick={() => setTab("chats")}>
Chats
</TabButton>
<TabButton
active={tab === "kautilya"}
onClick={() => setTab("kautilya")}
>
Kautilya
</TabButton>
</div>
{state === "error" ? (
<p className="text-sm text-destructive">
Could not load your chats. Please try again.
</p>
) : state === "loading" ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : items.length === 0 ? (
tab === "kautilya" ? (
<Empty
icon={<KautilyaIcon className="size-8" />}
title="Kautilya is coming soon"
text="An AI study companion tuned on Śrīla Prabhupāda's books, letters, and lectures."
/>
) : (
<Empty
icon={<MessageCircle className="size-8" />}
title="No chats yet"
text="Open someone's profile to start a conversation."
/>
)
) : (
<ul className="divide-y divide-border rounded-2xl border border-border overflow-hidden">
{items.map((item) => (
<ChatListItem
key={item.id}
item={item}
selfId={user.id}
highlighted={highlighted.has(item.id)}
/>
))}
</ul>
)}
{creatingGroup && (
<NewGroupDialog onClose={() => setCreatingGroup(false)} />
)}
</main>
);
}
function TabButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex-1 inline-flex items-center justify-center gap-2 h-9 rounded-md text-sm font-medium transition-colors",
active
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{children}
</button>
);
}
function Empty({
icon,
title,
text,
}: {
icon: React.ReactNode;
title: string;
text: string;
}) {
return (
<div className="flex flex-col items-center gap-3 py-16 text-center">
<span
aria-hidden
className="inline-flex size-14 items-center justify-center rounded-full bg-primary/10 text-primary"
>
{icon}
</span>
<p className="text-base font-medium text-foreground">{title}</p>
<p className="max-w-sm text-sm text-muted-foreground">{text}</p>
</div>
);
}
+3 -2
View File
@@ -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 />
</> </>
+240
View File
@@ -0,0 +1,240 @@
"use client";
/**
* /settings/chat — chat preferences.
*
* Each control saves on change rather than behind a Save button: these are
* independent switches, and a half-applied form is worse than an immediate
* write that can be toggled back.
*/
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { toast } from "sonner";
import { SettingsShell, Section } from "@/components/settings/settings-ui";
import { SettingRow, ToggleSwitch } from "@/components/settings/setting-row";
import { chat, type ChatSettings } from "@/lib/chat";
import { cn } from "@/lib/utils";
const AGE_CHOICES = [1, 3, 6, 12] as const;
export default function ChatSettingsPage() {
const [settings, setSettings] = useState<ChatSettings | null>(null);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
const [busy, setBusy] = useState(false);
useEffect(() => {
let cancelled = false;
chat
.settings()
.then((s) => {
if (cancelled) return;
setSettings(s);
setState("ready");
})
.catch(() => !cancelled && setState("error"));
return () => {
cancelled = true;
};
}, []);
const save = useCallback(
async (patch: Partial<Omit<ChatSettings, "gender_set">>) => {
setBusy(true);
try {
setSettings(await chat.updateSettings(patch));
} catch (err) {
// The BFF explains *why* — notably "add your gender in profile
// settings" — so surface its message rather than a generic failure.
toast.error(
err instanceof Error && err.message
? err.message
: "Could not save that setting",
);
} finally {
setBusy(false);
}
},
[],
);
return (
<SettingsShell
backHref="/settings"
backLabel="Back to settings"
title="Chat"
>
{state === "error" ? (
<p className="text-sm text-destructive">
Could not load your chat settings.
</p>
) : state === "loading" || !settings ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : (
<div className="space-y-8">
<Section
title="Read receipts"
description="If you turn these off, you won't see when others have read your messages either."
>
<SettingRow
label="Send read receipts"
description="People you chat with can see when you've read their messages. Delivery ticks are unaffected."
control={
<ToggleSwitch
label="Send read receipts"
checked={settings.read_receipts}
disabled={busy}
onToggle={() =>
save({ read_receipts: !settings.read_receipts })
}
/>
}
/>
</Section>
<Section
title="Who can message you"
description="This applies to new conversations. People you're already chatting with can still reach you."
>
<div className="space-y-2">
<Choice
active={settings.who_can_message === "anyone"}
disabled={busy}
onClick={() => save({ who_can_message: "anyone" })}
title="Anyone"
description="No restriction."
/>
<Choice
active={settings.who_can_message === "friends"}
disabled={busy}
onClick={() => save({ who_can_message: "friends" })}
title="Your friends"
description="Only people you follow, or who follow you."
/>
<Choice
active={settings.who_can_message === "account_age"}
disabled={busy}
onClick={() => save({ who_can_message: "account_age" })}
title="Established accounts"
description="Only accounts older than a chosen age."
>
{settings.who_can_message === "account_age" && (
<div className="mt-3 flex flex-wrap gap-2">
{AGE_CHOICES.map((months) => (
<button
key={months}
type="button"
disabled={busy}
onClick={(e) => {
e.stopPropagation();
void save({ min_account_age_months: months });
}}
className={cn(
"rounded-full border px-3 py-1 text-xs transition-colors",
settings.min_account_age_months === months
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:text-foreground",
)}
>
{months} {months === 1 ? "month" : "months"}
</button>
))}
</div>
)}
</Choice>
<Choice
active={settings.who_can_message === "same_gender"}
disabled={busy || !settings.gender_set}
onClick={() => save({ who_can_message: "same_gender" })}
title="Only accounts of your gender"
description={
settings.gender_set
? "Only accounts whose gender matches yours."
: undefined
}
>
{!settings.gender_set && (
<p className="mt-1 text-xs text-muted-foreground">
Add your gender in{" "}
<Link
href="/settings/profile"
className="underline underline-offset-2 hover:text-foreground"
>
profile settings
</Link>{" "}
to use this option.
</p>
)}
</Choice>
</div>
</Section>
<Section
title="Kautilya"
description="Kautilya is the AI study companion, tuned on Śrīla Prabhupāda's books, letters, and lectures."
>
<SettingRow
label="Use your chats to train Kautilya"
description="Your conversations may help improve the assistant's answers. Turning this off excludes them."
control={
<ToggleSwitch
label="Use your chats to train Kautilya"
checked={settings.train_kautilya}
disabled={busy}
onToggle={() =>
save({ train_kautilya: !settings.train_kautilya })
}
/>
}
/>
</Section>
</div>
)}
</SettingsShell>
);
}
function Choice({
active,
disabled,
onClick,
title,
description,
children,
}: {
active: boolean;
disabled?: boolean;
onClick: () => void;
title: string;
description?: string;
children?: React.ReactNode;
}) {
return (
<div
className={cn(
"rounded-xl border p-3 transition-colors",
active ? "border-primary bg-primary/5" : "border-border",
disabled && !active && "opacity-60",
)}
>
<button
type="button"
role="radio"
aria-checked={active}
disabled={disabled}
onClick={onClick}
className="w-full text-left disabled:cursor-default"
>
<span className="block text-sm font-medium text-foreground">
{title}
</span>
{description && (
<span className="block text-xs text-muted-foreground">
{description}
</span>
)}
</button>
{children}
</div>
);
}
+7
View File
@@ -22,6 +22,7 @@ import {
DownloadCloud, DownloadCloud,
LogOut, LogOut,
MessageSquareText, MessageSquareText,
MessagesSquare,
Scale, Scale,
ShieldCheck, ShieldCheck,
UserRound, UserRound,
@@ -49,6 +50,12 @@ export default function SettingsPage() {
label="Reading preferences" label="Reading preferences"
description="Theme, palette, font, and size — synced across devices." description="Theme, palette, font, and size — synced across devices."
/> />
<NavRow
href="/settings/chat"
icon={<MessagesSquare className="size-5" />}
label="Chat"
description="Read receipts, who can message you, and Kautilya training."
/>
<NavRow <NavRow
href="/settings/notifications" href="/settings/notifications"
icon={<Bell className="size-5" />} icon={<Bell className="size-5" />}
+15 -1
View File
@@ -1,4 +1,4 @@
import type { Metadata } from "next"; import type { Metadata, Viewport } from "next";
import Script from "next/script"; import Script from "next/script";
import { Geist, Geist_Mono, Lora, Bricolage_Grotesque } from "next/font/google"; import { Geist, Geist_Mono, Lora, Bricolage_Grotesque } from "next/font/google";
import { ogDefault } from "@/lib/og"; import { ogDefault } from "@/lib/og";
@@ -8,6 +8,7 @@ import { AudioPlayerProvider, FloatingPlayer } from "@/components/audio-player";
import { AuthProvider } from "@/components/auth/auth-provider"; import { AuthProvider } from "@/components/auth/auth-provider";
import { AuthGateProvider } from "@/components/auth/auth-gate"; import { AuthGateProvider } from "@/components/auth/auth-gate";
import { NotificationsProvider } from "@/components/notifications/notifications-provider"; import { NotificationsProvider } from "@/components/notifications/notifications-provider";
import { BroadcastModal } from "@/components/notifications/broadcast-modal";
import { SharePostProvider } from "@/components/social/share-post-provider"; import { SharePostProvider } from "@/components/social/share-post-provider";
import { Toaster } from "@/components/ui/toaster"; import { Toaster } from "@/components/ui/toaster";
import { ServiceWorkerRegistrar } from "@/components/pwa/sw-register"; import { ServiceWorkerRegistrar } from "@/components/pwa/sw-register";
@@ -43,6 +44,16 @@ const bricolage = Bricolage_Grotesque({
display: "swap", display: "swap",
}); });
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
// Ask the browser to shrink the layout viewport when the software keyboard
// opens, which is the native fix for a bottom-anchored composer. Chrome and
// Android honour it; Safari does not, which is why the chat thread also
// tracks window.visualViewport by hand.
interactiveWidget: "resizes-content",
};
export const metadata: Metadata = { export const metadata: Metadata = {
// Resolves all relative OG/Twitter image URLs and canonical links to // Resolves all relative OG/Twitter image URLs and canonical links to
// absolute URLs (required for correct social previews + SEO; Next warns // absolute URLs (required for correct social previews + SEO; Next warns
@@ -105,6 +116,9 @@ export default function RootLayout({
{/* Toaster and FloatingPlayer are global — toasts and {/* Toaster and FloatingPlayer are global — toasts and
currently-playing audio survive navigation between currently-playing audio survive navigation between
route groups. */} route groups. */}
{/* Announcements: at most one modal, only when signed
in, dismissed server-side so it never repeats. */}
<BroadcastModal />
<FloatingPlayer /> <FloatingPlayer />
<Toaster /> <Toaster />
<ServiceWorkerRegistrar /> <ServiceWorkerRegistrar />
+167
View File
@@ -0,0 +1,167 @@
"use client";
/**
* Search for people and add them to a group. Bottom drawer on mobile, modal on
* desktop — see ResponsiveDialog.
*
* Search runs through the same-origin /api/search/users proxy the search page
* uses. It returns handles, which is exactly what the member API takes — no id
* plumbing needed.
*/
import { useEffect, useState } from "react";
import { Check, Search } from "lucide-react";
import { toast } from "sonner";
import { useAuth } from "@/components/auth/auth-provider";
import { useMediaQuery } from "@/components/selection-toolbar/shared";
import { UserAvatar } from "@/components/ui/user-avatar";
import { Button } from "@/components/ui/button";
import { chat } from "@/lib/chat";
import { ResponsiveDialog } from "@/components/chat/responsive-dialog";
type Candidate = { handle: string; name: string; avatar_url: string | null };
export function AddMembersDialog({
conversationId,
existing,
onClose,
onAdded,
}: {
conversationId: string;
/** Handles already in the group. Filtered out of results entirely. */
existing: string[];
onClose: () => void;
onAdded: () => void;
}) {
const { user } = useAuth();
const isMobile = useMediaQuery("(max-width: 767px)");
const [query, setQuery] = useState("");
// Results are stored with the query they belong to, so what's displayed can
// be derived: a stale page from a previous query simply stops matching, and
// nothing has to be cleared from an effect.
const [results, setResults] = useState<{ q: string; rows: Candidate[] }>({
q: "",
rows: [],
});
const [picked, setPicked] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const q = query.trim();
const searchable = q.length >= 2;
// Current members and the caller are dropped rather than shown greyed out:
// this list exists to answer "who can I add?", and anyone already here is
// not an answer to that.
const alreadyIn = new Set(existing);
const visible =
results.q === q
? results.rows.filter(
(u) => u.handle !== user?.handle && !alreadyIn.has(u.handle),
)
: [];
useEffect(() => {
if (!searchable) return;
const timer = setTimeout(() => {
fetch(`/api/search/users?q=${encodeURIComponent(q)}&limit=15`)
.then((r) => r.json())
.then((data: { results?: Candidate[] }) =>
setResults({ q, rows: data.results ?? [] }),
)
.catch(() => setResults({ q, rows: [] }));
}, 300);
return () => clearTimeout(timer);
}, [q, searchable]);
const toggle = (handle: string) =>
setPicked((prev) => {
const next = new Set(prev);
if (next.has(handle)) next.delete(handle);
else next.add(handle);
return next;
});
const submit = async () => {
if (picked.size === 0) return;
setBusy(true);
try {
await chat.addMembers(conversationId, [...picked]);
toast.success(
picked.size === 1 ? "Member added" : `${picked.size} members added`,
);
onAdded();
onClose();
} catch {
toast.error("Could not add members");
} finally {
setBusy(false);
}
};
return (
<ResponsiveDialog
title="Add members"
description="Search for people to add to this group."
onClose={onClose}
toolbar={
<div className="flex items-center gap-2 rounded-lg bg-muted/40 px-3 py-2">
<Search className="size-4 shrink-0 text-muted-foreground" />
<input
// Not auto-focused on mobile: the keyboard would open mid-way
// through the drawer's slide-up and fight the animation.
autoFocus={!isMobile}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by name or handle"
className="flex-1 bg-transparent text-sm outline-none pointer-coarse:text-base placeholder:text-muted-foreground"
/>
</div>
}
footer={
<Button
className="w-full"
disabled={picked.size === 0 || busy}
onClick={submit}
>
{picked.size === 0
? "Select people to add"
: `Add ${picked.size} ${picked.size === 1 ? "person" : "people"}`}
</Button>
}
>
{!searchable ? (
<p className="p-4 text-sm text-muted-foreground">
Type at least two characters to search.
</p>
) : visible.length === 0 ? (
<p className="p-4 text-sm text-muted-foreground">No one found.</p>
) : (
<ul className="divide-y divide-border">
{visible.map((u) => (
<li key={u.handle}>
<button
type="button"
onClick={() => toggle(u.handle)}
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-muted/60"
>
<UserAvatar
src={u.avatar_url}
name={u.name}
className="size-9 shrink-0"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-foreground">{u.name}</p>
<p className="truncate text-xs text-muted-foreground">
@{u.handle}
</p>
</div>
{picked.has(u.handle) && (
<Check className="size-4 shrink-0 text-primary" />
)}
</button>
</li>
))}
</ul>
)}
</ResponsiveDialog>
);
}
+331
View File
@@ -0,0 +1,331 @@
"use client";
/**
* The message composer.
*
* Formatting is deliberately NOT parsed here — the user types `*bold*` and the
* BFF turns markers into clean text plus spans. Keeping one parser on the
* server means every client renders identically and no client can dictate how
* its text is decorated in someone else's thread.
*
* Link previews and attachments layer onto this in the next phase; nothing here
* needs to change to accommodate them.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { Paperclip, Smile, SendHorizonal, X } from "lucide-react";
import { LinkPreviewCard } from "@/components/chat/link-preview-card";
import {
ComposerAttachments,
type StagedAttachment,
} from "@/components/chat/composer-attachments";
import { StickerPicker } from "@/components/chat/sticker-picker";
import { compressImage } from "@/lib/compress-image";
import { cn } from "@/lib/utils";
import {
ACCEPTED_ATTACHMENTS,
chat,
firstUrl,
uploadAttachment,
type ChatMessage,
type LinkPreview,
type UploadedAttachment,
} from "@/lib/chat";
const MAX_ROWS_PX = 140;
export function ChatComposer({
replyTo,
onCancelReply,
onSend,
onTyping,
conversationId,
disabled = false,
placeholder = "Message…",
replyToName,
}: {
conversationId: string;
replyTo: ChatMessage | null;
onCancelReply: () => void;
/** Called on each keystroke; throttled by the caller. */
onTyping?: () => void;
onSend: (
body: string,
replyToId?: string,
previewUrl?: string,
attachments?: UploadedAttachment[],
/** Reward artwork sent on its own. */
stickerKey?: string,
) => void;
disabled?: boolean;
placeholder?: string;
/** Display name of whoever wrote the message being replied to. */
replyToName: string;
}) {
const [value, setValue] = useState("");
const [preview, setPreview] = useState<LinkPreview | null>(null);
// URLs the sender dismissed. Kept so re-typing doesn't resurrect a card
// they've already said no to.
const [dismissed, setDismissed] = useState<Set<string>>(new Set());
const [staged, setStaged] = useState<StagedAttachment[]>([]);
const ref = useRef<HTMLTextAreaElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
const [pickingSticker, setPickingSticker] = useState(false);
const lookupSeq = useRef(0);
// Grow with the content, up to a cap.
useEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, MAX_ROWS_PX)}px`;
}, [value]);
// Which link, if any, the draft currently wants a card for.
const activeUrl = firstUrl(value);
const wantsPreview = !!activeUrl && !dismissed.has(activeUrl);
// Whether to SHOW a card is derived during render rather than cleared from
// an effect: a stale card for a link the user has since edited away simply
// stops matching, so there is no state to reset.
const visiblePreview =
wantsPreview && preview?.url === activeUrl ? preview : null;
// The effect only fetches. Requests are sequenced so a slow earlier response
// cannot overwrite a newer one, and debounced so a lookup doesn't fire on
// every keystroke.
useEffect(() => {
if (!wantsPreview || !activeUrl) return;
const seq = ++lookupSeq.current;
const timer = setTimeout(() => {
chat
.linkPreview(activeUrl)
.then((p) => {
if (seq === lookupSeq.current) setPreview(p);
})
// No card is a normal outcome — many pages carry no metadata.
.catch(() => {});
}, 500);
return () => clearTimeout(timer);
}, [activeUrl, wantsPreview]);
const dismissPreview = useCallback(() => {
if (activeUrl) setDismissed((prev) => new Set(prev).add(activeUrl));
setPreview(null);
}, [activeUrl]);
// Starting a reply should put the cursor where the user is about to type.
useEffect(() => {
if (replyTo) ref.current?.focus();
}, [replyTo]);
/**
* Uploads each pick straight to the private bucket, so by the time the user
* hits send the bytes are already there and sending is just metadata.
* Images are compressed first, which also strips EXIF — location data
* shouldn't ride along into someone else's chat.
*/
const addFiles = useCallback(
async (files: FileList) => {
for (const original of Array.from(files)) {
const id = crypto.randomUUID();
const file = original.type.startsWith("image/")
? await compressImage(original).catch(() => original)
: original;
const previewUrl = file.type.startsWith("image/")
? URL.createObjectURL(file)
: undefined;
setStaged((prev) => [
...prev,
{ id, file, previewUrl, status: "uploading" },
]);
uploadAttachment(conversationId, file)
.then((uploaded) => {
setStaged((prev) =>
prev.map((s) =>
s.id === id ? { ...s, status: "ready", uploaded } : s,
),
);
})
.catch(() => {
// One failure shouldn't discard the others — mark it and let the
// user remove it.
setStaged((prev) =>
prev.map((s) => (s.id === id ? { ...s, status: "failed" } : s)),
);
});
}
},
[conversationId],
);
const removeStaged = useCallback((id: string) => {
setStaged((prev) => {
const target = prev.find((s) => s.id === id);
if (target?.previewUrl) URL.revokeObjectURL(target.previewUrl);
return prev.filter((s) => s.id !== id);
});
}, []);
// Object URLs are a leak if the composer unmounts mid-compose.
useEffect(() => {
return () => {
staged.forEach((s) => s.previewUrl && URL.revokeObjectURL(s.previewUrl));
};
// Intentionally on unmount only; removeStaged handles the per-chip case.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const uploading = staged.some((s) => s.status === "uploading");
const ready = staged.filter((s) => s.status === "ready" && s.uploaded);
const canSend =
!disabled && !uploading && (value.trim() !== "" || ready.length > 0);
const submit = () => {
if (!canSend) return;
const body = value.trim();
onSend(
body,
replyTo?.id,
visiblePreview?.url,
ready.map((s) => s.uploaded as UploadedAttachment),
);
staged.forEach((s) => s.previewUrl && URL.revokeObjectURL(s.previewUrl));
setStaged([]);
setValue("");
setPreview(null);
setDismissed(new Set());
onCancelReply();
};
return (
<div className="border-t border-border bg-background">
{replyTo && (
<div className="flex items-start gap-2 border-b border-border px-4 py-2">
<div className="min-w-0 flex-1 border-l-2 border-primary/50 pl-2">
<p className="text-xs font-medium text-foreground">
Replying to {replyToName}
</p>
<p className="line-clamp-1 break-words text-xs text-muted-foreground">
{replyTo.body}
</p>
</div>
<button
type="button"
onClick={onCancelReply}
aria-label="Cancel reply"
className="rounded-md p-1 text-muted-foreground hover:text-foreground"
>
<X className="size-4" />
</button>
</div>
)}
{visiblePreview && (
<div className="px-4 pt-2">
<LinkPreviewCard
preview={visiblePreview}
onDismiss={dismissPreview}
compact
/>
</div>
)}
<ComposerAttachments items={staged} onRemove={removeStaged} />
<div className="flex items-end gap-2 px-4 py-3">
<input
ref={fileRef}
type="file"
multiple
accept={ACCEPTED_ATTACHMENTS}
className="hidden"
onChange={(e) => {
if (e.target.files?.length) void addFiles(e.target.files);
// Reset so picking the same file twice still fires a change.
e.target.value = "";
}}
/>
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={disabled}
aria-label="Attach a file"
className="inline-flex size-9 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:text-foreground disabled:opacity-40"
>
<Paperclip className="size-4" />
</button>
<button
type="button"
onClick={() => setPickingSticker(true)}
disabled={disabled}
aria-label="Send a sticker"
className="inline-flex size-9 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:text-foreground disabled:opacity-40"
>
<Smile className="size-4" />
</button>
<textarea
ref={ref}
rows={1}
value={value}
disabled={disabled}
placeholder={placeholder}
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
// no Shift to reach for — so the send button is the only way there.
if (e.key !== "Enter" || e.shiftKey) return;
const touch =
typeof window !== "undefined" &&
window.matchMedia("(pointer: coarse)").matches;
if (touch) return;
e.preventDefault();
submit();
}}
className={cn(
// 16px on touch devices: iOS Safari zooms the whole page when a
// focused field computes below that, which on a composer means the
// thread jumps every time the keyboard opens. Pointer-based devices
// keep the app's 14px chrome.
"flex-1 resize-none rounded-2xl border border-border bg-muted/40 px-3 py-2",
"text-sm pointer-coarse:text-base",
"placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/40",
"disabled:opacity-60",
)}
/>
<button
type="button"
onClick={submit}
disabled={!canSend}
aria-label="Send message"
className={cn(
"inline-flex size-9 shrink-0 items-center justify-center rounded-full transition-colors",
"bg-primary text-primary-foreground disabled:opacity-40 dark:text-white",
)}
>
<SendHorizonal className="size-4" />
</button>
</div>
{pickingSticker && (
<StickerPicker
// Sent on its own, with no text: a sticker IS the message.
onPick={(sticker) =>
onSend("", undefined, undefined, undefined, sticker.key)
}
onClose={() => setPickingSticker(false)}
/>
)}
</div>
);
}
+129
View File
@@ -0,0 +1,129 @@
"use client";
/**
* One row of the chat inbox: avatar, title, the last message (truncated, with
* its sender), the time, and an unread badge.
*
* `highlighted` is driven by realtime `chat_activity` — a new message flashes
* its row here rather than raising a notification, which is what keeps chat out
* of the notification tray.
*/
import Link from "next/link";
import { BellOff } from "lucide-react";
import { UserAvatar } from "@/components/ui/user-avatar";
import { KautilyaIcon } from "@/components/selection-toolbar/shared";
import { timeAgo } from "@/lib/time";
import { cn } from "@/lib/utils";
import {
conversationTitle,
groupAvatarUrl,
isMuted,
type InboxItem,
} from "@/lib/chat";
/** "You: ..." for your own messages, "Name: ..." in groups, bare in a DM. */
function previewText(item: InboxItem, selfId: string): string {
const m = item.last_message;
if (!m) return "No messages yet";
if (m.deleted_at) return "This message was deleted";
const body = m.body || "Attachment";
if (m.sender_id === selfId) return `You: ${body}`;
// A DM's peer is already named by the row title, so repeating it wastes the
// line. Groups need the name to tell speakers apart.
if (item.kind === "group" && m.sender_name) return `${m.sender_name}: ${body}`;
return body;
}
export function ChatListItem({
item,
selfId,
highlighted = false,
}: {
item: InboxItem;
selfId: string;
highlighted?: boolean;
}) {
const muted = isMuted(item);
const unread = item.unread > 0;
return (
<li>
<Link
href={`/chats/${item.id}`}
className={cn(
"flex items-center gap-3 px-4 py-3 transition-colors hover:bg-muted/60",
highlighted && "bg-primary/5",
)}
>
{item.kind === "ai" ? (
<span
aria-hidden
className="inline-flex size-10 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary"
>
<KautilyaIcon className="size-5" />
</span>
) : (
<UserAvatar
src={
item.kind === "group"
? groupAvatarUrl(item.id, item.avatar_key)
: item.peer?.avatar_url
}
name={conversationTitle(item)}
className="size-10 shrink-0"
/>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span
className={cn(
"truncate text-sm text-foreground",
unread ? "font-semibold" : "font-medium",
)}
>
{conversationTitle(item)}
</span>
{muted && (
<BellOff
className="size-3.5 shrink-0 text-muted-foreground"
aria-label="Muted"
/>
)}
</div>
<p
className={cn(
"truncate text-sm",
unread ? "text-foreground" : "text-muted-foreground",
)}
>
{previewText(item, selfId)}
</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
{item.last_message_at && (
<span className="text-xs text-muted-foreground">
{timeAgo(item.last_message_at)}
</span>
)}
{unread && (
<span
className={cn(
"inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-xs font-semibold",
// A muted thread still counts, but shouldn't shout.
muted
? "bg-muted text-muted-foreground"
: "bg-primary text-primary-foreground dark:text-white",
)}
>
{item.unread > 99 ? "99+" : item.unread}
</span>
)}
</div>
</Link>
</li>
);
}
+153
View File
@@ -0,0 +1,153 @@
"use client";
/**
* The relationship block on a chat profile: how long you've been connected,
* which way the follow runs, mute, and blocking.
*
* One-way follows are stated plainly rather than collapsed into "friends" —
* "they follow you" and "you follow each other" are different facts, and the
* profile is where someone goes to check exactly that.
*
* Centred, to sit under the avatar and name above it — which are centred too.
*/
import { useState } from "react";
import { BellOff, Bell, ShieldBan, ShieldCheck } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { relationships } from "@/lib/social";
import {
chat,
followSummary,
isMuted,
type ConversationDetail,
type MuteDuration,
} from "@/lib/chat";
function friendsSinceLabel(iso?: string | null): string | null {
if (!iso) return null;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
return `Connected since ${d.toLocaleDateString(undefined, {
month: "long",
year: "numeric",
})}`;
}
export function ChatProfileHeader({
conversationId,
detail,
onChanged,
}: {
conversationId: string;
detail: ConversationDetail;
onChanged: () => void;
}) {
const [busy, setBusy] = useState(false);
const rel = detail.relationship;
const peer = detail.peer;
const muted = isMuted(detail);
const setMute = async (duration: MuteDuration) => {
setBusy(true);
try {
await chat.setMute(conversationId, duration);
toast.success(duration === "off" ? "Chat unmuted" : "Chat muted");
onChanged();
} catch {
toast.error("Could not change mute settings");
} finally {
setBusy(false);
}
};
const toggleBlock = async () => {
if (!peer) return;
const blocking = !rel?.blocked;
if (blocking && !window.confirm(`Block ${peer.name}? They won't be able to message you.`)) {
return;
}
setBusy(true);
try {
if (blocking) await relationships.block(peer.handle);
else await relationships.unblock(peer.handle);
toast.success(blocking ? `Blocked ${peer.name}` : `Unblocked ${peer.name}`);
onChanged();
} catch {
toast.error("Could not update block settings");
} finally {
setBusy(false);
}
};
return (
<div className="space-y-4 p-4">
{peer && rel && (
<div className="space-y-1 text-center text-sm">
<p className="text-foreground">{followSummary(rel, peer.name)}</p>
{friendsSinceLabel(rel.friends_since) && (
<p className="text-muted-foreground">
{friendsSinceLabel(rel.friends_since)}
</p>
)}
{rel.blocked_by && (
<p className="text-muted-foreground">
{peer.name} has blocked you.
</p>
)}
</div>
)}
<div className="flex flex-wrap justify-center gap-2">
{muted ? (
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={() => setMute("off")}
>
<Bell className="size-4" /> Unmute
</Button>
) : (
<>
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={() => setMute("8h")}
>
<BellOff className="size-4" /> Mute 8h
</Button>
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={() => setMute("always")}
>
Mute always
</Button>
</>
)}
{peer && (
<Button
variant={rel?.blocked ? "outline" : "destructive"}
size="sm"
disabled={busy}
onClick={toggleBlock}
>
{rel?.blocked ? (
<>
<ShieldCheck className="size-4" /> Unblock
</>
) : (
<>
<ShieldBan className="size-4" /> Block
</>
)}
</Button>
)}
</div>
</div>
);
}
+85
View File
@@ -0,0 +1,85 @@
"use client";
/**
* The row of files staged in the composer before a message is sent.
*
* Each chip shows its own upload state, because one failure among several
* shouldn't discard the rest — the user removes the failed one and sends.
*/
import { AlertCircle, FileText, Loader2, X } from "lucide-react";
import { cn } from "@/lib/utils";
import type { UploadedAttachment } from "@/lib/chat";
export type StagedAttachment = {
/** Client-side id; the upload has no server identity until it is sent. */
id: string;
file: File;
/** Object URL for an image thumbnail, revoked when the chip is removed. */
previewUrl?: string;
status: "uploading" | "ready" | "failed";
uploaded?: UploadedAttachment;
};
export function ComposerAttachments({
items,
onRemove,
}: {
items: StagedAttachment[];
onRemove: (id: string) => void;
}) {
if (items.length === 0) return null;
return (
<div className="flex flex-wrap gap-2 px-4 pt-2">
{items.map((item) => (
<div
key={item.id}
className={cn(
"relative flex items-center gap-2 rounded-lg border border-border bg-muted/40 p-1.5",
item.status === "failed" && "border-destructive/60",
)}
>
{item.previewUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={item.previewUrl}
alt=""
className="size-10 rounded object-cover"
/>
) : (
<span className="inline-flex size-10 items-center justify-center rounded bg-background text-muted-foreground">
<FileText className="size-4" />
</span>
)}
<span className="max-w-32 truncate text-xs text-foreground">
{item.file.name}
</span>
{item.status === "uploading" && (
<Loader2
className="size-3.5 shrink-0 animate-spin text-muted-foreground"
aria-label="Uploading"
/>
)}
{item.status === "failed" && (
<AlertCircle
className="size-3.5 shrink-0 text-destructive"
aria-label="Upload failed"
/>
)}
<button
type="button"
onClick={() => onRemove(item.id)}
aria-label={`Remove ${item.file.name}`}
className="ml-1 rounded-md p-1 text-muted-foreground hover:text-foreground"
>
<X className="size-3.5" />
</button>
</div>
))}
</div>
);
}
+135
View File
@@ -0,0 +1,135 @@
"use client";
/**
* Pick a thread to forward a message into.
*
* The destination list is the caller's own inbox, so there is no way to name a
* thread they aren't in — and the BFF re-checks both memberships anyway, since
* a client-supplied conversation id proves nothing.
*/
import { useEffect, useState } from "react";
import { X } from "lucide-react";
import { toast } from "sonner";
import { UserAvatar } from "@/components/ui/user-avatar";
import {
chat,
conversationTitle,
groupAvatarUrl,
type ChatMessage,
type InboxItem,
} from "@/lib/chat";
export function ForwardDialog({
message,
onClose,
}: {
message: ChatMessage;
onClose: () => void;
}) {
const [threads, setThreads] = useState<InboxItem[]>([]);
const [busy, setBusy] = useState<string | null>(null);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
useEffect(() => {
let cancelled = false;
chat
.inbox("chats")
.then((rows) => {
if (cancelled) return;
setThreads(rows ?? []);
setState("ready");
})
.catch(() => !cancelled && setState("error"));
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const forward = async (to: InboxItem) => {
setBusy(to.id);
try {
await chat.forward(to.id, message.id);
toast.success(`Forwarded to ${conversationTitle(to)}`);
onClose();
} catch {
toast.error("Could not forward the message");
} finally {
setBusy(null);
}
};
return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 sm:items-center">
<div
className="absolute inset-0"
onClick={onClose}
aria-hidden
/>
<div className="relative z-10 max-h-[70vh] w-full max-w-md overflow-hidden rounded-t-2xl border border-border bg-background sm:rounded-2xl">
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold text-foreground">Forward to…</h2>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="rounded-md p-1 text-muted-foreground hover:text-foreground"
>
<X className="size-4" />
</button>
</div>
<div className="max-h-[55vh] overflow-y-auto">
{state === "error" ? (
<p className="p-4 text-sm text-destructive">
Could not load your chats.
</p>
) : state === "loading" ? (
<p className="p-4 text-sm text-muted-foreground">Loading…</p>
) : threads.length === 0 ? (
<p className="p-4 text-sm text-muted-foreground">
You have no other chats yet.
</p>
) : (
<ul className="divide-y divide-border">
{threads.map((t) => (
<li key={t.id}>
<button
type="button"
disabled={busy !== null}
onClick={() => forward(t)}
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-muted/60 disabled:opacity-60"
>
<UserAvatar
src={
t.kind === "group"
? groupAvatarUrl(t.id, t.avatar_key)
: t.peer?.avatar_url
}
name={conversationTitle(t)}
className="size-9 shrink-0"
/>
<span className="min-w-0 flex-1 truncate text-sm text-foreground">
{conversationTitle(t)}
</span>
{busy === t.id && (
<span className="text-xs text-muted-foreground">
Sending…
</span>
)}
</button>
</li>
))}
</ul>
)}
</div>
</div>
</div>
);
}
+163
View File
@@ -0,0 +1,163 @@
"use client";
/**
* The group profile's Members tab: who is in the group, their role, and the
* admin controls for changing that.
*
* Which controls appear follows the same rules the BFF enforces — the server is
* the authority, this just avoids offering buttons that would be refused.
*/
import { useState } from "react";
import { Plus, ShieldMinus, ShieldPlus, UserMinus } from "lucide-react";
import { toast } from "sonner";
import { UserAvatar } from "@/components/ui/user-avatar";
import { Button } from "@/components/ui/button";
import { AddMembersDialog } from "@/components/chat/add-members-dialog";
import { chat, isGroupAdmin, type ConversationDetail } from "@/lib/chat";
const ROLE_LABEL: Record<string, string> = {
owner: "Owner",
admin: "Admin",
member: "",
};
export function GroupMembersTab({
conversationId,
detail,
selfId,
onChanged,
}: {
conversationId: string;
detail: ConversationDetail;
selfId: string;
onChanged: () => void;
}) {
const [busy, setBusy] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const iAmAdmin = isGroupAdmin(detail.my_role);
const iAmOwner = detail.my_role === "owner";
const act = async (handle: string, fn: () => Promise<unknown>, ok: string) => {
setBusy(handle);
try {
await fn();
toast.success(ok);
onChanged();
} catch {
toast.error("That didn't work");
} finally {
setBusy(null);
}
};
return (
<div className="space-y-3">
{iAmAdmin && (
<Button variant="outline" size="sm" onClick={() => setAdding(true)}>
<Plus className="size-4" /> Add members
</Button>
)}
<ul className="divide-y divide-border rounded-2xl border border-border overflow-hidden">
{detail.participants.map((m) => {
const isSelf = m.id === selfId;
// The owner cannot be demoted or removed by anyone, and only the
// owner may change roles at all.
const canChangeRole = iAmOwner && !isSelf && m.role !== "owner";
// An admin may remove members; only the owner may remove an admin.
const canRemove =
!isSelf &&
m.role !== "owner" &&
(iAmOwner || (iAmAdmin && m.role !== "admin"));
return (
<li key={m.id} className="flex items-center gap-3 px-4 py-3">
<UserAvatar
src={m.avatar_url}
name={m.name}
className="size-9 shrink-0"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{m.name}
{isSelf && (
<span className="text-muted-foreground"> (you)</span>
)}
</p>
<p className="truncate text-xs text-muted-foreground">
@{m.handle}
</p>
</div>
{m.role && ROLE_LABEL[m.role] && (
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
{ROLE_LABEL[m.role]}
</span>
)}
{canChangeRole && (
<button
type="button"
disabled={busy === m.handle}
aria-label={
m.role === "admin" ? "Remove admin" : "Make admin"
}
onClick={() =>
act(
m.handle,
() =>
chat.setMemberRole(
conversationId,
m.handle,
m.role === "admin" ? "member" : "admin",
),
m.role === "admin"
? `${m.name} is no longer an admin`
: `${m.name} is now an admin`,
)
}
className="shrink-0 rounded-md p-1.5 text-muted-foreground hover:text-foreground disabled:opacity-40"
>
{m.role === "admin" ? (
<ShieldMinus className="size-4" />
) : (
<ShieldPlus className="size-4" />
)}
</button>
)}
{canRemove && (
<button
type="button"
disabled={busy === m.handle}
aria-label={`Remove ${m.name}`}
onClick={() => {
if (!window.confirm(`Remove ${m.name} from the group?`)) return;
void act(
m.handle,
() => chat.removeMember(conversationId, m.handle),
`${m.name} was removed`,
);
}}
className="shrink-0 rounded-md p-1.5 text-destructive hover:opacity-80 disabled:opacity-40"
>
<UserMinus className="size-4" />
</button>
)}
</li>
);
})}
</ul>
{adding && (
<AddMembersDialog
conversationId={conversationId}
existing={detail.participants.map((p) => p.handle)}
onClose={() => setAdding(false)}
onAdded={onChanged}
/>
)}
</div>
);
}
+268
View File
@@ -0,0 +1,268 @@
"use client";
/**
* The group profile's Settings tab: rename, choose who may post, and leave.
*
* Editing is admin-only; everyone else sees the same facts read-only, so the
* tab still answers "why can't I post here?" for a plain member.
*/
import { useRef, useState } from "react";
import { Camera, Loader2, LogOut } from "lucide-react";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { UserAvatar } from "@/components/ui/user-avatar";
import { compressImage } from "@/lib/compress-image";
import {
chat,
groupAvatarUrl,
isGroupAdmin,
uploadGroupAvatar,
type ConversationDetail,
type SendPolicy,
} from "@/lib/chat";
import { cn } from "@/lib/utils";
export function GroupSettingsTab({
conversationId,
detail,
selfHandle,
onChanged,
}: {
conversationId: string;
detail: ConversationDetail;
selfHandle: string;
onChanged: () => void;
}) {
const router = useRouter();
const [title, setTitle] = useState(detail.title ?? "");
const [busy, setBusy] = useState(false);
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const iAmAdmin = isGroupAdmin(detail.my_role);
const renamed = title.trim() !== (detail.title ?? "").trim();
/**
* Upload first, then point the group at the new key. Two steps because the
* bytes go straight to storage and never through the BFF — the same path
* message attachments take.
*/
const changePhoto = async (file: File) => {
setUploading(true);
try {
const compressed = await compressImage(file).catch(() => file);
const key = await uploadGroupAvatar(conversationId, compressed);
await chat.updateGroup(conversationId, { avatar_key: key });
toast.success("Group photo updated");
onChanged();
} catch {
toast.error("Could not update the group photo");
} finally {
setUploading(false);
}
};
const rename = async () => {
if (!renamed || title.trim() === "") return;
setBusy(true);
try {
await chat.updateGroup(conversationId, { title: title.trim() });
toast.success("Group renamed");
onChanged();
} catch {
toast.error("Could not rename the group");
} finally {
setBusy(false);
}
};
const setPolicy = async (policy: SendPolicy) => {
if (policy === detail.send_policy) return;
setBusy(true);
try {
await chat.updateGroup(conversationId, { send_policy: policy });
toast.success(
policy === "admins"
? "Only admins can send messages now"
: "Everyone can send messages now",
);
onChanged();
} catch {
toast.error("Could not change the setting");
} finally {
setBusy(false);
}
};
const leave = async () => {
if (
!window.confirm(
detail.my_role === "owner"
? "Leave this group? Another admin will become the owner."
: "Leave this group?",
)
) {
return;
}
setBusy(true);
try {
await chat.removeMember(conversationId, selfHandle);
toast.success("You left the group");
router.push("/chats");
} catch {
toast.error("Could not leave the group");
setBusy(false);
}
};
return (
<div className="space-y-6">
<section className="space-y-2">
<h3 className="text-sm font-medium text-foreground">Group photo</h3>
<div className="flex items-center gap-3">
<div className="relative">
<UserAvatar
src={groupAvatarUrl(conversationId, detail.avatar_key)}
name={detail.title ?? "Group"}
className="size-16"
textClassName="text-lg"
/>
{uploading && (
<span className="absolute inset-0 flex items-center justify-center rounded-full bg-black/40">
<Loader2 className="size-5 animate-spin text-white" />
</span>
)}
</div>
{iAmAdmin ? (
<>
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void changePhoto(file);
// Reset so re-picking the same file still fires a change.
e.target.value = "";
}}
/>
<Button
variant="outline"
size="sm"
disabled={uploading || busy}
onClick={() => fileRef.current?.click()}
>
<Camera className="size-4" />
{detail.avatar_key ? "Change photo" : "Add photo"}
</Button>
</>
) : (
<p className="text-xs text-muted-foreground">
Only group admins can change the photo.
</p>
)}
</div>
</section>
<section className="space-y-2">
<h3 className="text-sm font-medium text-foreground">Group name</h3>
{iAmAdmin ? (
<div className="flex gap-2">
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={120}
className="flex-1 rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm outline-none pointer-coarse:text-base focus:ring-1 focus:ring-primary/40"
/>
<Button size="sm" disabled={!renamed || busy} onClick={rename}>
Save
</Button>
</div>
) : (
<p className="text-sm text-muted-foreground">{detail.title}</p>
)}
</section>
<section className="space-y-2">
<h3 className="text-sm font-medium text-foreground">
Who can send messages
</h3>
<div className="space-y-2">
<PolicyOption
active={detail.send_policy === "everyone"}
disabled={!iAmAdmin || busy}
onClick={() => setPolicy("everyone")}
title="Everyone"
description="Any member can send messages."
/>
<PolicyOption
active={detail.send_policy === "admins"}
disabled={!iAmAdmin || busy}
onClick={() => setPolicy("admins")}
title="Only admins"
description="Members can read but not post — an announcement group."
/>
</div>
{!iAmAdmin && (
<p className="text-xs text-muted-foreground">
Only group admins can change these settings.
</p>
)}
</section>
<section className="space-y-2">
<h3 className="text-sm font-medium text-foreground">Leave</h3>
<Button
variant="destructive"
size="sm"
disabled={busy}
onClick={leave}
>
<LogOut className="size-4" /> Leave group
</Button>
{detail.my_role === "owner" && (
<p className="text-xs text-muted-foreground">
You own this group. If you leave, the longest-serving admin becomes
the owner.
</p>
)}
</section>
</div>
);
}
function PolicyOption({
active,
disabled,
onClick,
title,
description,
}: {
active: boolean;
disabled: boolean;
onClick: () => void;
title: string;
description: string;
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
aria-pressed={active}
className={cn(
"flex w-full flex-col items-start gap-0.5 rounded-lg border p-3 text-left transition-colors",
active
? "border-primary bg-primary/5"
: "border-border hover:bg-muted/60",
disabled && "cursor-default opacity-70 hover:bg-transparent",
)}
>
<span className="text-sm font-medium text-foreground">{title}</span>
<span className="text-xs text-muted-foreground">{description}</span>
</button>
);
}
+89
View File
@@ -0,0 +1,89 @@
"use client";
/**
* The link card shown under a message, and in the composer before sending.
*
* Everything rendered here was fetched by the BFF. A client can choose whether
* a card appears, never what it says — otherwise any link could be dressed up
* as any site.
*/
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import type { LinkPreview } from "@/lib/chat";
export function LinkPreviewCard({
preview,
onDismiss,
compact = false,
className,
}: {
preview: LinkPreview;
/** When set, a dismiss control is shown (composer only). */
onDismiss?: () => void;
compact?: boolean;
className?: string;
}) {
const body = (
<div className="flex min-w-0 gap-2">
{preview.image_url && (
// Remote preview images come from arbitrary hosts, which next/image
// would need configured per-domain.
// eslint-disable-next-line @next/next/no-img-element
<img
src={preview.image_url}
alt=""
loading="lazy"
className={cn(
"shrink-0 rounded object-cover",
compact ? "size-10" : "size-14",
)}
/>
)}
<div className="min-w-0 flex-1">
{preview.site_name && (
<p className="truncate text-[11px] uppercase tracking-wide opacity-70">
{preview.site_name}
</p>
)}
<p className="line-clamp-2 text-xs font-medium">
{preview.title || preview.url}
</p>
{!compact && preview.description && (
<p className="line-clamp-2 text-[11px] opacity-80">
{preview.description}
</p>
)}
</div>
{onDismiss && (
<button
type="button"
onClick={onDismiss}
aria-label="Remove link preview"
className="shrink-0 self-start rounded-md p-1 opacity-70 hover:opacity-100"
>
<X className="size-3.5" />
</button>
)}
</div>
);
const shell = cn(
"rounded-lg border-l-2 border-current/30 bg-black/5 px-2 py-1.5 dark:bg-white/10",
className,
);
// In the composer the card is being edited, so it isn't a link; under a
// message it is.
if (onDismiss) return <div className={shell}>{body}</div>;
return (
<a
href={preview.url}
target="_blank"
rel="noopener noreferrer"
className={cn(shell, "block hover:opacity-90")}
>
{body}
</a>
);
}
+103
View File
@@ -0,0 +1,103 @@
"use client";
/**
* The media tiles inside a bubble.
*
* One item fills the bubble; several tile into a 2×2. Beyond four, the fourth
* tile carries a "+N more" overlay rather than growing the bubble — a message
* with twenty photos should still be one glanceable block.
*
* Every tile is square so the grid stays even regardless of what was sent;
* portrait and landscape both crop to the same cell.
*/
import { Play } from "lucide-react";
import { attachmentUrl, type Attachment } from "@/lib/chat";
/** Tiles shown before the rest collapse into the "+N more" overlay. */
const MAX_TILES = 4;
export function MediaGrid({
items,
onOpen,
}: {
items: Attachment[];
/** Opens the lightbox at this index. */
onOpen: (index: number) => void;
}) {
if (items.length === 0) return null;
const visible = items.slice(0, MAX_TILES);
const hidden = items.length - visible.length;
if (items.length === 1) {
return (
<button
type="button"
onClick={() => onOpen(0)}
className="block overflow-hidden rounded-lg"
>
<Tile item={items[0]} className="max-h-72 w-auto" />
</button>
);
}
return (
// Two columns throughout: two items make a row, three or four make the
// 2×2. A three-item grid deliberately leaves the fourth cell empty rather
// than stretching one tile — an uneven crop reads worse than a gap.
<div className="grid w-56 grid-cols-2 gap-0.5 overflow-hidden rounded-lg sm:w-64">
{visible.map((item, i) => {
const isLast = i === visible.length - 1;
return (
<button
key={item.id}
type="button"
onClick={() => onOpen(i)}
className="relative aspect-square overflow-hidden"
>
<Tile item={item} className="size-full object-cover" />
{isLast && hidden > 0 && (
<span className="absolute inset-0 flex items-center justify-center bg-black/60 text-sm font-medium text-white">
+{hidden} more
</span>
)}
</button>
);
})}
</div>
);
}
function Tile({ item, className }: { item: Attachment; className?: string }) {
if (item.kind === "video") {
return (
<span className="relative block size-full">
<video
src={attachmentUrl(item.id)}
// Just the poster frame: the lightbox is where it plays.
preload="metadata"
muted
playsInline
className={className}
/>
<span className="absolute inset-0 flex items-center justify-center">
<span className="rounded-full bg-black/50 p-2">
<Play className="size-4 fill-white text-white" />
</span>
</span>
</span>
);
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={attachmentUrl(item.id)}
alt={item.file_name ?? "Shared media"}
loading="lazy"
width={item.width ?? undefined}
height={item.height ?? undefined}
className={className}
/>
);
}
+204
View File
@@ -0,0 +1,204 @@
"use client";
/**
* Full-screen viewer for a message's media.
*
* A horizontally scroll-snapped carousel rather than a slide animation: swipe
* comes free on touch, the scrollbar reflects position, and the browser handles
* momentum. Arrow keys and the on-screen buttons scroll to a slide rather than
* setting state, so every input path drives the same mechanism.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { ChevronLeft, ChevronRight, Download, Loader2, X } from "lucide-react";
import { toast } from "sonner";
import { attachmentUrl, type Attachment } from "@/lib/chat";
import { cn } from "@/lib/utils";
export function MediaLightbox({
items,
startIndex = 0,
onClose,
}: {
items: Attachment[];
startIndex?: number;
onClose: () => void;
}) {
const trackRef = useRef<HTMLDivElement>(null);
const [index, setIndex] = useState(startIndex);
const [downloading, setDownloading] = useState(false);
const scrollTo = useCallback((i: number, smooth = true) => {
const track = trackRef.current;
if (!track) return;
track.scrollTo({
left: i * track.clientWidth,
behavior: smooth ? "smooth" : "auto",
});
}, []);
// Jump to the tapped item before the first paint, so opening the third photo
// doesn't visibly scroll from the first.
useEffect(() => {
scrollTo(startIndex, false);
}, [startIndex, scrollTo]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
if (e.key === "ArrowRight") scrollTo(Math.min(index + 1, items.length - 1));
if (e.key === "ArrowLeft") scrollTo(Math.max(index - 1, 0));
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [index, items.length, onClose, scrollTo]);
const current = items[index];
/**
* Download through a blob so the file keeps its name. The `download`
* attribute is ignored cross-origin, and chat media is served from the BFF,
* so a plain link would just navigate. If the fetch is refused (the signed
* storage URL needs CORS), fall back to opening it — better than nothing.
*/
const download = async () => {
if (!current) return;
setDownloading(true);
const href = attachmentUrl(current.id);
try {
const res = await fetch(href, { credentials: "include" });
if (!res.ok) throw new Error(String(res.status));
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = objectUrl;
a.download = current.file_name ?? `attachment-${current.id}`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(objectUrl);
} catch {
window.open(href, "_blank", "noopener,noreferrer");
toast.message("Opened in a new tab");
} finally {
setDownloading(false);
}
};
return (
<div className="fixed inset-0 z-50 flex flex-col bg-black/95">
<div className="flex items-center justify-between gap-2 px-3 py-2 text-white">
<button
type="button"
onClick={onClose}
aria-label="Close"
className="rounded-md p-2 hover:bg-white/10"
>
<X className="size-5" />
</button>
{items.length > 1 && (
<span className="text-xs tabular-nums text-white/70">
{index + 1} / {items.length}
</span>
)}
<button
type="button"
onClick={download}
disabled={downloading}
aria-label="Download"
className="rounded-md p-2 hover:bg-white/10 disabled:opacity-50"
>
{downloading ? (
<Loader2 className="size-5 animate-spin" />
) : (
<Download className="size-5" />
)}
</button>
</div>
<div className="relative flex-1 overflow-hidden">
<div
ref={trackRef}
onScroll={(e) => {
const el = e.currentTarget;
const next = Math.round(el.scrollLeft / el.clientWidth);
if (next !== index) setIndex(next);
}}
className="flex h-full snap-x snap-mandatory overflow-x-auto overflow-y-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{items.map((a) => (
<div
key={a.id}
className="flex h-full w-full shrink-0 snap-center items-center justify-center p-4"
>
{a.kind === "video" ? (
<video
src={attachmentUrl(a.id)}
controls
playsInline
className="max-h-full max-w-full"
/>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={attachmentUrl(a.id)}
alt={a.file_name ?? "Shared media"}
className="max-h-full max-w-full object-contain"
/>
)}
</div>
))}
</div>
{items.length > 1 && (
<>
<Arrow
side="left"
disabled={index === 0}
onClick={() => scrollTo(index - 1)}
/>
<Arrow
side="right"
disabled={index === items.length - 1}
onClick={() => scrollTo(index + 1)}
/>
</>
)}
</div>
</div>
);
}
function Arrow({
side,
disabled,
onClick,
}: {
side: "left" | "right";
disabled: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={side === "left" ? "Previous" : "Next"}
className={cn(
// Pointer devices only: on touch the swipe is the gesture, and arrows
// would sit over the image for no reason.
"absolute top-1/2 hidden -translate-y-1/2 rounded-full bg-white/10 p-2 text-white",
"hover:bg-white/20 disabled:opacity-0 sm:block",
side === "left" ? "left-3" : "right-3",
)}
>
{side === "left" ? (
<ChevronLeft className="size-5" />
) : (
<ChevronRight className="size-5" />
)}
</button>
);
}
+134
View File
@@ -0,0 +1,134 @@
"use client";
/**
* Attachments under a message bubble.
*
* Media (images and videos) tile into a grid and open in a lightbox; links and
* files keep their own row treatments, since a link card and a photo are read
* very differently.
*
* Media is fetched through the BFF's stable /chat/attachments/{id} URL, which
* re-checks membership and redirects to a short-lived signed URL. The object
* itself lives in a private bucket and is never public.
*/
import { useState } from "react";
import { FileText } from "lucide-react";
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";
function humanSize(bytes?: number | null): string {
if (!bytes) return "";
const units = ["B", "KB", "MB", "GB"];
let n = bytes;
let i = 0;
while (n >= 1024 && i < units.length - 1) {
n /= 1024;
i += 1;
}
return `${n < 10 && i > 0 ? n.toFixed(1) : Math.round(n)} ${units[i]}`;
}
export function MessageAttachments({
attachments,
}: {
attachments: Attachment[];
}) {
const [lightboxAt, setLightboxAt] = useState<number | null>(null);
if (!attachments || attachments.length === 0) return null;
// Only images and videos tile; everything else keeps its own row.
const media = attachments.filter(
(a) => a.kind === "image" || a.kind === "video",
);
const rest = attachments.filter(
(a) => a.kind !== "image" && a.kind !== "video",
);
return (
<>
<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 && (
<MediaGrid items={media} onOpen={(i) => setLightboxAt(i)} />
)}
{rest.map((a) => {
switch (a.kind) {
case "entity":
return <SharedPostCard key={a.id} attachment={a} />;
case "quote":
return <QuoteCard key={a.id} attachment={a} />;
case "link":
return (
<LinkPreviewCard
key={a.id}
compact
preview={{
url: a.url ?? "",
title: a.title ?? undefined,
description: a.description ?? undefined,
site_name: a.site_name ?? undefined,
image_url: a.image_url ?? undefined,
}}
/>
);
case "audio":
return (
<audio
key={a.id}
src={attachmentUrl(a.id)}
controls
className="w-56"
/>
);
default:
return (
<a
key={a.id}
href={attachmentUrl(a.id)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 rounded-lg bg-black/5 px-2 py-1.5 text-xs hover:opacity-90 dark:bg-white/10"
>
<FileText className="size-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">
{a.file_name ?? "Attachment"}
</span>
{a.size_bytes ? (
<span className="shrink-0 opacity-70">
{humanSize(a.size_bytes)}
</span>
) : null}
</a>
);
}
})}
</div>
{lightboxAt !== null && (
<MediaLightbox
items={media}
startIndex={lightboxAt}
onClose={() => setLightboxAt(null)}
/>
)}
</>
);
}
+208
View File
@@ -0,0 +1,208 @@
"use client";
/**
* Owns every pointer gesture on a chat bubble: swipe right to reply, and
* long-press (or right-click) to open the context menu.
*
* They live in ONE component because they share a pointer stream and must
* arbitrate between themselves. Two independent listeners would both claim the
* same press — you would get a menu in the middle of a swipe, or a swipe that
* never fires because the menu opened first.
*
* Swipe-to-reply behaves as before:
*
* Details that make the gesture feel right rather than fight the page:
*
* • Only horizontal drags are captured. A gesture that starts out mostly
* vertical is released back to the scroller immediately, so swiping never
* steals a scroll.
* • Rightward only, with resistance past the trigger point, so the bubble
* can't be dragged across the screen.
* • The reply fires on release, not on crossing the threshold, so a drag can
* still be cancelled by sliding back.
*
* Pointer Events cover touch and mouse, so the same code gives desktop a
* click-drag; the context menu in a later phase adds a non-gesture route.
*/
import { useCallback, useRef, useState } from "react";
import { Reply } from "lucide-react";
import { cn } from "@/lib/utils";
/** Drag distance that arms the reply. */
const TRIGGER_PX = 56;
/** Hard stop, so the bubble never travels far. */
const MAX_PX = 80;
/** Horizontal movement needed before we claim the gesture from the scroller. */
const CLAIM_PX = 10;
/** Hold time before a stationary press becomes a context menu. */
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;
}) {
const [offset, setOffset] = useState(0);
const [dragging, setDragging] = useState(false);
const start = useRef<{ x: number; y: number } | null>(null);
// null = undecided, true = ours, false = the scroller's
const claimed = useRef<boolean | 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.
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) {
clearTimeout(holdTimer.current);
holdTimer.current = null;
}
}, []);
const reset = useCallback(() => {
cancelHold();
start.current = null;
claimed.current = null;
setDragging(false);
setOffset(0);
}, [cancelHold]);
const onPointerDown = (e: React.PointerEvent) => {
if (disabled || (e.pointerType === "mouse" && e.button !== 0)) return;
start.current = { x: e.clientX, y: e.clientY };
claimed.current = null;
consumed.current = false;
// A stationary hold opens the menu. Touch only: a mouse has right-click,
// and making a held left-click open a menu would surprise desktop users.
if (e.pointerType !== "mouse") {
const point = { x: e.clientX, y: e.clientY };
cancelHold();
holdTimer.current = setTimeout(() => {
consumed.current = true;
reset();
onMenu(point);
}, LONG_PRESS_MS);
}
};
const onPointerMove = (e: React.PointerEvent) => {
if (!start.current || disabled) return;
const dx = e.clientX - start.current.x;
const dy = e.clientY - start.current.y;
// Any real movement means this is a drag, not a hold.
if (Math.abs(dx) > LONG_PRESS_SLOP_PX || Math.abs(dy) > LONG_PRESS_SLOP_PX) {
cancelHold();
}
if (claimed.current === null) {
if (Math.abs(dx) < CLAIM_PX && Math.abs(dy) < CLAIM_PX) return;
// Vertical intent, or a leftward drag: hand it back to the scroller and
// stay out of the way for the rest of this gesture.
claimed.current = Math.abs(dx) > Math.abs(dy) && dx > 0;
if (!claimed.current) {
start.current = null;
return;
}
e.currentTarget.setPointerCapture?.(e.pointerId);
setDragging(true);
}
// Resistance past the trigger point.
const travel = dx <= TRIGGER_PX ? dx : TRIGGER_PX + (dx - TRIGGER_PX) * 0.25;
setOffset(Math.max(0, Math.min(travel, MAX_PX)));
};
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();
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();
};
const armed = offset >= TRIGGER_PX;
return (
<div
// Full width so the whole row is draggable — WhatsApp lets you start the
// gesture anywhere on the line, including the empty space opposite the
// bubble. The bubble caps its own width inside this.
className="relative w-full"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={reset}
onContextMenu={(e) => {
if (disabled) return;
e.preventDefault();
cancelHold();
onMenu({ x: e.clientX, y: e.clientY });
}}
// Let the browser own vertical panning; we only ever take horizontal.
style={{ touchAction: "pan-y" }}
>
<span
aria-hidden
className={cn(
"pointer-events-none absolute inset-y-0 left-0 flex items-center transition-opacity",
offset > 8 ? "opacity-100" : "opacity-0",
)}
>
<span
className={cn(
"inline-flex size-7 items-center justify-center rounded-full transition-colors",
armed ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground",
)}
>
<Reply className="size-3.5" />
</span>
</span>
<div
style={{ transform: `translateX(${offset}px)` }}
className={cn(!dragging && "transition-transform duration-200")}
>
{children}
</div>
</div>
);
}
+396
View File
@@ -0,0 +1,396 @@
"use client";
/**
* One stack of consecutive messages from a single sender, rendered the way
* Instagram does: the avatar appears once beside the last bubble, the corners
* tighten between stacked bubbles, and one timestamp closes the stack.
*/
import { Clock, Reply, RotateCcw, Star } from "lucide-react";
import { UserAvatar } from "@/components/ui/user-avatar";
import { MessageGestures } from "@/components/chat/message-gestures";
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 { StickerGrid } from "@/components/chat/sticker-grid";
import { cn } from "@/lib/utils";
import type {
Attachment,
ChatMessage,
ChatPeer,
Receipts,
ReplyPreview,
} from "@/lib/chat";
/** Time only — the day is already established by the date separator above. */
function clockTime(iso: string): string {
return new Date(iso).toLocaleTimeString(undefined, {
hour: "numeric",
minute: "2-digit",
});
}
/**
* 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");
}
/** Stickers on a message, if any. */
function stickersOn(m: ChatMessage): Attachment[] {
return (m.attachments ?? []).filter((a) => a.kind === "sticker");
}
/**
* True when a message is nothing but stickers. Those render bare — artwork
* inside a coloured bubble reads as a pasted image rather than a sticker.
*/
function isStickerOnly(m: ChatMessage): boolean {
const atts = m.attachments ?? [];
return !m.body && atts.length > 0 && atts.every((a) => a.kind === "sticker");
}
/** 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;
const last = index === total - 1;
// Only the edge nearest the sender tightens, so the stack reads as one unit.
if (mine) {
return cn(
"rounded-2xl",
!first && "rounded-tr-md",
!last && "rounded-br-md",
);
}
return cn("rounded-2xl", !first && "rounded-tl-md", !last && "rounded-bl-md");
}
/**
* The quoted line above a reply. Kept to one line: it is a pointer back to the
* original, not a second copy of it.
*/
function QuotedReply({
reply,
selfId,
nameFor,
}: {
reply: ReplyPreview;
selfId: string;
nameFor: (senderId: string | null | undefined) => string;
}) {
// The server hydrates sender_name, but it is empty for messages no user
// wrote (Kautilya, system notices) — fall back to the participant lookup so
// those quote as something meaningful rather than "Unknown".
const who =
reply.sender_id === selfId
? "You"
: reply.sender_name || nameFor(reply.sender_id);
return (
<div
// Neutral on both sides: this now sits on the page background rather
// than inside a bubble, so the old primary-foreground treatment (white
// text, for a primary bubble) would be invisible in light mode.
// Spacing comes from the column's gap — no margin of its own.
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="line-clamp-1 break-words">
{reply.deleted ? "This message was deleted" : reply.body}
</span>
</div>
);
}
export function MessageGroup({
messages,
mine,
sender,
receipts,
showAvatar,
selfId,
onReply,
onMenu,
starredIds,
onRetry,
nameFor,
onReact,
onTrailClick,
}: {
messages: ChatMessage[];
mine: boolean;
/** Who sent this group of messages. Undefined for Kautilya, system
* messages, or a member who has since left. */
sender?: ChatPeer;
receipts: Receipts | null;
/** Groups show an avatar; a DM's own side never does. */
showAvatar: boolean;
selfId: string;
onReply: (message: ChatMessage) => void;
/** Opens the context menu for a message at a viewport point. */
onMenu: (message: ChatMessage, point: { x: number; y: number }) => void;
/** Ids the caller has starred, so the bubble can show its star. */
starredIds: Set<string>;
/** Re-send a message whose delivery failed. */
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];
return (
<div
className={cn(
"flex w-full items-end gap-2",
mine ? "justify-end" : "justify-start",
)}
>
{!mine && showAvatar && (
<UserAvatar
src={sender?.avatar_url}
name={sender?.name ?? "?"}
className="size-7 shrink-0"
textClassName="text-[10px]"
/>
)}
<div
// flex-1 rather than max-w: the column spans the row so a swipe can
// start anywhere on it. min-w-0 lets long words wrap instead of
// widening the row.
className={cn(
"flex min-w-0 flex-1 flex-col gap-0.5",
mine ? "items-end" : "items-start",
)}
>
{/* Name the sender once per run of messages. An avatar alone is thin
identification in a group — two people with similar photos, or
none at all, are indistinguishable at 28px. */}
{!mine && showAvatar && (
<span className="px-1 text-[11px] font-medium text-muted-foreground">
{sender?.name ?? "Unknown"}
</span>
)}
{messages.map((m, i) => (
// A deleted message has nothing to quote, so it isn't swipeable.
<MessageGestures
key={m.id}
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 && (
<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
className={cn(
"flex w-full",
mine ? "justify-end" : "justify-start",
)}
>
<div
className={cn(
"group/bubble relative max-w-[78%] text-sm",
bubbleCorners(mine, i, messages.length),
// A shared post is a card in its own right: it brings its
// own border, rounding and background, so the bubble drops
// its chrome rather than drawing a second frame around it.
// Any caption gets the padding back on its own line below.
sharesAnEntity(m) || isStickerOnly(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.deleted_at ? (
<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. */}
{isStickerOnly(m) ? (
<StickerGrid
conversationId={m.conversation_id}
messageId={m.id}
stickers={stickersOn(m)}
mine={mine}
/>
) : (
sharesAnEntity(m) && (
<MessageAttachments attachments={m.attachments} />
)
)}
{m.body && (
<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) && !isStickerOnly(m) && (
<MessageAttachments attachments={m.attachments} />
)}
{/* An optimistic bubble for a captionless attachment has
nothing to draw until the server answers — say so rather
than render an empty box. */}
{m.pending && !m.body && !m.attachments?.length && (
<span className="italic opacity-80">
Sending attachment…
</span>
)}
{starredIds.has(m.id) && (
<Star
className="mt-1 size-3 fill-current opacity-70"
aria-label="Starred"
/>
)}
</>
)}
{/* Desktop affordance. Swiping is the mobile gesture, but a
drag is not discoverable with a mouse — and a real button is
also reachable by keyboard, which a gesture never is. */}
{!m.deleted_at && !m.pending && (
<button
type="button"
onClick={() => onReply(m)}
aria-label="Reply to this message"
className={cn(
"absolute top-1/2 hidden -translate-y-1/2 rounded-full border border-border bg-background p-1.5 text-muted-foreground shadow-sm",
"opacity-0 transition-opacity group-hover/bubble:opacity-100 focus-visible:opacity-100",
"hover:text-foreground sm:block",
mine ? "-left-9" : "-right-9",
)}
>
<Reply className="size-3.5" />
</button>
)}
</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>
))}
<div className="flex items-center gap-1 px-1">
<time
dateTime={last.created_at}
className="text-[11px] text-muted-foreground"
>
{clockTime(last.created_at)}
</time>
{/* Ticks belong only to your own messages — you can't know whether
someone else's message reached you before it did. A message still
in flight has earned no tick at all. */}
{mine && last.failed && (
<button
type="button"
onClick={() => onRetry(last)}
className="inline-flex items-center gap-1 text-[11px] font-medium text-destructive hover:underline"
>
<RotateCcw className="size-3" />
Not sent — retry
</button>
)}
{mine &&
!last.deleted_at &&
!last.failed &&
(last.pending ? (
<Clock
role="img"
aria-label="Sending"
className="size-3.5 shrink-0 text-muted-foreground"
/>
) : (
<MessageTicks state={tickState(last.seq, receipts)} />
))}
</div>
</div>
</div>
);
}
+205
View File
@@ -0,0 +1,205 @@
"use client";
/**
* The scrolling thread: date separators, grouped bubbles, and infinite scroll
* upwards into older history.
*
* Two scroll behaviours matter and are easy to get wrong:
*
* 1. Loading older messages PREPENDS content, which would otherwise shove the
* reader down the page. We record scrollHeight before the prepend and add
* the difference back afterwards, so the message they were reading stays
* under their eyes.
*
* 2. A new message should only auto-scroll if they were already at the
* bottom. Yanking someone away from history they are reading is worse than
* making them tap to catch up.
*/
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
import { groupMessages } from "@/lib/chat-grouping";
import { MessageGroup } from "@/components/chat/message-group";
import type { ChatMessage, ChatPeer, Receipts } from "@/lib/chat";
/** Distance from the bottom still considered "at the bottom". */
const AT_BOTTOM_PX = 120;
/** Distance from the top that triggers loading older history. */
const LOAD_MORE_PX = 200;
function dayLabel(iso: string): string {
const d = new Date(iso);
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(today.getDate() - 1);
const sameDay = (a: Date, b: Date) => a.toDateString() === b.toDateString();
if (sameDay(d, today)) return "Today";
if (sameDay(d, yesterday)) return "Yesterday";
return d.toLocaleDateString(undefined, {
day: "numeric",
month: "short",
year: d.getFullYear() === today.getFullYear() ? undefined : "numeric",
});
}
export function MessageList({
messages,
selfId,
senderFor,
receipts,
isGroup,
hasMore,
loadingMore,
onLoadMore,
onReply,
onMenu,
starredIds,
onRetry,
nameFor,
typingNames,
onReact,
onTrailClick,
}: {
/** Ascending by seq — oldest first. */
messages: ChatMessage[];
selfId: string;
/** Resolves a message's sender for their avatar and name. */
senderFor: (senderId: string | null | undefined) => ChatPeer | undefined;
receipts: Receipts | null;
isGroup: boolean;
hasMore: boolean;
loadingMore: boolean;
onLoadMore: () => void;
onReply: (message: ChatMessage) => void;
onMenu: (message: ChatMessage, point: { x: number; y: number }) => void;
starredIds: Set<string>;
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<HTMLDivElement>(null);
const atBottomRef = useRef(true);
// Height captured just before older messages are prepended.
const prependAnchorRef = useRef<number | null>(null);
const prevOldestRef = useRef<string | null>(null);
const prevNewestRef = useRef<string | null>(null);
const oldest = messages[0]?.id ?? null;
const newest = messages[messages.length - 1]?.id ?? null;
const onScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
atBottomRef.current =
el.scrollHeight - el.scrollTop - el.clientHeight < AT_BOTTOM_PX;
if (el.scrollTop < LOAD_MORE_PX && hasMore && !loadingMore) {
// Capture the anchor before the parent prepends, so the layout effect
// below can restore the reading position.
prependAnchorRef.current = el.scrollHeight;
onLoadMore();
}
}, [hasMore, loadingMore, onLoadMore]);
// Runs before paint, so the correction is never visible as a jump.
useLayoutEffect(() => {
const el = scrollRef.current;
if (!el) return;
const prependedOlder =
prevOldestRef.current !== null && oldest !== prevOldestRef.current;
const appendedNewer =
prevNewestRef.current !== null && newest !== prevNewestRef.current;
const firstRender = prevNewestRef.current === null && newest !== null;
if (prependedOlder && prependAnchorRef.current !== null) {
el.scrollTop += el.scrollHeight - prependAnchorRef.current;
prependAnchorRef.current = null;
} else if (firstRender) {
el.scrollTop = el.scrollHeight; // open at the newest message
} else if (appendedNewer && atBottomRef.current) {
el.scrollTop = el.scrollHeight;
}
prevOldestRef.current = oldest;
prevNewestRef.current = newest;
}, [oldest, newest, messages.length]);
// Keep pulling while the thread is still shorter than its viewport —
// otherwise a short first page leaves nothing to scroll and history stalls.
useEffect(() => {
const el = scrollRef.current;
if (!el || loadingMore || !hasMore) return;
if (el.scrollHeight <= el.clientHeight) onLoadMore();
}, [messages.length, loadingMore, hasMore, onLoadMore]);
const rows = groupMessages(messages);
return (
<div
ref={scrollRef}
onScroll={onScroll}
// overflow-x-hidden is load-bearing: `overflow-y: auto` computes the
// other axis to `auto` rather than `visible`, so the swipe-to-reply
// transform would otherwise give the thread a horizontal scrollbar.
className="flex-1 overflow-x-hidden overflow-y-auto overscroll-contain px-4 py-4"
>
{loadingMore && (
<p className="pb-3 text-center text-xs text-muted-foreground">
Loading earlier messages…
</p>
)}
<div className="flex flex-col gap-3">
{rows.map((row) =>
row.kind === "date" ? (
<div key={row.key} className="flex justify-center py-1">
<span className="rounded-full bg-muted px-3 py-1 text-[11px] font-medium text-muted-foreground">
{dayLabel(row.at)}
</span>
</div>
) : row.messages[0]?.kind === "system" ? (
<div key={row.key} className="flex justify-center">
<span className="max-w-[80%] text-center text-[11px] text-muted-foreground">
{row.messages[0].body}
</span>
</div>
) : (
<MessageGroup
key={row.key}
messages={row.messages}
mine={row.senderId === selfId}
sender={senderFor(row.senderId)}
receipts={receipts}
showAvatar={isGroup}
selfId={selfId}
onReply={onReply}
onMenu={onMenu}
starredIds={starredIds}
onRetry={onRetry}
nameFor={nameFor}
onReact={onReact}
onTrailClick={onTrailClick}
/>
),
)}
</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>
);
}
+191
View File
@@ -0,0 +1,191 @@
"use client";
/**
* The message context menu: reply, copy, star, forward, delete, report.
*
* Opened by long-press on touch and right-click on desktop, both arbitrated by
* MessageGestures. Positioned at the pointer and clamped to the viewport, so a
* bubble near an edge doesn't push the menu off-screen.
*/
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import {
Copy,
Flag,
Forward,
Reply,
Star,
StarOff,
Trash2,
} from "lucide-react";
import { ReactionRow } from "@/components/chat/reaction-row";
import { cn } from "@/lib/utils";
export type MenuPoint = { x: number; y: number };
export type MessageMenuActions = {
onReply: () => void;
onCopy: () => void;
onStar: () => void;
onForward: () => void;
onDeleteForMe: () => void;
/** Absent when the caller isn't the sender — only they may erase for all. */
onDeleteForEveryone?: () => void;
onReport?: () => void;
};
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;
}) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<MenuPoint>(point);
// Clamp after measuring, before paint, so the menu never flashes off-screen.
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const { width, height } = el.getBoundingClientRect();
setPos({
x: Math.min(Math.max(MARGIN, point.x), window.innerWidth - width - MARGIN),
y: Math.min(Math.max(MARGIN, point.y), window.innerHeight - height - MARGIN),
});
}, [point.x, point.y]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const run = (fn?: () => void) => () => {
fn?.();
onClose();
};
return (
<>
{/* Catches the next click or scroll anywhere and dismisses. */}
<div
className="fixed inset-0 z-40"
onClick={onClose}
onContextMenu={(e) => {
e.preventDefault();
onClose();
}}
/>
<div
ref={ref}
role="menu"
style={{ left: pos.x, top: pos.y }}
// 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 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)}>
Reply
</Item>
<Item icon={<Copy className="size-4" />} onClick={run(actions.onCopy)}>
Copy
</Item>
<Item
icon={
starred ? <StarOff className="size-4" /> : <Star className="size-4" />
}
onClick={run(actions.onStar)}
>
{starred ? "Remove star" : "Star"}
</Item>
<Item
icon={<Forward className="size-4" />}
onClick={run(actions.onForward)}
>
Forward
</Item>
<div className="my-1 h-px bg-border" />
<Item
icon={<Trash2 className="size-4" />}
onClick={run(actions.onDeleteForMe)}
>
Delete for me
</Item>
{actions.onDeleteForEveryone && (
<Item
danger
icon={<Trash2 className="size-4" />}
onClick={run(actions.onDeleteForEveryone)}
>
Delete for everyone
</Item>
)}
{actions.onReport && (
<Item
danger
icon={<Flag className="size-4" />}
onClick={run(actions.onReport)}
>
Report
</Item>
)}
</div>
</>
);
}
function Item({
icon,
onClick,
danger = false,
children,
}: {
icon: React.ReactNode;
onClick: () => void;
danger?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
role="menuitem"
onClick={onClick}
className={cn(
"flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors",
danger
? "text-destructive hover:bg-destructive/10"
: "text-foreground hover:bg-muted",
)}
>
{icon}
{children}
</button>
);
}
+68
View File
@@ -0,0 +1,68 @@
"use client";
/**
* Renders a message body from its entity spans.
*
* Nothing here parses markup — the server already turned the composer's
* markers into clean text plus spans, so a message cannot inject markup into
* anyone else's thread. This component only decorates ranges.
*/
import { buildEntityTree, type EntityNode } from "@/lib/chat-format";
import type { MessageEntity } from "@/lib/chat";
function renderNodes(nodes: EntityNode[], keyPrefix: string): React.ReactNode[] {
return nodes.map((node, i) => {
const key = `${keyPrefix}-${i}`;
if (node.kind === "text") return <span key={key}>{node.text}</span>;
const children = renderNodes(node.children, key);
switch (node.entity.type) {
case "bold":
return <strong key={key} className="font-semibold">{children}</strong>;
case "strike":
return <s key={key}>{children}</s>;
case "code":
return (
<code
key={key}
className="rounded bg-black/10 px-1 py-0.5 font-mono text-[0.9em] dark:bg-white/15"
>
{children}
</code>
);
case "url":
return (
<a
key={key}
href={node.entity.url}
target="_blank"
// noreferrer as well as noopener: the destination shouldn't learn
// which conversation the link was shared in.
rel="noopener noreferrer"
className="underline underline-offset-2 hover:opacity-80"
>
{children}
</a>
);
default:
return <span key={key}>{children}</span>;
}
});
}
export function MessageText({
body,
entities,
}: {
body: string;
entities: MessageEntity[];
}) {
// whitespace-pre-wrap keeps the sender's line breaks without turning the
// body into markup.
return (
<span className="whitespace-pre-wrap break-words">
{renderNodes(buildEntityTree(body, entities), "e")}
</span>
);
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
/**
* Delivery ticks for your own messages: one for sent, two for delivered, two
* in blue for read.
*
* "Read" means read by everyone — the cursors behind it are the minimum across
* the other participants, so a group only turns blue once the last member has
* caught up.
*/
import { Check, CheckCheck } from "lucide-react";
import { cn } from "@/lib/utils";
import type { TickState } from "@/lib/chat-format";
const LABEL: Record<TickState, string> = {
sent: "Sent",
delivered: "Delivered",
read: "Read by everyone",
};
export function MessageTicks({
state,
className,
}: {
state: TickState;
className?: string;
}) {
const Icon = state === "sent" ? Check : CheckCheck;
return (
<Icon
role="img"
aria-label={LABEL[state]}
className={cn(
"size-3.5 shrink-0",
state === "read" ? "text-sky-500" : "text-muted-foreground",
className,
)}
/>
);
}
+175
View File
@@ -0,0 +1,175 @@
"use client";
/**
* Create a group: name it, pick people, done. Bottom drawer on mobile, modal on
* desktop — see ResponsiveDialog.
*
* Members are named by handle, matching the member-management API — search
* results already identify people that way, so nothing needs id plumbing.
*/
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Check, Search, X } from "lucide-react";
import { toast } from "sonner";
import { useAuth } from "@/components/auth/auth-provider";
import { useMediaQuery } from "@/components/selection-toolbar/shared";
import { UserAvatar } from "@/components/ui/user-avatar";
import { Button } from "@/components/ui/button";
import { chat } from "@/lib/chat";
import { ResponsiveDialog } from "@/components/chat/responsive-dialog";
type Candidate = { handle: string; name: string; avatar_url: string | null };
export function NewGroupDialog({ onClose }: { onClose: () => void }) {
const router = useRouter();
const { user } = useAuth();
const isMobile = useMediaQuery("(max-width: 767px)");
const [title, setTitle] = useState("");
const [query, setQuery] = useState("");
// Results are stored with the query they belong to, so what's displayed can
// be derived: a stale page from a previous query simply stops matching, and
// nothing has to be cleared from an effect.
const [results, setResults] = useState<{ q: string; rows: Candidate[] }>({
q: "",
rows: [],
});
const [picked, setPicked] = useState<Map<string, Candidate>>(new Map());
const [busy, setBusy] = useState(false);
const q = query.trim();
const searchable = q.length >= 2;
// Never offer yourself: you're the group's creator, and the BFF drops the
// caller from the member list anyway — so picking yourself would look like it
// worked and then silently do nothing.
const visible =
results.q === q
? results.rows.filter((u) => u.handle !== user?.handle)
: [];
useEffect(() => {
if (!searchable) return;
const timer = setTimeout(() => {
fetch(`/api/search/users?q=${encodeURIComponent(q)}&limit=15`)
.then((r) => r.json())
.then((d: { results?: Candidate[] }) =>
setResults({ q, rows: d.results ?? [] }),
)
.catch(() => setResults({ q, rows: [] }));
}, 300);
return () => clearTimeout(timer);
}, [q, searchable]);
const toggle = (u: Candidate) =>
setPicked((prev) => {
const next = new Map(prev);
if (next.has(u.handle)) next.delete(u.handle);
else next.set(u.handle, u);
return next;
});
const create = async () => {
const name = title.trim();
if (!name) return;
setBusy(true);
try {
const conv = await chat.createGroup(name, [...picked.keys()]);
toast.success("Group created");
router.push(`/chats/${conv.id}`);
} catch {
toast.error("Could not create the group");
setBusy(false);
}
};
return (
<ResponsiveDialog
title="New group"
description="Name the group and choose who to include."
onClose={onClose}
toolbar={
<div className="space-y-3">
<input
// Not auto-focused on mobile: the keyboard would open mid-way
// through the drawer's slide-up and fight the animation.
autoFocus={!isMobile}
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={120}
placeholder="Group name"
className="w-full rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm outline-none pointer-coarse:text-base focus:ring-1 focus:ring-primary/40"
/>
<div className="flex items-center gap-2 rounded-lg bg-muted/40 px-3 py-2">
<Search className="size-4 shrink-0 text-muted-foreground" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Add people"
className="flex-1 bg-transparent text-sm outline-none pointer-coarse:text-base placeholder:text-muted-foreground"
/>
</div>
{picked.size > 0 && (
<div className="flex flex-wrap gap-1.5">
{[...picked.values()].map((u) => (
<button
key={u.handle}
type="button"
onClick={() => toggle(u)}
className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-1 text-xs text-primary"
>
{u.name}
<X className="size-3" />
</button>
))}
</div>
)}
</div>
}
footer={
<Button
className="w-full"
disabled={title.trim() === "" || busy}
onClick={create}
>
Create group
</Button>
}
>
{!searchable ? (
<p className="p-4 text-sm text-muted-foreground">
Search to add people. You can also add them later.
</p>
) : visible.length === 0 ? (
<p className="p-4 text-sm text-muted-foreground">No one found.</p>
) : (
<ul className="divide-y divide-border">
{visible.map((u) => (
<li key={u.handle}>
<button
type="button"
onClick={() => toggle(u)}
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-muted/60"
>
<UserAvatar
src={u.avatar_url}
name={u.name}
className="size-9 shrink-0"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-foreground">{u.name}</p>
<p className="truncate text-xs text-muted-foreground">
@{u.handle}
</p>
</div>
{picked.has(u.handle) && (
<Check className="size-4 shrink-0 text-primary" />
)}
</button>
</li>
))}
</ul>
)}
</ResponsiveDialog>
);
}
+46
View File
@@ -0,0 +1,46 @@
"use client";
/**
* A passage shared from the library, rendered as a quotation.
*
* Tapping it returns the reader to the exact phrase: the stored path carries
* the `?hl=` fragment that TextHighlighter resolves on arrival, scrolling to
* the passage and flashing it.
*
* 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 type { Attachment } from "@/lib/chat";
export function QuoteCard({ attachment }: { attachment: Attachment }) {
const router = useRouter();
const path = attachment.url ?? "";
const passage = attachment.description ?? "";
return (
<button
type="button"
onClick={() => path && router.push(path)}
disabled={!path}
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 leads; the citation follows in the quieter role a
reference normally takes. */}
<span className="block text-sm italic leading-snug">{passage}</span>
{attachment.title && (
<span className="mt-0.5 block truncate text-xs opacity-70">
{attachment.title}
</span>
)}
</button>
);
}
+101
View File
@@ -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>
)}
</>
);
}
+83
View File
@@ -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>
);
}
+125
View File
@@ -0,0 +1,125 @@
"use client";
/**
* The shell the chat dialogs sit in: a bottom drawer on mobile, a centred
* modal on desktop.
*
* Extracted because "Add members" and "New group" are the same shape — title,
* a fixed toolbar of inputs, a scrolling list of people, a confirm button —
* and each would otherwise carry its own copy of the drawer/modal split.
*
* Follows the pattern already used by ReportDialog and ShareModal: the same
* `useMediaQuery("(max-width: 767px)")` breakpoint, vaul's Drawer below it,
* and a portalled modal above.
*/
import type { ReactNode } from "react";
import { useEffect } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import { useMediaQuery } from "@/components/selection-toolbar/shared";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
export function ResponsiveDialog({
title,
description,
onClose,
toolbar,
footer,
children,
}: {
title: string;
description: string;
onClose: () => void;
/** Fixed block under the title — search fields, selected chips. */
toolbar?: ReactNode;
/** Pinned action row at the bottom. */
footer?: ReactNode;
/** The scrolling region between toolbar and footer. */
children: ReactNode;
}) {
const isMobile = useMediaQuery("(max-width: 767px)");
// Desktop only: Escape to dismiss, and lock the page behind the modal. The
// drawer does both itself, and doubling up on the scroll lock fights vaul's
// own body handling on iOS.
useEffect(() => {
if (isMobile) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prev;
};
}, [isMobile, onClose]);
if (isMobile) {
return (
<Drawer open onOpenChange={(o) => !o && onClose()}>
<DrawerContent className="bg-card">
<DrawerHeader className="text-left">
<DrawerTitle>{title}</DrawerTitle>
<DrawerDescription>{description}</DrawerDescription>
</DrawerHeader>
{toolbar && <div className="border-b border-border px-4 pb-3">{toolbar}</div>}
{/* Capped rather than flex-1: a drawer sizes to its content, so an
unbounded list would grow the sheet past the screen. */}
<div className="max-h-[45vh] overflow-y-auto overscroll-contain">
{children}
</div>
{footer && <div className="border-t border-border p-4">{footer}</div>}
</DrawerContent>
</Drawer>
);
}
if (typeof document === "undefined") return null;
return createPortal(
<>
<div
className="fixed inset-0 z-100 bg-foreground/40 backdrop-blur-sm animate-in fade-in-0"
onClick={onClose}
aria-hidden
/>
<div
role="dialog"
aria-modal="true"
aria-label={title}
className="fixed left-1/2 top-1/2 z-101 w-full max-w-md -translate-x-1/2 -translate-y-1/2 px-4"
>
<div className="flex max-h-[80vh] flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-xl">
<div className="flex items-start gap-3 border-b border-border px-5 py-4">
<div className="min-w-0 flex-1">
<h2 className="font-[family-name:var(--font-title)] text-lg font-semibold text-foreground">
{title}
</h2>
<p className="mt-0.5 text-sm leading-relaxed text-muted-foreground">
{description}
</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X className="size-4" />
</button>
</div>
{toolbar && <div className="border-b border-border p-3">{toolbar}</div>}
<div className="flex-1 overflow-y-auto">{children}</div>
{footer && <div className="border-t border-border p-3">{footer}</div>}
</div>
</div>
</>,
document.body,
);
}
+245
View File
@@ -0,0 +1,245 @@
"use client";
/**
* Send the selected passage to someone, from the reading toolbar.
*
* Two ways in, because they answer different questions:
*
* Frequent chats — people you already talk to. A grid of avatars, because
* you recognise these faces and want the shortest possible path.
* Friends — people you follow or who follow you but have never messaged.
* Rows with an explicit button, because starting a conversation is a bigger
* step than continuing one and shouldn't be a mis-tap away.
*
* Anyone with an existing thread is removed from Friends, so nobody appears
* twice and the second list is genuinely "people you haven't written to".
*/
import { useEffect, useState } from "react";
import { Check, Loader2, Send } from "lucide-react";
import { toast } from "sonner";
import { useAuth } from "@/components/auth/auth-provider";
import { UserAvatar } from "@/components/ui/user-avatar";
import { Button } from "@/components/ui/button";
import { ResponsiveDialog } from "@/components/chat/responsive-dialog";
import { chat, type ChatPeer, type QuotePayload } from "@/lib/chat";
import { relationships, type UserSummary } from "@/lib/social";
/** How many existing threads to offer before it stops being "frequent". */
const FREQUENT_LIMIT = 9;
/** Friends fetched from each side of the follow graph. */
const FRIENDS_PER_SIDE = 20;
type Frequent = { conversationId: string; peer: ChatPeer };
/** Per-person send state, keyed by handle. */
type SendState = "idle" | "sending" | "sent";
export function ShareToChatDialog({
body = "",
quote,
entity,
onClose,
}: {
/** Plain message text. Empty when sending a quote on its own. */
body?: string;
/** A passage from the library, sent as a tappable quotation. */
quote?: QuotePayload;
/** A post, sent as a card. Only the reference travels — the BFF resolves
* the contents and re-checks visibility for each viewer. */
entity?: { type: "post"; id: string };
onClose: () => void;
}) {
const { user } = useAuth();
const [frequent, setFrequent] = useState<Frequent[]>([]);
const [friends, setFriends] = useState<UserSummary[]>([]);
const [loaded, setLoaded] = useState(false);
// Derived rather than stored: with no session there is nothing to fetch, so
// "loading" is false immediately instead of needing an effect to say so.
const loading = !!user && !loaded;
const [states, setStates] = useState<Record<string, SendState>>({});
useEffect(() => {
// The toolbar gates this behind requireAuth; with no session there is
// nothing to fetch and `loading` is already false.
if (!user) return;
let cancelled = false;
(async () => {
// Existing threads first: they populate the grid AND tell us who to drop
// from Friends, so both lists come out of one pass.
const [inbox, followers, following] = await Promise.all([
chat.inbox("chats").catch(() => []),
relationships
.followers(user.handle, { limit: FRIENDS_PER_SIDE })
.catch(() => ({ users: [] as UserSummary[] })),
relationships
.following(user.handle, { limit: FRIENDS_PER_SIDE })
.catch(() => ({ users: [] as UserSummary[] })),
]);
if (cancelled) return;
// The inbox arrives newest-first, so "frequent" is really "most recent"
// — a reasonable stand-in until there's a message-count to rank by.
const dms: Frequent[] = inbox
.filter((c) => c.kind === "dm" && c.peer)
.slice(0, FREQUENT_LIMIT)
.map((c) => ({ conversationId: c.id, peer: c.peer as ChatPeer }));
const talkingTo = new Set(dms.map((d) => d.peer.handle));
const seen = new Set<string>();
const rest = [...following.users, ...followers.users].filter((u) => {
// Following before followers, so mutuals show up under the side you
// chose. Self can appear in neither.
if (u.handle === user.handle || talkingTo.has(u.handle)) return false;
if (seen.has(u.handle)) return false;
seen.add(u.handle);
return true;
});
setFrequent(dms);
setFriends(rest);
setLoaded(true);
})();
return () => {
cancelled = true;
};
}, [user]);
const mark = (handle: string, state: SendState) =>
setStates((prev) => ({ ...prev, [handle]: state }));
/** Sends into an existing thread, or opens one first. */
const sendTo = async (handle: string, conversationId?: string) => {
if (states[handle] === "sending" || states[handle] === "sent") return;
mark(handle, "sending");
try {
const id = conversationId ?? (await chat.openDM(handle)).id;
await chat.send(id, body, { quote, entity });
mark(handle, "sent");
} catch {
mark(handle, "idle");
toast.error("Could not send that message");
}
};
const sentCount = Object.values(states).filter((s) => s === "sent").length;
return (
<ResponsiveDialog
title="Send to a chat"
description="Share this passage in a conversation."
onClose={onClose}
footer={
<Button variant="outline" className="w-full" onClick={onClose}>
{sentCount > 0 ? "Done" : "Cancel"}
</Button>
}
>
{loading ? (
<p className="p-4 text-sm text-muted-foreground">Loading…</p>
) : frequent.length === 0 && friends.length === 0 ? (
<p className="p-4 text-sm text-muted-foreground">
No one to send to yet. Follow someone to start a conversation.
</p>
) : (
<>
{frequent.length > 0 && (
<section className="p-4">
<h3 className="mb-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Frequent chats
</h3>
<ul className="grid grid-cols-3 gap-3">
{frequent.map(({ conversationId, peer }) => {
const state = states[peer.handle] ?? "idle";
return (
<li key={peer.handle}>
<button
type="button"
onClick={() => sendTo(peer.handle, conversationId)}
disabled={state !== "idle"}
className="flex w-full flex-col items-center gap-1.5 rounded-lg p-2 text-center hover:bg-muted/60 disabled:opacity-70"
>
<span className="relative">
<UserAvatar
src={peer.avatar_url}
name={peer.name}
className="size-14"
/>
{state !== "idle" && (
<span className="absolute inset-0 flex items-center justify-center rounded-full bg-background/70">
{state === "sending" ? (
<Loader2 className="size-4 animate-spin text-foreground" />
) : (
<Check className="size-5 text-primary" />
)}
</span>
)}
</span>
<span className="w-full truncate text-xs text-foreground">
{peer.name}
</span>
</button>
</li>
);
})}
</ul>
</section>
)}
{friends.length > 0 && (
<section className={frequent.length > 0 ? "border-t border-border" : ""}>
<h3 className="px-4 pb-2 pt-4 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Friends
</h3>
<ul className="divide-y divide-border">
{friends.map((u) => {
const state = states[u.handle] ?? "idle";
return (
<li
key={u.handle}
className="flex items-center gap-3 px-4 py-2.5"
>
<UserAvatar
src={u.avatar_url}
name={u.name}
className="size-9 shrink-0"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-foreground">
{u.name}
</p>
<p className="truncate text-xs text-muted-foreground">
@{u.handle}
</p>
</div>
<Button
size="sm"
variant={state === "sent" ? "outline" : "default"}
disabled={state !== "idle"}
onClick={() => sendTo(u.handle)}
className="shrink-0"
>
{state === "sending" ? (
<Loader2 className="size-4 animate-spin" />
) : state === "sent" ? (
<>
<Check className="size-4" /> Sent
</>
) : (
<>
<Send className="size-4" /> Send
</>
)}
</Button>
</li>
);
})}
</ul>
</section>
)}
</>
)}
</ResponsiveDialog>
);
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
/**
* A post shared into a chat, rendered as a card with its author.
*
* The contents are resolved by the BFF on every read, per viewer — never from
* a snapshot frozen at send time. So a post that has since been deleted or
* hidden arrives as `entity_unavailable` and renders as a tombstone rather
* than quietly showing content the reader is no longer allowed to see.
*/
import Link from "next/link";
import { ImageOff } from "lucide-react";
import { UserAvatar } from "@/components/ui/user-avatar";
import type { Attachment } from "@/lib/chat";
export function SharedPostCard({ attachment }: { attachment: Attachment }) {
const post = attachment.entity;
if (!post || attachment.entity_unavailable) {
return (
<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 />
<span className="text-sm text-muted-foreground">
This post is no longer available
</span>
</div>
);
}
return (
<Link
href={`/u/${encodeURIComponent(post.author_handle)}/posts#post-${post.id}`}
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">
<UserAvatar
src={post.author_avatar_url ?? null}
name={post.author_name}
className="size-6 shrink-0"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-medium text-foreground">
{post.author_name}
</span>
<span className="block truncate text-[11px] text-muted-foreground">
@{post.author_handle}
</span>
</span>
</span>
{post.image_url && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={post.image_url}
alt=""
loading="lazy"
className="aspect-square w-full object-cover"
/>
)}
{post.caption && (
<span className="block px-3 py-2 text-sm leading-relaxed text-foreground line-clamp-3">
{post.caption}
</span>
)}
{post.source_title && (
<span className="block truncate px-3 pb-2 text-xs text-muted-foreground">
{post.source_title}
</span>
)}
</Link>
);
}
+79
View File
@@ -0,0 +1,79 @@
"use client";
/**
* Stickers on a message: a 2-column grid, rendered without a chat bubble.
*
* Unlike the media grid, this never collapses into "+N more" — every sticker
* sent is drawn. They are small and deliberately playful, and hiding some
* behind a counter would make a wall of them read as an attachment list
* rather than what it is.
*
* Long-pressing or right-clicking one offers to keep it, which is the only
* way a sticker spreads beyond the person who earned it.
*/
import { useState } from "react";
import Image from "next/image";
import { toast } from "sonner";
import { chat, type Attachment } from "@/lib/chat";
export function StickerGrid({
conversationId,
messageId,
stickers,
/** Own stickers are already in your collection — no point offering to save. */
mine,
}: {
conversationId: string;
messageId: string;
stickers: Attachment[];
mine: boolean;
}) {
const [saving, setSaving] = useState<string | null>(null);
if (stickers.length === 0) return null;
const save = async (a: Attachment) => {
const key = a.entity_id;
if (!key || mine || saving) return;
setSaving(key);
try {
await chat.saveSticker(conversationId, messageId, key);
toast.success(`Saved ${a.title ?? "sticker"} to your collection`);
} catch {
toast.error("Could not save that sticker");
} finally {
setSaving(null);
}
};
return (
<div className="grid w-fit grid-cols-2 gap-1">
{stickers.map((a) => (
<button
key={a.id}
type="button"
disabled={mine}
onContextMenu={(e) => {
if (mine) return;
e.preventDefault();
void save(a);
}}
title={mine ? (a.title ?? "") : `${a.title ?? "Sticker"} — hold to save`}
aria-label={a.title ?? "Sticker"}
className="relative size-20 disabled:cursor-default sm:size-24"
>
{a.image_url && (
<Image
src={a.image_url}
alt={a.title ?? ""}
fill
sizes="96px"
className="object-contain"
/>
)}
</button>
))}
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
"use client";
/**
* The sticker drawer, opened from the composer.
*
* The collection is whatever the server says it is — rewards earned through
* sādhana plus stickers saved from a chat — so a sticker you were sent
* appears here the moment you keep it, with no client-side bookkeeping.
*
* Empty is a real state worth explaining: stickers are earned, so someone
* with none needs to know how to get one rather than seeing a blank sheet.
*/
import { useEffect, useState } from "react";
import Image from "next/image";
import { ResponsiveDialog } from "@/components/chat/responsive-dialog";
import { chat, type Sticker } from "@/lib/chat";
export function StickerPicker({
onPick,
onClose,
}: {
onPick: (sticker: Sticker) => void;
onClose: () => void;
}) {
const [stickers, setStickers] = useState<Sticker[] | null>(null);
useEffect(() => {
let cancelled = false;
chat
.stickers()
.then((s) => {
if (!cancelled) setStickers(s);
})
.catch(() => {
if (!cancelled) setStickers([]);
});
return () => {
cancelled = true;
};
}, []);
return (
<ResponsiveDialog
title="Stickers"
description="Earned through sādhana, or saved from a chat."
onClose={onClose}
>
{stickers === null ? (
<p className="p-4 text-sm text-muted-foreground">Loading…</p>
) : stickers.length === 0 ? (
<p className="p-4 text-sm text-muted-foreground">
No stickers yet. Keep your sādhana streak — a sacred reward arrives
every 7 days — or save one someone sends you.
</p>
) : (
<div className="grid grid-cols-4 gap-2 p-4 sm:grid-cols-5">
{stickers.map((s) => (
<button
key={s.key}
type="button"
onClick={() => {
onPick(s);
onClose();
}}
title={s.name}
aria-label={s.name}
className="relative aspect-square rounded-lg p-1 transition-colors hover:bg-muted"
>
<Image
src={s.image_url}
alt=""
fill
sizes="80px"
className="object-contain p-1"
/>
</button>
))}
</div>
)}
</ResponsiveDialog>
);
}
+29
View File
@@ -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>
);
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
/**
* The unread badge on a nav entry.
*
* Counts threads waiting on you, not messages: one busy group shouldn't read
* as twenty things needing attention. Muted threads are excluded server-side,
* so muting a noisy group clears the badge while its unread count stays
* visible in the inbox.
*
* Wraps its icon rather than being positioned by each caller, so the sidebar
* and bottom bar can't drift apart.
*/
import { useNotifications } from "@/components/notifications/notifications-provider";
export function NavUnreadBadge({ children }: { children: React.ReactNode }) {
const { chatUnread } = useNotifications();
if (chatUnread <= 0) return <>{children}</>;
return (
<span className="relative inline-flex">
{children}
<span
// Announced through the link's aria-label instead; this is decoration.
aria-hidden
className="absolute -right-1.5 -top-1 min-w-4 rounded-full bg-primary px-1 text-center text-[10px] font-semibold leading-4 text-white ring-2 ring-background"
>
{chatUnread > 9 ? "9+" : chatUnread}
</span>
</span>
);
}
/** The count, for callers that need it in an accessible name. */
export function useChatUnread(): number {
return useNotifications().chatUnread;
}
+26 -7
View File
@@ -13,9 +13,13 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { Home, Search, User as UserIcon } from "lucide-react"; import { Home, MessageCircle, Search, User as UserIcon } from "lucide-react";
import {
NavUnreadBadge,
useChatUnread,
} from "@/components/layout/nav-unread-badge";
import { useAuth } from "@/components/auth/auth-provider"; import { useAuth } from "@/components/auth/auth-provider";
import { KautilyaIcon } from "@/components/selection-toolbar/shared"; import { useHidesAppChrome } from "@/lib/use-chrome-visibility";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type Item = { type Item = {
@@ -28,7 +32,11 @@ 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
// link's accessible name — the badge itself is decorative.
const chatUnread = useChatUnread();
const items: Item[] = [ const items: Item[] = [
{ href: "/", label: "Home", icon: <Home className="size-5" /> }, { href: "/", label: "Home", icon: <Home className="size-5" /> },
@@ -52,9 +60,9 @@ export function SiteBottomNav() {
], ],
}, },
{ {
href: "/ask", href: "/chats",
label: "Kautilya", label: "Chat",
icon: <KautilyaIcon className="size-5" />, icon: <MessageCircle className="size-5" />,
}, },
{ {
href: user ? "/u/me" : "/login", href: user ? "/u/me" : "/login",
@@ -91,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"
@@ -114,7 +125,11 @@ export function SiteBottomNav() {
> >
<Link <Link
href={item.href} href={item.href}
aria-label={item.label} aria-label={
item.href === "/chats" && chatUnread > 0
? `${item.label} (${chatUnread} unread)`
: item.label
}
aria-current={active ? "page" : undefined} aria-current={active ? "page" : undefined}
className={cn( className={cn(
"w-12 h-12 rounded-md flex flex-col items-center justify-center", "w-12 h-12 rounded-md flex flex-col items-center justify-center",
@@ -123,7 +138,11 @@ export function SiteBottomNav() {
: "text-muted-foreground hover:text-foreground", : "text-muted-foreground hover:text-foreground",
)} )}
> >
{item.icon} {item.href === "/chats" ? (
<NavUnreadBadge>{item.icon}</NavUnreadBadge>
) : (
item.icon
)}
</Link> </Link>
</li> </li>
); );
+5
View File
@@ -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">
+5
View File
@@ -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">
+21 -5
View File
@@ -34,11 +34,16 @@ import {
DownloadCloud, DownloadCloud,
FolderClosed, FolderClosed,
Home, Home,
MessageCircle,
LogIn, LogIn,
Search, Search,
Settings2, Settings2,
User as UserIcon, User as UserIcon,
} from "lucide-react"; } from "lucide-react";
import {
NavUnreadBadge,
useChatUnread,
} from "@/components/layout/nav-unread-badge";
import { useAuth } from "@/components/auth/auth-provider"; import { useAuth } from "@/components/auth/auth-provider";
import { ReadingSettings } from "@/components/reading-settings"; import { ReadingSettings } from "@/components/reading-settings";
import { KautilyaIcon } from "@/components/selection-toolbar/shared"; import { KautilyaIcon } from "@/components/selection-toolbar/shared";
@@ -77,9 +82,9 @@ const TOP_ITEMS: NavItem[] = [
], ],
}, },
{ {
href: "/ask", href: "/chats",
label: "Kautilya", label: "Chats",
icon: <KautilyaIcon className="size-5" />, icon: <MessageCircle className="size-5" />,
}, },
{ {
href: "/u/me/collections", href: "/u/me/collections",
@@ -176,10 +181,15 @@ function SidebarLink({
* settings popover is up) and the mouse isn't actually hovering. */ * settings popover is up) and the mouse isn't actually hovering. */
expandedOverride?: boolean; expandedOverride?: boolean;
}) { }) {
const chatUnread = useChatUnread();
return ( return (
<Link <Link
href={item.href} href={item.href}
aria-label={item.label} aria-label={
item.href === "/chats" && chatUnread > 0
? `${item.label} (${chatUnread} unread)`
: item.label
}
title={item.label} title={item.label}
className={cn( className={cn(
"mx-2 h-11 flex items-center gap-3 px-3 rounded-lg overflow-hidden whitespace-nowrap text-sm font-medium", "mx-2 h-11 flex items-center gap-3 px-3 rounded-lg overflow-hidden whitespace-nowrap text-sm font-medium",
@@ -189,7 +199,13 @@ function SidebarLink({
: "text-muted-foreground hover:text-foreground hover:bg-muted", : "text-muted-foreground hover:text-foreground hover:bg-muted",
)} )}
> >
<span className="shrink-0">{item.icon}</span> <span className="shrink-0">
{item.href === "/chats" ? (
<NavUnreadBadge>{item.icon}</NavUnreadBadge>
) : (
item.icon
)}
</span>
<span <span
className={cn( className={cn(
"transition-opacity duration-150", "transition-opacity duration-150",
@@ -0,0 +1,118 @@
"use client";
/**
* The announcement surface: a modal on desktop, a drawer on mobile.
*
* Shown at most once per user, and that guarantee lives on the server — the
* dismissal is a row, not local storage — so it survives a new device, a
* cleared browser, and two tabs racing.
*
* It appears on load and on the realtime nudge, but never over a signed-out
* page: an announcement is addressed to a person, and there is nobody to
* address until they're signed in.
*/
import { useCallback, useEffect, useState } from "react";
import Image from "next/image";
import { useAuth } from "@/components/auth/auth-provider";
import { useNotifications } from "@/components/notifications/notifications-provider";
import { ResponsiveDialog } from "@/components/chat/responsive-dialog";
import { MessageText } from "@/components/chat/message-text";
import { Button } from "@/components/ui/button";
import { broadcasts, type Broadcast } from "@/lib/broadcasts";
export function BroadcastModal() {
const { user } = useAuth();
const { lastBroadcastAt } = useNotifications();
const [item, setItem] = useState<Broadcast | null>(null);
const [dismissing, setDismissing] = useState(false);
const load = useCallback(() => {
if (!user?.id) return;
broadcasts
.pending()
.then(setItem)
.catch(() => {
/* announcements are never worth an error surface */
});
}, [user?.id]);
// On sign-in, and again whenever the realtime nudge fires. lastBroadcastAt
// changes on each publish, so this refetches without the event carrying any
// content of its own.
useEffect(() => {
load();
}, [load, lastBroadcastAt]);
const dismiss = useCallback(async () => {
if (!item || dismissing) return;
setDismissing(true);
// Close first: the dismissal is idempotent and the server already knows
// what this user has seen, so a failed write costs at most one repeat
// rather than a modal that won't go away.
const id = item.id;
setItem(null);
setDismissing(false);
try {
await broadcasts.markSeen(id);
} catch {
/* it will simply be offered again */
}
}, [item, dismissing]);
// Guard on the shape, not just presence: a malformed payload must not put
// an empty modal over every page in the app.
if (!item?.id || !item.title) return null;
return (
<ResponsiveDialog
title={item.title}
description=""
onClose={dismiss}
footer={
<div className="flex gap-2">
{item.cta_url && item.cta_label ? (
<>
<Button variant="outline" className="flex-1" onClick={dismiss}>
Not now
</Button>
<a
href={item.cta_url}
// Dismiss on the way out, so following the link also counts as
// having seen it.
onClick={dismiss}
{...(item.cta_url.startsWith("/")
? {}
: { target: "_blank", rel: "noopener noreferrer" })}
className="inline-flex flex-1 items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 dark:text-white"
>
{item.cta_label}
</a>
</>
) : (
<Button className="w-full" onClick={dismiss}>
Got it
</Button>
)}
</div>
}
>
<div className="space-y-3 p-4">
{item.banner_url && (
<div className="relative aspect-[2/1] w-full overflow-hidden rounded-lg bg-muted">
<Image
src={item.banner_url}
alt=""
fill
sizes="(max-width: 768px) 100vw, 28rem"
className="object-cover"
/>
</div>
)}
<div className="text-sm leading-relaxed text-foreground">
<MessageText body={item.body} entities={item.entities ?? []} />
</div>
</div>
</ResponsiveDialog>
);
}
@@ -50,11 +50,20 @@ const EVENTS: {
label: "Sādhana reminders", label: "Sādhana reminders",
description: "Daily nudges to chant your rounds and share a verse.", description: "Daily nudges to chant your rounds and share a verse.",
}, },
{
key: "messages",
label: "Messages",
description: "New direct messages and group chats.",
// Push only. In-app delivery for chat is the inbox itself — a message
// highlights its row rather than entering the notification tray — and
// there is no such thing as an emailed chat message.
channels: ["push"],
},
{ {
key: "community", key: "community",
label: "Community", label: "Community",
description: description:
"Rare announcements — such as a direct disciple of Śrīla Prabhupāda joining.", "Rare announcements — such as new feature, downtime etc.",
// Announcements are in-app/push only; we deliberately don't email them. // Announcements are in-app/push only; we deliberately don't email them.
channels: ["in_app", "push"], channels: ["in_app", "push"],
}, },
@@ -34,6 +34,7 @@ import { useAuth } from "@/components/auth/auth-provider";
import { channelAuthorizer } from "@/lib/realtime"; import { channelAuthorizer } from "@/lib/realtime";
import { feed, notifications } from "@/lib/social"; import { feed, notifications } from "@/lib/social";
import { sadhna } from "@/lib/sadhna"; import { sadhna } from "@/lib/sadhna";
import { chat } from "@/lib/chat";
type NotificationsContextValue = { type NotificationsContextValue = {
/** Live unread count for the header bell. */ /** Live unread count for the header bell. */
@@ -44,6 +45,30 @@ type NotificationsContextValue = {
refreshUnread: () => void; refreshUnread: () => void;
/** Zero the badge locally (after the page marks everything read). */ /** Zero the badge locally (after the page marks everything read). */
clearUnread: () => void; clearUnread: () => void;
/**
* Last chat message seen over realtime, or null. Chat deliberately does NOT
* flow through the notification tray — a new message highlights its row in
* the inbox instead — so it rides the same private channel under its own
* event name and is surfaced here rather than bumping `unread`.
*/
lastChatActivity: ChatActivity | null;
/** Bumps when an announcement is published. Carries no content — consumers
* refetch /broadcasts/pending, which answers per user. */
lastBroadcastAt: number;
/** Threads holding unread messages — the Chats nav badge. Muted threads are
* excluded by the BFF, so muting a busy group clears the badge. */
chatUnread: number;
/** Re-read the authoritative chat count (after opening a thread). */
refreshChatUnread: () => void;
};
/** Payload of the BFF's `chat_activity` event. */
export type ChatActivity = {
conversation_id: string;
seq: number;
sender_id: string;
/** Client-side receipt time, so consumers can react to repeats. */
at: number;
}; };
const NotificationsContext = createContext<NotificationsContextValue | null>( const NotificationsContext = createContext<NotificationsContextValue | null>(
@@ -92,7 +117,12 @@ export function NotificationsProvider({
}) { }) {
const { user } = useAuth(); const { user } = useAuth();
const [unread, setUnread] = useState(0); const [unread, setUnread] = useState(0);
const [chatUnread, setChatUnread] = useState(0);
const [lastBroadcastAt, setLastBroadcastAt] = useState(0);
const [lastEventAt, setLastEventAt] = useState(0); const [lastEventAt, setLastEventAt] = useState(0);
const [lastChatActivity, setLastChatActivity] = useState<ChatActivity | null>(
null,
);
const refreshUnread = useCallback(() => { const refreshUnread = useCallback(() => {
notifications notifications
@@ -105,12 +135,22 @@ export function NotificationsProvider({
const clearUnread = useCallback(() => setUnread(0), []); const clearUnread = useCallback(() => setUnread(0), []);
const refreshChatUnread = useCallback(() => {
chat
.unreadCount()
.then(setChatUnread)
.catch(() => {
/* badge is non-critical */
});
}, []);
// Seed the count whenever the signed-in user changes (async — no // Seed the count whenever the signed-in user changes (async — no
// synchronous setState in the effect body). // synchronous setState in the effect body).
useEffect(() => { useEffect(() => {
if (!user?.id) return; if (!user?.id) return;
refreshUnread(); refreshUnread();
}, [user?.id, refreshUnread]); refreshChatUnread();
}, [user?.id, refreshUnread, refreshChatUnread]);
// Report the browser's timezone (once per signed-in session) so sādhana // Report the browser's timezone (once per signed-in session) so sādhana
// reminders fire at the user's local clock time. Best-effort. // reminders fire at the user's local clock time. Best-effort.
@@ -139,7 +179,7 @@ export function NotificationsProvider({
}; };
window.addEventListener("pagehide", onLeave); window.addEventListener("pagehide", onLeave);
return () => window.removeEventListener("pagehide", onLeave); return () => window.removeEventListener("pagehide", onLeave);
}, [userId]); }, [userId, refreshChatUnread]);
// Open the realtime connection while signed in. // Open the realtime connection while signed in.
useEffect(() => { useEffect(() => {
@@ -178,6 +218,26 @@ export function NotificationsProvider({
channel.bind("pusher:subscription_error", (status: unknown) => { channel.bind("pusher:subscription_error", (status: unknown) => {
console.warn("notifications: realtime subscription failed", status); console.warn("notifications: realtime subscription failed", status);
}); });
// Chat activity: no toast, nothing in the tray. The inbox subscribes to
// this to highlight the affected row, and the nav badge re-reads its
// count — refetched rather than incremented, because the badge counts
// THREADS: a second message in a thread already counted must not bump
// it, and the sender's own message must not either.
channel.bind(
"chat_activity",
(data: Omit<ChatActivity, "at">) => {
if (!data?.conversation_id) return;
setLastChatActivity({ ...data, at: Date.now() });
refreshChatUnread();
// Ack delivery here rather than in the thread, so "delivered" means
// "reached their device" rather than "they happened to have the chat
// open". This event only reaches non-senders, so nobody acks their
// own message.
if (data.seq) {
chat.markDelivered(data.conversation_id, data.seq).catch(() => {});
}
},
);
channel.bind("notification", (data: IncomingEvent) => { channel.bind("notification", (data: IncomingEvent) => {
setUnread((u) => u + 1); setUnread((u) => u + 1);
setLastEventAt(Date.now()); setLastEventAt(Date.now());
@@ -193,7 +253,18 @@ export function NotificationsProvider({
}); });
}, },
); );
// Announcements ride a PUBLIC channel: one publish reaches everyone in
// a single Soketi call rather than one per user, and the event is safe
// to broadcast because it carries nothing — the content comes from the
// authenticated fetch it triggers.
const announcements = pusher.subscribe("broadcasts");
announcements.bind("broadcast_published", () =>
setLastBroadcastAt(Date.now()),
);
cleanup = () => { cleanup = () => {
announcements.unbind_all();
pusher.unsubscribe("broadcasts");
channel.unbind_all(); channel.unbind_all();
pusher.unsubscribe(channelName); pusher.unsubscribe(channelName);
pusher.disconnect(); pusher.disconnect();
@@ -204,11 +275,20 @@ export function NotificationsProvider({
cancelled = true; cancelled = true;
cleanup?.(); cleanup?.();
}; };
}, [userId]); }, [userId, refreshChatUnread]);
return ( return (
<NotificationsContext.Provider <NotificationsContext.Provider
value={{ unread, lastEventAt, refreshUnread, clearUnread }} value={{
unread,
lastEventAt,
refreshUnread,
clearUnread,
lastChatActivity,
chatUnread,
refreshChatUnread,
lastBroadcastAt,
}}
> >
{children} {children}
</NotificationsContext.Provider> </NotificationsContext.Provider>
+92
View File
@@ -0,0 +1,92 @@
"use client";
/**
* The Message button on someone else's profile.
*
* Shown only when the BFF says the viewer may actually start a conversation —
* `can_message` reflects the target's "who can message you" setting and any
* block. Offering a button that fails on tap is worse than not offering one, so
* when messaging is closed this explains why instead.
*
* A blocked profile shows nothing at all: the block itself is already conveyed
* by the rest of the action row, and spelling it out again would be noise.
*/
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, MessageCircle } from "lucide-react";
import { toast } from "sonner";
import { useAuthGate } from "@/components/auth/auth-gate";
import { chat } from "@/lib/chat";
import type { RelationshipState } from "@/lib/social";
import { cn } from "@/lib/utils";
/** Why messaging is closed, in the target's own terms. */
const REASON: Record<string, string> = {
friends: "only accepts messages from friends",
account_age: "only accepts messages from established accounts",
same_gender: "limits who can message them",
};
export function MessageButton({
handle,
name,
relationship,
}: {
handle: string;
name: string;
relationship: RelationshipState | null;
}) {
const router = useRouter();
const { requireAuth } = useAuthGate();
const [busy, setBusy] = useState(false);
// Anonymous viewers get the button and hit the auth gate on tap — hiding it
// would make the feature invisible to exactly the people who need to sign up.
if (relationship && !relationship.can_message) {
const reason = REASON[relationship.message_restriction ?? ""];
if (!reason) return null; // blocked, or self — say nothing
return (
<p className="text-xs text-muted-foreground">
{name} {reason}.
</p>
);
}
const open = async () => {
if (!(await requireAuth())) return;
setBusy(true);
try {
const conv = await chat.openDM(handle);
router.push(`/chats/${conv.id}`);
} catch (err) {
toast.error(
err instanceof Error && err.message
? err.message
: "Could not open the conversation",
);
setBusy(false);
}
};
return (
<button
type="button"
onClick={open}
disabled={busy}
className={cn(
"inline-flex h-9 items-center justify-center gap-1.5 rounded-lg px-3 shrink-0",
"border border-border text-sm font-medium text-foreground hover:bg-muted",
"transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring",
"disabled:opacity-50",
)}
>
{busy ? (
<Loader2 className="size-4 animate-spin" />
) : (
<MessageCircle className="size-4" />
)}
Message
</button>
);
}
+5 -1
View File
@@ -58,6 +58,7 @@ import { countryName } from "@/lib/countries";
import { useAuth } from "@/components/auth/auth-provider"; import { useAuth } from "@/components/auth/auth-provider";
import { useAuthGate } from "@/components/auth/auth-gate"; import { useAuthGate } from "@/components/auth/auth-gate";
import { useMediaQuery } from "@/components/selection-toolbar/shared"; import { useMediaQuery } from "@/components/selection-toolbar/shared";
import { MessageButton } from "@/components/profile/message-button";
import { import {
Drawer, Drawer,
DrawerContent, DrawerContent,
@@ -206,8 +207,11 @@ export function ProfileActions({
return ( return (
<> <>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{primary} {primary}
{/* Offered only when the target's settings allow it — see
MessageButton. */}
<MessageButton handle={handle} name={name} relationship={rel} />
{/* Block / Report / Remove are signed-in-only (their BFF routes {/* Block / Report / Remove are signed-in-only (their BFF routes
require auth) and only appear when `authed`. "More info" is require auth) and only appear when `authed`. "More info" is
public, so the menu always renders with at least that item. */} public, so the menu always renders with at least that item. */}
+3 -1
View File
@@ -64,7 +64,9 @@ export function RewardCelebration({
You&apos;ve earned a reward! You&apos;ve earned a reward!
</h2> </h2>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
For {grant.milestone} days of unbroken sādhana 🙏 {grant.milestone !== null
? `For ${grant.milestone} days of unbroken sādhana 🙏`
: "A gift for you 🙏"}
</p> </p>
<div className="relative mx-auto my-6 flex size-48 items-center justify-center"> <div className="relative mx-auto my-6 flex size-48 items-center justify-center">
+63 -8
View File
@@ -15,9 +15,11 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import Image from "next/image"; import Image from "next/image";
import { Gift } from "lucide-react"; import { Gift, Sparkles } from "lucide-react";
import type { SadhnaRewardGrant } from "@/lib/sadhna"; import { sadhna, type SadhnaRewardGrant } from "@/lib/sadhna";
import { useMounted } from "@/lib/use-mounted"; import { useMounted } from "@/lib/use-mounted";
import { RewardCelebration } from "@/components/sadhna/reward-celebration";
import { cn } from "@/lib/utils";
export function RewardsGallery({ export function RewardsGallery({
collected, collected,
@@ -27,6 +29,11 @@ export function RewardsGallery({
nextRewardDate: string | null; nextRewardDate: string | null;
}) { }) {
const [detail, setDetail] = useState<SadhnaRewardGrant | null>(null); const [detail, setDetail] = useState<SadhnaRewardGrant | null>(null);
// The gift currently being opened — shown enlarged, over confetti.
const [unwrapping, setUnwrapping] = useState<SadhnaRewardGrant | null>(null);
// Gifts opened in this session. Held locally so the tile clears the moment
// the celebration is dismissed, rather than waiting for a page refresh.
const [justOpened, setJustOpened] = useState<Set<string>>(new Set());
// De-dupe by reward (collected is newest-first, so the first wins). // De-dupe by reward (collected is newest-first, so the first wins).
const seen = new Set<string>(); const seen = new Set<string>();
@@ -38,6 +45,19 @@ export function RewardsGallery({
} }
} }
// A gift stays wrapped until opened. Everything else — every streak
// reward — is simply collected.
const isWrapped = (g: SadhnaRewardGrant) =>
g.source === "gift" && !g.opened && !justOpened.has(g.reward.key);
const openGift = (g: SadhnaRewardGrant) => {
setUnwrapping(null);
setJustOpened((prev) => new Set(prev).add(g.reward.key));
// The tile has already cleared; a failed write only means it arrives
// wrapped again next load, which is better than blocking the celebration.
void sadhna.openGift(g.reward.key).catch(() => {});
};
return ( return (
<section className="rounded-2xl border border-border bg-card p-4 sm:p-5"> <section className="rounded-2xl border border-border bg-card p-4 sm:p-5">
<div className="mb-3 flex items-center gap-2"> <div className="mb-3 flex items-center gap-2">
@@ -61,20 +81,42 @@ export function RewardsGallery({
<button <button
key={g.reward.key} key={g.reward.key}
type="button" type="button"
onClick={() => setDetail(g)} onClick={() =>
className="group flex flex-col items-center gap-1.5 rounded-xl border border-border bg-background p-2 text-center transition-colors hover:border-primary/40 hover:bg-muted/40" isWrapped(g) ? setUnwrapping(g) : setDetail(g)
}
aria-label={
isWrapped(g)
? "A gift is waiting — tap to open"
: g.reward.name
}
className={cn(
"group flex flex-col items-center gap-1.5 rounded-xl border bg-background p-2 text-center transition-colors",
isWrapped(g)
? "border-primary/40 hover:border-primary"
: "border-border hover:border-primary/40 hover:bg-muted/40",
)}
> >
<div className="relative aspect-square w-full"> <div className="relative aspect-square w-full">
<Image <Image
src={g.reward.image_url} src={g.reward.image_url}
alt={g.reward.name} alt={isWrapped(g) ? "" : g.reward.name}
fill fill
sizes="(max-width: 640px) 30vw, 120px" sizes="(max-width: 640px) 30vw, 120px"
className="object-contain p-1" className={cn(
"object-contain p-1 transition-all",
// Blurred, not hidden: the shape hints at what's coming
// without giving it away.
isWrapped(g) && "scale-95 blur-md",
)}
/> />
{isWrapped(g) && (
<span className="absolute inset-0 flex items-center justify-center">
<Sparkles className="size-6 text-primary drop-shadow" />
</span>
)}
</div> </div>
<span className="line-clamp-2 text-[13px] font-medium leading-tight text-foreground"> <span className="line-clamp-2 text-[13px] font-medium leading-tight text-foreground">
{shortName(g.reward.name)} {isWrapped(g) ? "A gift for you" : shortName(g.reward.name)}
</span> </span>
</button> </button>
))} ))}
@@ -93,6 +135,15 @@ export function RewardsGallery({
{detail ? ( {detail ? (
<RewardDetail grant={detail} onClose={() => setDetail(null)} /> <RewardDetail grant={detail} onClose={() => setDetail(null)} />
) : null} ) : null}
{/* Reuses the streak celebration wholesale — a gift should feel like
any other sacred reward arriving, not like a second mechanism. */}
{unwrapping ? (
<RewardCelebration
grant={unwrapping}
onClose={() => openGift(unwrapping)}
/>
) : null}
</section> </section>
); );
} }
@@ -145,7 +196,11 @@ function RewardDetail({
</p> </p>
) : null} ) : null}
<p className="mt-4 text-xs text-muted-foreground"> <p className="mt-4 text-xs text-muted-foreground">
Earned on day {grant.milestone} of your streak {/* A gift has no milestone — saying "day null of your streak"
would be worse than saying nothing about where it came from. */}
{grant.milestone !== null
? `Earned on day ${grant.milestone} of your streak`
: "An occasional gift"}
</p> </p>
<button <button
type="button" type="button"
@@ -382,7 +382,10 @@ export function PostCommentsDrawer({
rows={1} rows={1}
maxLength={2000} maxLength={2000}
placeholder={replyTo ? "Write a reply…" : "Add a comment…"} placeholder={replyTo ? "Write a reply…" : "Add a comment…"}
className="w-full resize-none rounded-lg border border-border bg-background px-3 py-2 text-base sm:text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-primary/40 max-h-32" // 16px wherever the pointer is coarse, so iOS Safari doesn't
// auto-zoom on focus. Keyed off pointer type rather than width:
// an iPad is wider than `sm` but still zooms a 14px field.
className="w-full resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm pointer-coarse:text-base text-foreground outline-none focus-visible:ring-2 focus-visible:ring-primary/40 max-h-32"
/> />
<button <button
type="button" type="button"
+34 -6
View File
@@ -39,6 +39,8 @@ import {
type Comment, type Comment,
type Post, type Post,
} from "@/lib/social"; } from "@/lib/social";
import { useLongPress } from "@/lib/use-long-press";
import { ShareToChatDialog } from "@/components/chat/share-to-chat-dialog";
import { useAuth } from "@/components/auth/auth-provider"; import { useAuth } from "@/components/auth/auth-provider";
import { useAuthGate } from "@/components/auth/auth-gate"; import { useAuthGate } from "@/components/auth/auth-gate";
import { useMediaQuery } from "@/components/selection-toolbar/shared"; import { useMediaQuery } from "@/components/selection-toolbar/shared";
@@ -62,6 +64,9 @@ export function PostActionsFooter({ post }: { post: Post }) {
// on mobile native-share; sheet mount on desktop) so the Send icon // on mobile native-share; sheet mount on desktop) so the Send icon
// can render a spinner while the user waits. // can render a spinner while the user waits.
const [sharePending, setSharePending] = useState(false); const [sharePending, setSharePending] = useState(false);
// Opens the chat picker for this post. The post travels as a reference,
// not as text — the BFF renders the card and re-checks who may see it.
const [sharingToChat, setSharingToChat] = useState(false);
// Optimistic local mirror of post.viewer_has_reshared so the swap // Optimistic local mirror of post.viewer_has_reshared so the swap
// happens the instant the user taps Reshare. Reconciles to the // happens the instant the user taps Reshare. Reconciles to the
// server's value via rollback if the API call fails. // server's value via rollback if the API call fails.
@@ -140,7 +145,16 @@ export function PostActionsFooter({ post }: { post: Post }) {
// parallel, then navigator.share) and the desktop/no-native-share // parallel, then navigator.share) and the desktop/no-native-share
// path (mount the custom SharePostSheet). Provider's openShareSheet // path (mount the custom SharePostSheet). Provider's openShareSheet
// is always-resolves, so a single try/finally toggles the spinner. // is always-resolves, so a single try/finally toggles the spinner.
async function onSendClick() { // Tap → send to a chat. Hold → the platform's own share sheet, for
// everything outside the app. The link text is identical either way; only
// the destination differs.
function onSendClick() {
// No URL to resolve: sharing in-app sends the post id, so this is
// instant where the native sheet has to await the shortener.
setSharingToChat(true);
}
async function onSendHold() {
if (sharePending) return; if (sharePending) return;
setSharePending(true); setSharePending(true);
try { try {
@@ -150,6 +164,12 @@ export function PostActionsFooter({ post }: { post: Post }) {
} }
} }
const sendGestures = useLongPress({
onClick: onSendClick,
onLongPress: onSendHold,
disabled: sharePending,
});
return ( return (
<> <>
<div className="flex items-center justify-between px-2"> <div className="flex items-center justify-between px-2">
@@ -197,8 +217,8 @@ export function PostActionsFooter({ post }: { post: Post }) {
{showSend ? ( {showSend ? (
<ActionButton <ActionButton
label={sharePending ? "Preparing share link…" : "Send"} label={sharePending ? "Preparing share link…" : "Send"}
onClick={onSendClick}
disabled={sharePending} disabled={sharePending}
{...sendGestures}
icon={ icon={
sharePending ? ( sharePending ? (
<Loader2 <Loader2
@@ -270,29 +290,37 @@ export function PostActionsFooter({ post }: { post: Post }) {
{/* SharePostSheet is mounted globally by SharePostProvider, so {/* SharePostSheet is mounted globally by SharePostProvider, so
no per-card instance lives here. The provider's no per-card instance lives here. The provider's
openShareSheet swaps the active post under the hood. */} openShareSheet swaps the active post under the hood. */}
{sharingToChat && (
<ShareToChatDialog
entity={{ type: "post", id: post.id }}
onClose={() => setSharingToChat(false)}
/>
)}
</> </>
); );
} }
function ActionButton({ function ActionButton({
label, label,
onClick,
icon, icon,
disabled = false, disabled = false,
...buttonProps
}: { }: {
label: string; label: string;
onClick: () => void;
icon: React.ReactNode; icon: React.ReactNode;
/** Greys out the button and blocks clicks. Used by the Send button /** Greys out the button and blocks clicks. Used by the Send button
* while the short URL is being resolved on the mobile native-share * while the short URL is being resolved on the mobile native-share
* path so users can't fire navigator.share before the URL is * path so users can't fire navigator.share before the URL is
* ready. */ * ready. */
disabled?: boolean; disabled?: boolean;
}) { /** onClick, plus the pointer handlers the Send button needs to tell a
* tap from a hold. */
} & React.ComponentProps<"button">) {
return ( return (
<button <button
{...buttonProps}
type="button" type="button"
onClick={onClick}
disabled={disabled} disabled={disabled}
aria-label={label} aria-label={label}
title={label} title={label}
+8 -22
View File
@@ -38,8 +38,7 @@ import {
useState, useState,
} from "react"; } from "react";
import type { Post } from "@/lib/social"; import type { Post } from "@/lib/social";
import { createShortLink } from "@/lib/share"; import { buildPostShareText } from "@/lib/post-share-text";
import { structuredReferenceForPost } from "@/lib/source-reference";
import { useAuth } from "@/components/auth/auth-provider"; import { useAuth } from "@/components/auth/auth-provider";
import { useMediaQuery } from "@/components/selection-toolbar/shared"; import { useMediaQuery } from "@/components/selection-toolbar/shared";
import { SharePostSheet } from "./share-post-sheet"; import { SharePostSheet } from "./share-post-sheet";
@@ -131,30 +130,17 @@ export function SharePostProvider({
// it. // it.
// 3. On any non-AbortError, fall back to the custom sheet. // 3. On any non-AbortError, fall back to the custom sheet.
try { try {
const longPath = `/u/${encodeURIComponent(handle)}/posts#post-${post.id}`;
const hasImage = !!post.images?.[0]; const hasImage = !!post.images?.[0];
const [code, imageFile] = await Promise.all([ const [share, imageFile] = await Promise.all([
createShortLink(longPath), buildPostShareText(post, handle),
hasImage ? fetchShareImageFile(post.id) : Promise.resolve(null), hasImage ? fetchShareImageFile(post.id) : Promise.resolve(null),
]); ]);
const origin = window.location.origin; if (!share) {
const url = code setActivePost(post);
? new URL(`/share/${code}`, origin).toString() return;
: new URL(longPath, origin).toString(); }
const ref = structuredReferenceForPost( const payload: ShareData = { title: share.title, text: share.text };
post.source_url,
post.source_title,
);
const lines = [`“${post.caption}”`, "", "Source:", ref.title];
if (ref.detail) lines.push(ref.detail);
lines.push(url);
const text = lines.join("\n");
const title = ref.detail
? `${ref.title} — ${ref.detail}`
: ref.title;
const payload: ShareData = { title, text };
// Only attach files when the platform actually accepts them. // Only attach files when the platform actually accepts them.
// canShare returns false on Chrome desktop / Firefox even // canShare returns false on Chrome desktop / Firefox even
// though navigator.share itself exists, and we want to fail // though navigator.share itself exists, and we want to fail
+45 -12
View File
@@ -6,16 +6,16 @@ import {
Copy, Copy,
FolderPlus, FolderPlus,
Link as LinkIcon, Link as LinkIcon,
MessageCircle,
MoreHorizontal, MoreHorizontal,
Send, Send,
Share2, Share2,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { formatCopyText, getSourceReference } from "@/lib/source-reference"; import { formatCopyText, getSourceReference } from "@/lib/source-reference";
import { buildHighlightUrl } from "@/lib/text-highlight"; import { buildHighlightPath, buildHighlightUrl } from "@/lib/text-highlight";
import { bookmarks, SocialError } from "@/lib/social"; import { bookmarks, SocialError } from "@/lib/social";
import { import {
KautilyaIcon,
useMediaQuery, useMediaQuery,
useCopyFeedback, useCopyFeedback,
type Position, type Position,
@@ -25,6 +25,8 @@ import { FloatingToolbar } from "./selection-toolbar/floating-toolbar";
import { CollectionsDrawer } from "./selection-toolbar/collections-drawer"; import { CollectionsDrawer } from "./selection-toolbar/collections-drawer";
import { MorePopover, type MoreItem } from "./selection-toolbar/more-popover"; import { MorePopover, type MoreItem } from "./selection-toolbar/more-popover";
import { ComposePost } from "./selection-toolbar/compose-post"; import { ComposePost } from "./selection-toolbar/compose-post";
import { ShareToChatDialog } from "@/components/chat/share-to-chat-dialog";
import type { QuotePayload } from "@/lib/chat";
import { useAuth } from "./auth/auth-provider"; import { useAuth } from "./auth/auth-provider";
import { useAuthGate } from "./auth/auth-gate"; import { useAuthGate } from "./auth/auth-gate";
@@ -34,6 +36,8 @@ import { useAuthGate } from "./auth/auth-gate";
export function TextSelectionToolbar() { export function TextSelectionToolbar() {
const [selectedText, setSelectedText] = useState(""); const [selectedText, setSelectedText] = useState("");
// The passage to share, captured at click time. Non-null opens the dialog.
const [chatQuote, setChatQuote] = useState<QuotePayload | null>(null);
const [toolbarPos, setToolbarPos] = useState<Position | null>(null); const [toolbarPos, setToolbarPos] = useState<Position | null>(null);
const [showMore, setShowMore] = useState(false); const [showMore, setShowMore] = useState(false);
const [morePos, setMorePos] = useState<Position>({ x: 0, y: 0 }); const [morePos, setMorePos] = useState<Position>({ x: 0, y: 0 });
@@ -337,6 +341,21 @@ export function TextSelectionToolbar() {
setShowCollections(true); setShowCollections(true);
} }
async function onChat() {
// Snapshot the passage first: requireAuth may open a login flow, and
// finishAction clears the selection — the dialog outlives both.
const ref = getSourceReference();
const quote: QuotePayload = {
// Relative, not absolute: the BFF only accepts in-app paths for quotes.
path: buildHighlightPath(selectedText),
title: ref ? (ref.detail ? `${ref.title} — ${ref.detail}` : ref.title) : "",
text: selectedText,
};
if (!(await requireAuth())) return;
setChatQuote(quote);
finishAction();
}
async function onPost() { async function onPost() {
if (!(await requireAuth())) return; if (!(await requireAuth())) return;
if (isMobile) saveAndClearSelection(); if (isMobile) saveAndClearSelection();
@@ -418,14 +437,11 @@ export function TextSelectionToolbar() {
const isAuthed = !!user; const isAuthed = !!user;
const askAction: ToolbarAction = { const chatAction: ToolbarAction = {
key: "ask", key: "chat",
label: "Ask Kautilya", label: "Chat",
icon: <KautilyaIcon className="size-3.5" />, icon: <MessageCircle className="size-3.5" />,
onClick: () => { onClick: onChat,
// TODO: Implement Ask Kautilya
finishAction();
},
className: "text-amber-400 hover:bg-amber-400/15 hover:text-amber-300", className: "text-amber-400 hover:bg-amber-400/15 hover:text-amber-300",
}; };
@@ -445,7 +461,7 @@ export function TextSelectionToolbar() {
}; };
const authedToolbarActions: ToolbarAction[] = [ const authedToolbarActions: ToolbarAction[] = [
askAction, chatAction,
{ {
key: "post", key: "post",
label: "Post", label: "Post",
@@ -466,7 +482,6 @@ export function TextSelectionToolbar() {
]; ];
const publicToolbarActions: ToolbarAction[] = [ const publicToolbarActions: ToolbarAction[] = [
askAction,
{ {
key: "copy", key: "copy",
label: "Copy", label: "Copy",
@@ -515,6 +530,17 @@ export function TextSelectionToolbar() {
]; ];
const publicMoreItems: MoreItem[] = [ const publicMoreItems: MoreItem[] = [
{
key: "chat-public",
label: "Chat",
description: "Send this passage to someone",
icon: <MessageCircle className="size-5" />,
locked: true,
onClick: async () => {
setShowMore(false);
await onChat();
},
},
{ {
key: "share-as-post", key: "share-as-post",
label: "Share as Post", label: "Share as Post",
@@ -601,6 +627,13 @@ export function TextSelectionToolbar() {
isMobile={isMobile} isMobile={isMobile}
selectedText={selectedText} selectedText={selectedText}
/> />
{chatQuote !== null && (
<ShareToChatDialog
quote={chatQuote}
onClose={() => setChatQuote(null)}
/>
)}
</div> </div>
); );
} }
+1 -1
View File
@@ -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
View File
@@ -0,0 +1,51 @@
/**
* Browser-side client for admin announcements.
*
* Deliberately thin: an announcement is fetched, shown once, and dismissed.
* Nothing is cached client-side — "have I seen this?" is the server's answer,
* so it survives a new device, a cleared browser, and a second tab.
*/
import type { MessageEntity } from "@/lib/chat";
const BFF_BASE = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080";
async function call<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BFF_BASE}/api/v1${path}`, {
credentials: "include",
headers: { "Content-Type": "application/json" },
...init,
});
if (!res.ok) throw new Error(`broadcast request failed: ${res.status}`);
if (res.status === 204) return undefined as T;
const body = (await res.json().catch(() => ({}))) as {
data?: T;
success?: boolean;
};
// NOT `body.data ?? body`: /pending answers {data: null} when nothing is
// waiting, and `??` would fall through and hand back the whole envelope —
// which is truthy, so the modal would try to render it as an announcement.
return (body.data ?? (null as unknown as T)) as T;
}
export type Broadcast = {
id: string;
title: string;
body: string;
/** Formatting spans over `body`, same shape chat messages use. */
entities: MessageEntity[];
banner_url?: string | null;
cta_label?: string | null;
cta_url?: string | null;
published_at?: string | null;
expires_at?: string | null;
};
export const broadcasts = {
/** The announcement waiting for this user, or null. */
pending: () => call<Broadcast | null>("/broadcasts/pending"),
/** Dismiss it. Idempotent, so a double-tap or two tabs are both fine. */
markSeen: (id: string) =>
call<void>(`/broadcasts/${id}/seen`, { method: "POST" }),
};
+106
View File
@@ -0,0 +1,106 @@
/**
* Rendering helpers for chat messages — the client-side counterpart to the
* BFF's entity parser.
*
* The server stores clean text plus spans; nothing here interprets markup, so
* a message can never inject markup into someone else's thread.
*
* Entity offsets are UTF-16 code units, which is exactly what String.slice
* indexes by — so the arithmetic below needs no conversion. The server counts
* the same way for this reason; it matters constantly in this app, where
* Devanāgarī is multi-byte and emoji are surrogate pairs.
*/
import type { MessageEntity, Receipts } from "@/lib/chat";
export type EntityNode =
| { kind: "text"; text: string }
| { kind: "entity"; entity: MessageEntity; children: EntityNode[] };
/**
* Turns a body plus its spans into a nestable tree, so `*bold with `code`*`
* renders as one bold run containing a code run.
*
* Spans that only partially overlap another are dropped rather than clipped:
* the server never emits them, and rendering half a span is a worse failure
* than rendering none of it.
*/
export function buildEntityTree(
body: string,
entities: MessageEntity[],
): EntityNode[] {
// Defensive on both inputs: this renders content from the network, and a
// missing body should show nothing rather than take down the screen it is
// on. The broadcast modal renders through here too, and it mounts on every
// page — so a crash here is not confined to chat.
const text = body ?? "";
const sorted = [...(entities ?? [])].sort(
(a, b) => a.offset - b.offset || b.length - a.length,
);
return build(text, 0, text.length, sorted);
}
function build(
body: string,
start: number,
end: number,
entities: MessageEntity[],
): EntityNode[] {
const nodes: EntityNode[] = [];
let cursor = start;
let i = 0;
while (i < entities.length) {
const e = entities[i];
const eStart = e.offset;
const eEnd = e.offset + e.length;
if (eStart >= end) break;
// Partially overlapping or already-consumed spans: skip.
if (eEnd > end || eStart < cursor || e.length <= 0) {
i += 1;
continue;
}
if (eStart > cursor) {
nodes.push({ kind: "text", text: body.slice(cursor, eStart) });
}
// Everything fully inside this span belongs to it.
const children: MessageEntity[] = [];
let j = i + 1;
while (j < entities.length && entities[j].offset + entities[j].length <= eEnd) {
children.push(entities[j]);
j += 1;
}
nodes.push({
kind: "entity",
entity: e,
children: build(body, eStart, eEnd, children),
});
cursor = eEnd;
i = j;
}
if (cursor < end) {
nodes.push({ kind: "text", text: body.slice(cursor, end) });
}
return nodes;
}
/** Tick state for one of your own messages. */
export type TickState = "sent" | "delivered" | "read";
/**
* Read wins over delivered, delivered over sent.
*
* The cursors are the MINIMUM across every other participant, so in a group a
* message only turns blue once everyone has read it.
*/
export function tickState(seq: number, receipts: Receipts | null): TickState {
if (!receipts) return "sent";
if (receipts.read_seq >= seq) return "read";
if (receipts.delivered_seq >= seq) return "delivered";
return "sent";
}
+80
View File
@@ -0,0 +1,80 @@
/**
* Groups a thread's messages the way Instagram does: consecutive messages from
* one sender, sent close together, render as a stack with a single avatar and
* one timestamp, and a date separator marks each new day.
*
* Pure and message-shaped so it can be reasoned about (and checked) without a
* DOM.
*/
import type { ChatMessage } from "@/lib/chat";
/** How long a pause before the same sender starts a new stack. */
export const GROUP_GAP_MS = 5 * 60 * 1000;
export type ChatRow =
| { kind: "date"; key: string; at: string }
| {
kind: "group";
key: string;
senderId: string | null;
messages: ChatMessage[];
};
function dayKey(iso: string): string {
const d = new Date(iso);
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
}
/**
* `messages` must be ascending by seq (oldest first) — the order the thread is
* rendered in.
*/
export function groupMessages(
messages: ChatMessage[],
gapMs: number = GROUP_GAP_MS,
): ChatRow[] {
const rows: ChatRow[] = [];
let current: Extract<ChatRow, { kind: "group" }> | null = null;
let currentDay = "";
for (const m of messages) {
const day = dayKey(m.created_at);
if (day !== currentDay) {
rows.push({ kind: "date", key: `date-${day}`, at: m.created_at });
currentDay = day;
current = null; // a new day always starts a new stack
}
const last = current?.messages[current.messages.length - 1];
const gap = last
? new Date(m.created_at).getTime() - new Date(last.created_at).getTime()
: Infinity;
// System notices stand alone — they belong to no one and shouldn't be
// absorbed into a neighbouring stack.
const standalone = m.kind === "system";
const continues =
current !== null &&
!standalone &&
current.senderId === (m.sender_id ?? null) &&
current.messages[0]?.kind !== "system" &&
gap <= gapMs;
if (continues && current) {
current.messages.push(m);
continue;
}
current = {
kind: "group",
key: `group-${m.id}`,
senderId: m.sender_id ?? null,
messages: [m],
};
rows.push(current);
if (standalone) current = null;
}
return rows;
}
+695
View File
@@ -0,0 +1,695 @@
/**
* Browser-side BFF client for chat — the inbox, threads and messages.
*
* Mirrors lib/social.ts: `credentials: "include"` so the auth cookie travels,
* and the {success, data} envelope is unwrapped here so callers see plain data.
*/
const BFF_BASE = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080";
export class ChatError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
this.name = "ChatError";
}
}
type Envelope<T> = {
success: boolean;
data?: T;
error?: { code: number; message: string };
};
async function call<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BFF_BASE}/api/v1${path}`, {
credentials: "include",
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
...init,
});
if (res.status === 204) return undefined as T;
const body = (await res.json().catch(() => ({}))) as Envelope<T>;
if (!res.ok || body.success === false) {
throw new ChatError(res.status, body.error?.message || res.statusText);
}
return (body.data ?? (undefined as unknown as T)) as T;
}
// ─── Types ───────────────────────────────────────────────────────────────
/** Which inbox tab a thread belongs to. `ai` is Kautilya. */
export type ConversationKind = "dm" | "group" | "ai";
/** Roles inside a group, most privileged first. */
export type GroupRole = "owner" | "admin" | "member";
/** Who may post in a group. */
export type SendPolicy = "everyone" | "admins";
export type ChatPeer = {
id: string;
handle: string;
name: string;
avatar_url?: string | null;
/** Present on participant lists; absent on a DM's `peer`. */
role?: GroupRole;
joined_at?: string | null;
};
export type MessagePreview = {
seq: number;
sender_id?: string | null;
sender_name: string;
kind: string;
body: string;
deleted_at?: string | null;
};
export type InboxItem = {
id: string;
kind: ConversationKind;
title?: string | null;
avatar_key?: string | null;
send_policy: SendPolicy;
last_seq: number;
last_message_at?: string | null;
created_at: string;
unread: number;
/** Set while muted. Unread still accrues — mute silences alerts only. */
muted_until?: string | null;
peer?: ChatPeer | null;
last_message?: MessagePreview | null;
};
/** A formatting span. Offsets are UTF-16 code units, matching String.slice. */
export type MessageEntity = {
type: "bold" | "strike" | "code" | "url";
offset: number;
length: number;
url?: string;
};
/** The quoted snippet shown above a reply. */
export type ReplyPreview = {
id: string;
seq: number;
sender_id?: string | null;
sender_name: string;
body: string;
deleted: boolean;
};
export type ChatMessage = {
id: string;
conversation_id: string;
seq: number;
sender_id?: string | null;
kind: "text" | "system" | "assistant";
body: string;
entities: MessageEntity[];
client_msg_id?: string | null;
reply_to_id?: string | null;
forwarded_from_id?: string | null;
edited_at?: string | null;
deleted_at?: string | null;
created_at: string;
/** Present when this message replies to another in the same thread. */
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.
*/
pending?: boolean;
/**
* Client-only. Set when the send failed. The bubble stays put so the text
* isn't lost and the user can retry — retrying reuses the same
* `client_msg_id`, which is what makes it safe if the original actually
* landed.
*/
failed?: boolean;
};
/** One thread plus who is in it — everything the thread screen needs. */
export type ConversationDetail = {
id: string;
kind: ConversationKind;
title?: string | null;
avatar_key?: string | null;
send_policy: SendPolicy;
/** The caller's own role, so admin controls can be shown without deriving it. */
my_role: GroupRole;
last_seq: number;
last_message_at?: string | null;
created_at: string;
peer?: ChatPeer | null;
participants: ChatPeer[];
unread: number;
muted_until?: string | null;
/** DMs only — a group has no single peer to stand in a relationship with. */
relationship?: ChatRelationship | null;
};
/** Link preview metadata. Always fetched by the BFF, never composed here. */
export type LinkPreview = {
url: string;
title?: string;
description?: string;
site_name?: string;
image_url?: string;
};
export type AttachmentKind =
| "image"
| "video"
| "audio"
| "file"
| "link"
| "entity"
| "quote"
| "sticker";
/** 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;
};
/** A piece of reward artwork the caller may send. */
export type Sticker = {
key: string;
name: string;
image_url: string;
/** Came from a chat rather than from sādhana. */
saved: 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;
ord: number;
kind: AttachmentKind;
mime?: string | null;
size_bytes?: number | null;
width?: number | null;
height?: number | null;
duration_ms?: number | null;
blurhash?: string | null;
file_name?: string | null;
url?: string | null;
title?: string | null;
description?: string | null;
site_name?: string | null;
image_url?: string | null;
/** Shared entity. `entity` is resolved per viewer on every read, so it can
* be absent even though the message still references a post. */
entity_type?: "post" | null;
entity_id?: string | null;
entity?: SharedPost | null;
/** The post was deleted, hidden, or is no longer visible to this viewer. */
entity_unavailable?: boolean;
};
/** The post card the BFF resolves for a shared-post attachment. */
export type SharedPost = {
id: string;
author_handle: string;
author_name: string;
author_avatar_url?: string | null;
caption: string;
image_url?: string | null;
source_title?: string;
};
/** How two people in a DM stand with each other. */
export type ChatRelationship = {
following: boolean;
followed_by: boolean;
blocked: boolean;
blocked_by: boolean;
/** When the first follow between them was accepted. */
friends_since?: string | null;
};
export type Receipts = { delivered_seq: number; read_seq: number };
export type MuteDuration = "8h" | "1w" | "always" | "off";
/** Who may START a conversation with you. */
export type WhoCanMessage =
| "anyone"
| "friends"
| "account_age"
| "same_gender";
export type ChatSettings = {
read_receipts: boolean;
who_can_message: WhoCanMessage;
min_account_age_months: number;
train_kautilya: boolean;
/** Whether a gender is set on the profile — same_gender needs one. */
gender_set: boolean;
};
export type InboxTab = "chats" | "kautilya";
// ─── API ─────────────────────────────────────────────────────────────────
export const chat = {
inbox: (tab: InboxTab) =>
call<InboxItem[]>(`/chat/conversations?tab=${tab}`),
/**
* Opens (or returns) the DM with a profile. Idempotent, and keyed by handle
* because that is what a profile page has.
*/
/** Threads waiting on the caller. Muted ones are excluded — see the BFF. */
unreadCount: () =>
call<{ count: number }>("/chat/unread").then((r) => r.count),
openDM: (handle: string) =>
call<{ id: string; kind: ConversationKind }>("/chat/conversations/dm", {
method: "POST",
body: JSON.stringify({ handle }),
}),
createGroup: (title: string, handles: string[]) =>
call<{ id: string }>("/chat/conversations/group", {
method: "POST",
body: JSON.stringify({ title, member_handles: handles }),
}),
detail: (conversationId: string) =>
call<ConversationDetail>(`/chat/conversations/${conversationId}`),
messages: (conversationId: string, beforeSeq = 0, limit = 30) =>
call<ChatMessage[]>(
`/chat/conversations/${conversationId}/messages?before_seq=${beforeSeq}&limit=${limit}`,
),
send: (
conversationId: string,
body: string,
opts?: {
clientMsgId?: string;
replyToId?: string;
previewUrl?: string;
attachments?: UploadedAttachment[];
quote?: QuotePayload;
/** Share a post as a card. Only the reference — the BFF resolves it. */
entity?: { type: "post"; id: string };
/** Send reward artwork. Ownership is checked server-side. */
stickerKey?: string;
},
) =>
call<ChatMessage>(`/chat/conversations/${conversationId}/messages`, {
method: "POST",
body: JSON.stringify({
body,
client_msg_id: opts?.clientMsgId ?? null,
reply_to_id: opts?.replyToId ?? null,
// Only the URL: the BFF fetches the card's contents itself.
preview_url: opts?.previewUrl ?? null,
attachments: opts?.attachments ?? [],
quote: opts?.quote ?? null,
entity: opts?.entity ?? null,
sticker_key: opts?.stickerKey ?? null,
}),
}),
/**
* Members are named by handle, not id: search results and follow lists
* identify people by handle, and it matches /u/{handle}/block elsewhere.
*/
addMembers: (conversationId: string, handles: string[]) =>
call<void>(`/chat/conversations/${conversationId}/members`, {
method: "POST",
body: JSON.stringify({ member_handles: handles }),
}),
/** Passing your own handle is how you leave. */
removeMember: (conversationId: string, handle: string) =>
call<void>(
`/chat/conversations/${conversationId}/members/${encodeURIComponent(handle)}`,
{ method: "DELETE" },
),
setMemberRole: (conversationId: string, handle: string, role: GroupRole) =>
call<void>(
`/chat/conversations/${conversationId}/members/${encodeURIComponent(handle)}`,
{ method: "PATCH", body: JSON.stringify({ role }) },
),
updateGroup: (
conversationId: string,
patch: { title?: string; send_policy?: SendPolicy; avatar_key?: string },
) =>
call<void>(`/chat/conversations/${conversationId}`, {
method: "PATCH",
body: JSON.stringify(patch),
}),
settings: () => call<ChatSettings>("/chat/settings"),
/** Partial: anything omitted keeps its current value. */
updateSettings: (patch: Partial<Omit<ChatSettings, "gender_set">>) =>
call<ChatSettings>("/chat/settings", {
method: "PATCH",
body: JSON.stringify(patch),
}),
gallery: (conversationId: string, tab: "media" | "links", before?: string) =>
call<Attachment[]>(
`/chat/conversations/${conversationId}/gallery?tab=${tab}` +
(before ? `&before=${encodeURIComponent(before)}` : ""),
),
starred: (conversationId: string) =>
call<ChatMessage[]>(`/chat/conversations/${conversationId}/starred`),
deleteMessage: (
conversationId: string,
messageId: string,
scope: "me" | "everyone",
) =>
call<void>(
`/chat/conversations/${conversationId}/messages/${messageId}?scope=${scope}`,
{ method: "DELETE" },
),
/** The path names the destination; the body names the message copied in. */
forward: (toConversationId: string, messageId: string) =>
call<ChatMessage>(`/chat/conversations/${toConversationId}/forward`, {
method: "POST",
body: JSON.stringify({ message_id: messageId }),
}),
report: (
conversationId: string,
messageId: string,
category: string,
reason: string,
) =>
call<void>(
`/chat/conversations/${conversationId}/messages/${messageId}/report`,
{ method: "POST", body: JSON.stringify({ category, reason }) },
),
setStar: (conversationId: string, messageId: string, starred: boolean) =>
call<void>(
`/chat/conversations/${conversationId}/messages/${messageId}/star`,
{ method: starred ? "POST" : "DELETE" },
),
presignGroupAvatar: (conversationId: string, contentType: string) =>
call<{ upload_url: string; key: string }>(
`/chat/conversations/${conversationId}/avatar/presign`,
{ method: "POST", body: JSON.stringify({ content_type: contentType }) },
),
presignAttachment: (conversationId: string, contentType: string) =>
call<{ upload_url: string; key: string }>(
`/chat/conversations/${conversationId}/attachments/presign`,
{ method: "POST", body: JSON.stringify({ content_type: contentType }) },
),
/** Metadata for a link the user is typing, so they can dismiss the card. */
linkPreview: (url: string) =>
call<LinkPreview>("/chat/link-preview", {
method: "POST",
body: JSON.stringify({ url }),
}),
markRead: (conversationId: string, seq: number) =>
call<void>(`/chat/conversations/${conversationId}/read`, {
method: "POST",
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",
}),
/** Everything the caller can send: earned through sādhana, or saved from
* a chat. */
stickers: () => call<Sticker[]>("/chat/stickers"),
/** Keep a sticker someone sent. Keyed to the message it arrived on, which
* is what proves it was actually sent to you. */
saveSticker: (conversationId: string, messageId: string, key: string) =>
call<void>(
`/chat/conversations/${conversationId}/messages/${messageId}/sticker`,
{ method: "POST", body: JSON.stringify({ key }) },
),
/** 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",
body: JSON.stringify({ seq }),
}),
receipts: (conversationId: string) =>
call<Receipts>(`/chat/conversations/${conversationId}/receipts`),
setMute: (conversationId: string, duration: MuteDuration) =>
call<{ muted_until: string | null }>(
`/chat/conversations/${conversationId}/mute`,
{ method: "POST", body: JSON.stringify({ duration }) },
),
};
/** A file already uploaded to the private bucket, ready to attach on send. */
/**
* A passage shared from the library.
*
* `path` is app-relative on purpose — the BFF refuses anything absolute or
* protocol-relative, because a quote renders as trusted in-app content and
* must not be able to carry the reader off-site.
*/
export type QuotePayload = {
path: string;
title: string;
text: string;
};
export type UploadedAttachment = {
key: string;
mime: string;
size_bytes?: number;
width?: number;
height?: number;
file_name?: string;
};
/** Natural dimensions of an image file, or nothing if it can't be decoded. */
async function imageSize(
file: File,
): Promise<{ width: number; height: number } | null> {
if (!file.type.startsWith("image/")) return null;
try {
const bitmap = await createImageBitmap(file);
const size = { width: bitmap.width, height: bitmap.height };
bitmap.close();
return size;
} catch {
return null;
}
}
/**
* Uploads a file straight to the private bucket and returns what `send` needs.
*
* The bytes never touch the BFF — it only signs the URL — which is the same
* path avatars and post images already take. Dimensions travel with the
* attachment so the bubble can reserve the right space before the image
* loads, instead of reflowing the thread when it arrives.
*/
export async function uploadAttachment(
conversationId: string,
file: File,
): Promise<UploadedAttachment> {
const size = await imageSize(file);
const { upload_url, key } = await chat.presignAttachment(
conversationId,
file.type,
);
const res = await fetch(upload_url, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
});
if (!res.ok) {
throw new ChatError(res.status, "Upload failed");
}
return {
key,
mime: file.type,
size_bytes: file.size,
width: size?.width,
height: size?.height,
file_name: file.name,
};
}
/**
* Stable URL for a group's photo. Like message attachments it redirects to a
* short-lived signed URL after re-checking membership, so the object itself
* stays private. `v` busts the browser cache when the photo changes.
*/
export function groupAvatarUrl(
conversationId: string,
avatarKey?: string | null,
): string | undefined {
if (!avatarKey) return undefined;
return `${BFF_BASE}/api/v1/chat/conversations/${conversationId}/avatar?v=${encodeURIComponent(
avatarKey.slice(-12),
)}`;
}
/**
* Uploads a new group photo and returns its key for `updateGroup`. Compressed
* first, which also strips EXIF.
*/
export async function uploadGroupAvatar(
conversationId: string,
file: File,
): Promise<string> {
const { upload_url, key } = await chat.presignGroupAvatar(
conversationId,
file.type,
);
const res = await fetch(upload_url, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
});
if (!res.ok) throw new ChatError(res.status, "Upload failed");
return key;
}
/** MIME types the BFF accepts as chat attachments. */
export const ACCEPTED_ATTACHMENTS = "image/*,video/*,audio/*,application/pdf";
/**
* Stable URL for a chat attachment. The BFF re-checks membership and redirects
* to a short-lived signed URL, so this can be cached in markup safely while the
* underlying object stays private.
*/
export function attachmentUrl(attachmentId: string): string {
return `${BFF_BASE}/api/v1/chat/attachments/${attachmentId}`;
}
/** First http(s) URL in a string, or null. Mirrors the server's matcher. */
export function firstUrl(text: string): string | null {
const m = text.match(/(?:https?:\/\/|www\.)[^\s<>()[\]{}"']+/i);
if (!m) return null;
const raw = m[0].replace(/[.,;:!?'"]+$/, "");
return raw.includes("://") ? raw : `https://${raw}`;
}
/** Whether a role may administer a group. */
export function isGroupAdmin(role?: GroupRole | null): boolean {
return role === "owner" || role === "admin";
}
/** True while `muted_until` is in the future. */
export function isMuted(item: Pick<InboxItem, "muted_until">): boolean {
return !!item.muted_until && new Date(item.muted_until).getTime() > Date.now();
}
/** Display name for a thread: the peer for a DM, the title otherwise. */
export function conversationTitle(
item: Pick<InboxItem, "kind" | "peer" | "title">,
): string {
if (item.kind === "ai") return "Kautilya";
if (item.kind === "dm") return item.peer?.name || "Unknown";
return item.title || "Group";
}
/**
* How to describe a DM's follow state in one line. Deliberately explicit about
* one-way follows rather than collapsing everything into "friends".
*/
export function followSummary(
rel: ChatRelationship | null | undefined,
peerName: string,
): string {
if (!rel) return "";
if (rel.following && rel.followed_by) return "You follow each other";
if (rel.following) return `You follow ${peerName}`;
if (rel.followed_by) return `${peerName} follows you`;
return "You don't follow each other";
}
/** Resolves a sender id to a display name using a thread's participants. */
/**
* The participant who sent a message, or undefined for Kautilya, a system
* message, or someone who has since been removed.
*
* Groups need this rather than the conversation's `peer`: `peer` is only set
* on DMs, so a group has no single counterpart to read an avatar from.
*/
export function senderPeer(
senderId: string | null | undefined,
participants: ChatPeer[],
): ChatPeer | undefined {
if (!senderId) return undefined;
return participants.find((p) => p.id === senderId);
}
export function senderName(
senderId: string | null | undefined,
participants: ChatPeer[],
selfId: string,
): string {
if (!senderId) return "Kautilya";
if (senderId === selfId) return "You";
return participants.find((p) => p.id === senderId)?.name ?? "Unknown";
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Builds the shareable text for a post — the caption, its source reference,
* and a link.
*
* Extracted so the native share sheet and "send to a chat" quote a post
* identically. They differ only in where the text ends up, and two copies of
* this would drift the moment either format changed.
*/
import { createShortLink } from "@/lib/share";
import { structuredReferenceForPost } from "@/lib/source-reference";
import type { Post } from "@/lib/social";
export type PostShareText = {
/** Short link when the shortener answered, canonical URL otherwise. */
url: string;
/** Source reference on one line, for a native share sheet's title slot. */
title: string;
/** Caption, source reference and link — the body of the share. */
text: string;
};
/**
* `viewerHandle` covers the case where the BFF's create response omits
* author_handle: the poster IS the signed-in user right after posting.
* Returns null when neither yields a handle, since there is no post URL
* without one.
*/
export async function buildPostShareText(
post: Post,
viewerHandle: string | undefined,
): Promise<PostShareText | null> {
const handle = post.author_handle ?? viewerHandle ?? "";
if (!handle) return null;
const longPath = `/u/${encodeURIComponent(handle)}/posts#post-${post.id}`;
const code = await createShortLink(longPath);
const origin = window.location.origin;
const url = code
? new URL(`/share/${code}`, origin).toString()
: new URL(longPath, origin).toString();
const ref = structuredReferenceForPost(post.source_url, post.source_title);
const lines = [`“${post.caption}”`, "", "Source:", ref.title];
if (ref.detail) lines.push(ref.detail);
lines.push(url);
return {
url,
title: ref.detail ? `${ref.title} — ${ref.detail}` : ref.title,
text: lines.join("\n"),
};
}
+11 -1
View File
@@ -111,9 +111,13 @@ export type SadhnaReward = {
/** A reward earned at a streak milestone (a multiple of 7 days). */ /** A reward earned at a streak milestone (a multiple of 7 days). */
export type SadhnaRewardGrant = { export type SadhnaRewardGrant = {
milestone: number; /** The streak day this was earned on. Null for an occasional gift. */
milestone: number | null;
granted_at: string; granted_at: string;
reward: SadhnaReward; reward: SadhnaReward;
source: "streak" | "gift";
/** False while a gift is still wrapped — the gallery blurs those. */
opened: boolean;
}; };
// ─── API ─────────────────────────────────────────────────────────────── // ─── API ───────────────────────────────────────────────────────────────
@@ -140,6 +144,12 @@ export const sadhna = {
rewards: () => rewards: () =>
call<{ collected: SadhnaRewardGrant[]; total: number }>("/sadhna/rewards"), call<{ collected: SadhnaRewardGrant[]; total: number }>("/sadhna/rewards"),
/** Open one gift, after its celebration is dismissed. Idempotent. */
openGift: (rewardKey: string) =>
call<void>(`/sadhna/rewards/gifts/${encodeURIComponent(rewardKey)}/open`, {
method: "POST",
}),
/** Acknowledge pending celebrations after showing the confetti. */ /** Acknowledge pending celebrations after showing the confetti. */
ackRewards: () => call<void>("/sadhna/rewards/ack", { method: "POST" }), ackRewards: () => call<void>("/sadhna/rewards/ack", { method: "POST" }),
+10
View File
@@ -168,6 +168,13 @@ export type RelationshipState = {
blocked: boolean; blocked: boolean;
/** they have blocked you. */ /** they have blocked you. */
blocked_by: boolean; blocked_by: boolean;
/** Whether the viewer may START a conversation with this profile. Reflects
* the target's "who can message you" setting and any block, so the Message
* button is only offered when tapping it would actually work. */
can_message: boolean;
/** The target's rule when `can_message` is false — one of "blocked",
* "friends", "account_age", "same_gender" — so the UI can explain why. */
message_restriction?: string;
}; };
/** Returned by every relationship mutation: the recomputed state plus /** Returned by every relationship mutation: the recomputed state plus
@@ -705,6 +712,9 @@ export const notifications = {
// ─── Notification preferences ────────────────────────────────────────── // ─── Notification preferences ──────────────────────────────────────────
export type PrefEvent = export type PrefEvent =
/** Chat push. Delivered by the chat service directly, not the notification
* tray — a new message highlights its inbox row instead. */
| "messages"
| "follows" | "follows"
| "post_interactions" | "post_interactions"
| "sadhna_reminders" | "sadhna_reminders"
+12
View File
@@ -303,6 +303,18 @@ export function buildSearchResultUrl(
/** /**
* Build the full URL with highlight fragment for the selected text. * Build the full URL with highlight fragment for the selected text.
*/ */
/**
* The highlight link as an app-relative path ("/books/bg/2/13?hl=…").
*
* Chat quotes store a path rather than a full URL: the BFF refuses absolute
* links there, since a quote renders as trusted in-app content and must not be
* able to carry the reader off-site.
*/
export function buildHighlightPath(selectedText: string): string {
const url = new URL(buildHighlightUrl(selectedText));
return url.pathname + url.search + url.hash;
}
export function buildHighlightUrl(selectedText: string): string { export function buildHighlightUrl(selectedText: string): string {
const fragment = encodeTextFragment(selectedText); const fragment = encodeTextFragment(selectedText);
if (!fragment) return window.location.href; if (!fragment) return window.location.href;
+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;
}
+87
View File
@@ -0,0 +1,87 @@
"use client";
/**
* Distinguishes a tap from a press-and-hold on the same control.
*
* Built on Pointer Events so one implementation covers touch, mouse and pen.
* Two details make it behave:
*
* - A hold that fires must swallow the click that follows it, or the tap
* action runs straight after the hold action.
* - Small finger movement during a hold is normal, so the press only
* cancels once it drifts past a slop threshold — otherwise holds fail
* constantly on touch.
*
* Timing and slop match the chat bubbles' gesture handler so a hold feels the
* same everywhere in the app.
*/
import { useCallback, useEffect, useRef } from "react";
const LONG_PRESS_MS = 450;
const LONG_PRESS_SLOP_PX = 8;
export function useLongPress({
onClick,
onLongPress,
disabled = false,
}: {
onClick: () => void;
onLongPress: () => void;
disabled?: boolean;
}) {
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const start = useRef<{ x: number; y: number } | null>(null);
const fired = useRef(false);
const clear = useCallback(() => {
if (timer.current) clearTimeout(timer.current);
timer.current = null;
start.current = null;
}, []);
// A hold can end with the pointer outside the button, or with the component
// unmounting behind whatever the hold opened; either would leak the timer.
useEffect(() => clear, [clear]);
const onPointerDown = (e: React.PointerEvent) => {
if (disabled) return;
fired.current = false;
start.current = { x: e.clientX, y: e.clientY };
timer.current = setTimeout(() => {
fired.current = true;
clear();
onLongPress();
}, LONG_PRESS_MS);
};
const onPointerMove = (e: React.PointerEvent) => {
if (!start.current) return;
const dx = e.clientX - start.current.x;
const dy = e.clientY - start.current.y;
if (Math.abs(dx) > LONG_PRESS_SLOP_PX || Math.abs(dy) > LONG_PRESS_SLOP_PX) {
clear();
}
};
return {
onPointerDown,
onPointerMove,
onPointerUp: clear,
onPointerCancel: clear,
onPointerLeave: clear,
// Suppress iOS's press-and-hold callout, which would otherwise cover the
// sheet the hold just opened.
onContextMenu: (e: React.MouseEvent) => {
if (fired.current) e.preventDefault();
},
onClick: () => {
// The hold already acted; the browser's trailing click is not a tap.
if (fired.current) {
fired.current = false;
return;
}
if (!disabled) onClick();
},
};
}
+63
View File
@@ -0,0 +1,63 @@
"use client";
/**
* Tracks the *visual* viewport — the part of the page not covered by the
* software keyboard.
*
* Why this exists: on iOS Safari `100dvh` does not shrink when the keyboard
* opens. The dynamic viewport units respond to browser chrome collapsing, not
* to the keyboard, so a composer pinned to the bottom of a `100dvh` column
* ends up underneath it. `window.visualViewport` is the only thing iOS updates
* reliably, so the thread is sized from that instead while the keyboard is up.
*
* `offsetTop` matters as much as `height`: iOS scrolls the layout viewport to
* reveal the focused field rather than resizing it, so a panel pinned to the
* visible area has to follow that offset or it drifts off-screen.
*/
import { useEffect, useState } from "react";
export type VisualViewport = {
/** Visible height in px, excluding the keyboard. */
height: number;
/** How far the layout viewport has scrolled to reveal the focused field. */
offsetTop: number;
/** True once the visible area is materially shorter than the window. */
keyboardOpen: boolean;
};
/**
* A URL bar collapsing costs ~50–60px; a keyboard costs several hundred. The
* threshold keeps ordinary chrome changes from being read as a keyboard.
*/
const KEYBOARD_THRESHOLD_PX = 120;
export function useVisualViewport(): VisualViewport | null {
const [state, setState] = useState<VisualViewport | null>(null);
useEffect(() => {
const vv = window.visualViewport;
// Absent on older browsers — callers fall back to their CSS height.
if (!vv) return;
const apply = () => {
setState({
height: vv.height,
offsetTop: vv.offsetTop,
keyboardOpen: window.innerHeight - vv.height > KEYBOARD_THRESHOLD_PX,
});
};
apply();
vv.addEventListener("resize", apply);
// iOS fires scroll (not resize) as the layout viewport shifts under the
// keyboard, so both are needed to stay pinned.
vv.addEventListener("scroll", apply);
return () => {
vv.removeEventListener("resize", apply);
vv.removeEventListener("scroll", apply);
};
}, []);
return state;
}