feat: chat

This commit is contained in:
gosangam
2026-08-29 03:49:58 +05:30
parent 3257efb06a
commit 523b6fa7e8
34 changed files with 5381 additions and 11 deletions
+184
View File
@@ -0,0 +1,184 @@
"use client";
/**
* Search for people and add them to a group.
*
* 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, X } 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 { chat } from "@/lib/chat";
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 [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]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
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 (
<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 flex max-h-[75vh] w-full max-w-md flex-col 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">Add members</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="border-b border-border p-3">
<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
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by name or handle"
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{!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>
)}
</div>
<div className="border-t border-border p-3">
<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>
</div>
</div>
</div>
);
}
+294
View File
@@ -0,0 +1,294 @@
"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, SendHorizonal, X } from "lucide-react";
import { LinkPreviewCard } from "@/components/chat/link-preview-card";
import {
ComposerAttachments,
type StagedAttachment,
} from "@/components/chat/composer-attachments";
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,
conversationId,
disabled = false,
placeholder = "Message…",
replyToName,
}: {
conversationId: string;
replyTo: ChatMessage | null;
onCancelReply: () => void;
onSend: (
body: string,
replyToId?: string,
previewUrl?: string,
attachments?: UploadedAttachment[],
) => 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 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>
<textarea
ref={ref}
rows={1}
value={value}
disabled={disabled}
placeholder={placeholder}
onChange={(e) => setValue(e.target.value)}
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(
"flex-1 resize-none rounded-2xl border border-border bg-muted/40 px-3 py-2 text-sm",
"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>
</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 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>
);
}
+118
View File
@@ -0,0 +1,118 @@
"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 { 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="mt-1 flex flex-col gap-1.5">
{media.length > 0 && (
<MediaGrid items={media} onOpen={(i) => setLightboxAt(i)} />
)}
{rest.map((a) => {
switch (a.kind) {
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)}
/>
)}
</>
);
}
+177
View File
@@ -0,0 +1,177 @@
"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;
export function MessageGestures({
onReply,
onMenu,
disabled = false,
children,
}: {
onReply: () => void;
/** Opens the context menu at the given viewport point. */
onMenu: (point: { x: number; y: number }) => 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);
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 = () => {
cancelHold();
// A long press already acted; releasing must not also fire a reply.
if (!consumed.current && claimed.current && offset >= TRIGGER_PX) onReply();
reset();
};
const armed = offset >= TRIGGER_PX;
return (
<div
className="relative"
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>
);
}
+241
View File
@@ -0,0 +1,241 @@
"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 { cn } from "@/lib/utils";
import type { 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",
});
}
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,
mine,
selfId,
nameFor,
}: {
reply: ReplyPreview;
mine: boolean;
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
className={cn(
"mb-1 rounded-lg border-l-2 px-2 py-1 text-xs",
mine
? "border-primary-foreground/50 bg-black/10 text-primary-foreground/90 dark:text-white/90"
: "border-primary/50 bg-background/60 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,
peer,
receipts,
showAvatar,
selfId,
onReply,
onMenu,
starredIds,
onRetry,
nameFor,
}: {
messages: ChatMessage[];
mine: boolean;
peer?: ChatPeer | null;
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;
}) {
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={peer?.avatar_url}
name={peer?.name ?? "?"}
className="size-7 shrink-0"
textClassName="text-[10px]"
/>
)}
<div
className={cn(
"flex max-w-[78%] flex-col gap-0.5",
mine ? "items-end" : "items-start",
!mine && !showAvatar && "ml-9",
)}
>
{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)}
>
<div
className={cn(
"group/bubble relative px-3 py-2 text-sm",
bubbleCorners(mine, i, messages.length),
m.deleted_at
? "bg-muted italic text-muted-foreground"
: mine
? "bg-primary text-primary-foreground dark:text-white"
: "bg-muted text-foreground",
)}
>
{m.reply_to && !m.deleted_at && (
<QuotedReply
reply={m.reply_to}
mine={mine}
selfId={selfId}
nameFor={nameFor}
/>
)}
{m.deleted_at ? (
<span>This message was deleted</span>
) : (
<>
{m.body && <MessageText body={m.body} entities={m.entities} />}
<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 === 0 && (
<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>
</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>
);
}
+178
View File
@@ -0,0 +1,178 @@
"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,
peer,
receipts,
isGroup,
hasMore,
loadingMore,
onLoadMore,
onReply,
onMenu,
starredIds,
onRetry,
nameFor,
}: {
/** Ascending by seq — oldest first. */
messages: ChatMessage[];
selfId: string;
peer?: ChatPeer | null;
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;
}) {
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}
className="flex-1 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}
peer={peer}
receipts={receipts}
showAvatar={isGroup}
selfId={selfId}
onReply={onReply}
onMenu={onMenu}
starredIds={starredIds}
onRetry={onRetry}
nameFor={nameFor}
/>
),
)}
</div>
</div>
);
}
+173
View File
@@ -0,0 +1,173 @@
"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 { 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,
actions,
onClose,
}: {
point: MenuPoint;
starred: boolean;
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 py-1 shadow-lg"
>
<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,
)}
/>
);
}
+188
View File
@@ -0,0 +1,188 @@
"use client";
/**
* Create a group: name it, pick people, done.
*
* 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 { UserAvatar } from "@/components/ui/user-avatar";
import { Button } from "@/components/ui/button";
import { chat } from "@/lib/chat";
type Candidate = { handle: string; name: string; avatar_url: string | null };
export function NewGroupDialog({ onClose }: { onClose: () => void }) {
const router = useRouter();
const { user } = useAuth();
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]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
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 (
<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 flex max-h-[80vh] w-full max-w-md flex-col 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">New group</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="space-y-3 border-b border-border p-3">
<input
autoFocus
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 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 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>
<div className="flex-1 overflow-y-auto">
{!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>
)}
</div>
<div className="border-t border-border p-3">
<Button
className="w-full"
disabled={title.trim() === "" || busy}
onClick={create}
>
Create group
</Button>
</div>
</div>
</div>
);
}
+4 -5
View File
@@ -13,9 +13,8 @@
import Image from "next/image";
import Link from "next/link";
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 { useAuth } from "@/components/auth/auth-provider";
import { KautilyaIcon } from "@/components/selection-toolbar/shared";
import { cn } from "@/lib/utils";
type Item = {
@@ -52,9 +51,9 @@ export function SiteBottomNav() {
],
},
{
href: "/ask",
label: "Kautilya",
icon: <KautilyaIcon className="size-5" />,
href: "/chat",
label: "Chat",
icon: <MessageCircle className="size-5" />,
},
{
href: user ? "/u/me" : "/login",
+4 -3
View File
@@ -34,6 +34,7 @@ import {
DownloadCloud,
FolderClosed,
Home,
MessageCircle,
LogIn,
Search,
Settings2,
@@ -77,9 +78,9 @@ const TOP_ITEMS: NavItem[] = [
],
},
{
href: "/ask",
label: "Kautilya",
icon: <KautilyaIcon className="size-5" />,
href: "/chats",
label: "Chats",
icon: <MessageCircle className="size-5" />,
},
{
href: "/u/me/collections",
@@ -54,7 +54,7 @@ const EVENTS: {
key: "community",
label: "Community",
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.
channels: ["in_app", "push"],
},
@@ -44,6 +44,22 @@ type NotificationsContextValue = {
refreshUnread: () => void;
/** Zero the badge locally (after the page marks everything read). */
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;
};
/** 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>(
@@ -93,6 +109,9 @@ export function NotificationsProvider({
const { user } = useAuth();
const [unread, setUnread] = useState(0);
const [lastEventAt, setLastEventAt] = useState(0);
const [lastChatActivity, setLastChatActivity] = useState<ChatActivity | null>(
null,
);
const refreshUnread = useCallback(() => {
notifications
@@ -178,6 +197,15 @@ export function NotificationsProvider({
channel.bind("pusher:subscription_error", (status: unknown) => {
console.warn("notifications: realtime subscription failed", status);
});
// Chat activity: no toast, no unread bump, nothing in the tray. The
// inbox subscribes to this to highlight the affected row.
channel.bind(
"chat_activity",
(data: Omit<ChatActivity, "at">) => {
if (!data?.conversation_id) return;
setLastChatActivity({ ...data, at: Date.now() });
},
);
channel.bind("notification", (data: IncomingEvent) => {
setUnread((u) => u + 1);
setLastEventAt(Date.now());
@@ -208,7 +236,13 @@ export function NotificationsProvider({
return (
<NotificationsContext.Provider
value={{ unread, lastEventAt, refreshUnread, clearUnread }}
value={{
unread,
lastEventAt,
refreshUnread,
clearUnread,
lastChatActivity,
}}
>
{children}
</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 { useAuthGate } from "@/components/auth/auth-gate";
import { useMediaQuery } from "@/components/selection-toolbar/shared";
import { MessageButton } from "@/components/profile/message-button";
import {
Drawer,
DrawerContent,
@@ -206,8 +207,11 @@ export function ProfileActions({
return (
<>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
{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
require auth) and only appear when `authed`. "More info" is
public, so the menu always renders with at least that item. */}