feat: chat
This commit is contained in:
@@ -0,0 +1,559 @@
|
||||
"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 { toast } from "sonner";
|
||||
import { channelAuthorizer } from "@/lib/realtime";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
chat,
|
||||
conversationTitle,
|
||||
groupAvatarUrl,
|
||||
isMuted,
|
||||
senderName,
|
||||
type ChatMessage,
|
||||
type ConversationDetail,
|
||||
type Receipts,
|
||||
type UploadedAttachment,
|
||||
} from "@/lib/chat";
|
||||
|
||||
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());
|
||||
// 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[],
|
||||
) => {
|
||||
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: [],
|
||||
created_at: new Date().toISOString(),
|
||||
pending: true,
|
||||
};
|
||||
setMessages((prev) => [...prev, optimistic]);
|
||||
|
||||
chat
|
||||
.send(id, body, { clientMsgId, replyToId, previewUrl, attachments })
|
||||
.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. */
|
||||
const markRead = useCallback(
|
||||
(seq: number) => {
|
||||
if (!seq) return;
|
||||
chat.markRead(id, seq).catch(() => {});
|
||||
},
|
||||
[id],
|
||||
);
|
||||
|
||||
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("receipt", () => {
|
||||
chat.receipts(id).then(setReceipts).catch(() => {});
|
||||
});
|
||||
|
||||
cleanup = () => {
|
||||
channel.unbind_all();
|
||||
pusher.unsubscribe(channelName);
|
||||
pusher.disconnect();
|
||||
};
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cleanup?.();
|
||||
};
|
||||
}, [id, user]);
|
||||
|
||||
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);
|
||||
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.
|
||||
"h-[calc(100dvh_-_3rem_-_4rem)] sm:h-[calc(100dvh_-_3.5rem_-_4rem)] md:h-[calc(100dvh_-_3.5rem)]",
|
||||
)}
|
||||
>
|
||||
<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}
|
||||
peer={meta?.peer}
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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}
|
||||
disabled={state !== "ready"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{menu && (
|
||||
<MessageMenu
|
||||
point={menu.point}
|
||||
starred={starredIds.has(menu.message.id)}
|
||||
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),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{forwarding && (
|
||||
<ForwardDialog
|
||||
message={forwarding}
|
||||
onClose={() => setForwarding(null)}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
DownloadCloud,
|
||||
LogOut,
|
||||
MessageSquareText,
|
||||
MessagesSquare,
|
||||
Scale,
|
||||
ShieldCheck,
|
||||
UserRound,
|
||||
@@ -49,6 +50,12 @@ export default function SettingsPage() {
|
||||
label="Reading preferences"
|
||||
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
|
||||
href="/settings/notifications"
|
||||
icon={<Bell className="size-5" />}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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. */}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 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[] {
|
||||
const sorted = [...(entities ?? [])].sort(
|
||||
(a, b) => a.offset - b.offset || b.length - a.length,
|
||||
);
|
||||
return build(body, 0, body.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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+562
@@ -0,0 +1,562 @@
|
||||
/**
|
||||
* 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[];
|
||||
/**
|
||||
* 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";
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
/** 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.
|
||||
*/
|
||||
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[];
|
||||
},
|
||||
) =>
|
||||
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 ?? [],
|
||||
}),
|
||||
}),
|
||||
|
||||
/**
|
||||
* 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 }),
|
||||
}),
|
||||
|
||||
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. */
|
||||
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. */
|
||||
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";
|
||||
}
|
||||
@@ -168,6 +168,13 @@ export type RelationshipState = {
|
||||
blocked: boolean;
|
||||
/** they have blocked you. */
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user