Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2597c6f2ec | ||
|
|
8c7afe596d | ||
|
|
0f382ed885 | ||
|
|
03c8799f22 | ||
|
|
011b5bba91 | ||
|
|
3e6a1c6f39 | ||
|
|
76cd106ef7 | ||
|
|
028a8d7e1c | ||
|
|
290ceb048f |
@@ -30,21 +30,30 @@ import {
|
|||||||
type MenuPoint,
|
type MenuPoint,
|
||||||
} from "@/components/chat/message-menu";
|
} from "@/components/chat/message-menu";
|
||||||
import { ForwardDialog } from "@/components/chat/forward-dialog";
|
import { ForwardDialog } from "@/components/chat/forward-dialog";
|
||||||
|
import { ReactionsDrawer } from "@/components/chat/reactions-drawer";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { channelAuthorizer } from "@/lib/realtime";
|
import { channelAuthorizer } from "@/lib/realtime";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useVisualViewport } from "@/lib/use-visual-viewport";
|
||||||
|
import { useNotifications } from "@/components/notifications/notifications-provider";
|
||||||
import {
|
import {
|
||||||
chat,
|
chat,
|
||||||
conversationTitle,
|
conversationTitle,
|
||||||
groupAvatarUrl,
|
groupAvatarUrl,
|
||||||
isMuted,
|
isMuted,
|
||||||
senderName,
|
senderName,
|
||||||
|
senderPeer,
|
||||||
type ChatMessage,
|
type ChatMessage,
|
||||||
type ConversationDetail,
|
type ConversationDetail,
|
||||||
type Receipts,
|
type Receipts,
|
||||||
type UploadedAttachment,
|
type UploadedAttachment,
|
||||||
} from "@/lib/chat";
|
} from "@/lib/chat";
|
||||||
|
|
||||||
|
/** How long a typing indicator survives its last ping. */
|
||||||
|
const TYPING_TTL_MS = 5000;
|
||||||
|
/** How often we tell the thread we are typing, while typing. */
|
||||||
|
const TYPING_PING_MS = 3000;
|
||||||
|
|
||||||
const PAGE_SIZE = 30;
|
const PAGE_SIZE = 30;
|
||||||
|
|
||||||
export default function ChatThreadPage() {
|
export default function ChatThreadPage() {
|
||||||
@@ -63,6 +72,18 @@ export default function ChatThreadPage() {
|
|||||||
const [menu, setMenu] = useState<{ message: ChatMessage; point: MenuPoint } | null>(null);
|
const [menu, setMenu] = useState<{ message: ChatMessage; point: MenuPoint } | null>(null);
|
||||||
const [forwarding, setForwarding] = useState<ChatMessage | null>(null);
|
const [forwarding, setForwarding] = useState<ChatMessage | null>(null);
|
||||||
const [starredIds, setStarredIds] = useState<Set<string>>(new Set());
|
const [starredIds, setStarredIds] = useState<Set<string>>(new Set());
|
||||||
|
// user id → when they last typed. Kept as timestamps rather than a boolean
|
||||||
|
// so the indicator expires on its own: there is no "stopped typing" event,
|
||||||
|
// and inventing one would still be lost on a dropped connection.
|
||||||
|
const [typingAt, setTypingAt] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
|
// While the keyboard is up the thread is sized from the visual viewport
|
||||||
|
// instead of dvh — see useVisualViewport for why dvh can't do this on iOS.
|
||||||
|
// Declared with the other hooks: the early return below means anything
|
||||||
|
// placed after it would run conditionally.
|
||||||
|
const { refreshChatUnread } = useNotifications();
|
||||||
|
const viewport = useVisualViewport();
|
||||||
|
const keyboardOpen = viewport?.keyboardOpen ?? false;
|
||||||
// Guards against two loads racing (scroll handler + the short-page effect).
|
// Guards against two loads racing (scroll handler + the short-page effect).
|
||||||
const loadingRef = useRef(false);
|
const loadingRef = useRef(false);
|
||||||
|
|
||||||
@@ -147,6 +168,7 @@ export default function ChatThreadPage() {
|
|||||||
replyToId?: string,
|
replyToId?: string,
|
||||||
previewUrl?: string,
|
previewUrl?: string,
|
||||||
attachments?: UploadedAttachment[],
|
attachments?: UploadedAttachment[],
|
||||||
|
stickerKey?: string,
|
||||||
) => {
|
) => {
|
||||||
const clientMsgId = crypto.randomUUID();
|
const clientMsgId = crypto.randomUUID();
|
||||||
const optimistic: ChatMessage = {
|
const optimistic: ChatMessage = {
|
||||||
@@ -160,13 +182,20 @@ export default function ChatThreadPage() {
|
|||||||
client_msg_id: clientMsgId,
|
client_msg_id: clientMsgId,
|
||||||
reply_to_id: replyToId ?? null,
|
reply_to_id: replyToId ?? null,
|
||||||
attachments: [],
|
attachments: [],
|
||||||
|
reactions: [],
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
pending: true,
|
pending: true,
|
||||||
};
|
};
|
||||||
setMessages((prev) => [...prev, optimistic]);
|
setMessages((prev) => [...prev, optimistic]);
|
||||||
|
|
||||||
chat
|
chat
|
||||||
.send(id, body, { clientMsgId, replyToId, previewUrl, attachments })
|
.send(id, body, {
|
||||||
|
clientMsgId,
|
||||||
|
replyToId,
|
||||||
|
previewUrl,
|
||||||
|
attachments,
|
||||||
|
stickerKey,
|
||||||
|
})
|
||||||
.then((saved) => {
|
.then((saved) => {
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
// Drop the placeholder and any realtime echo of the same message.
|
// Drop the placeholder and any realtime echo of the same message.
|
||||||
@@ -311,12 +340,100 @@ export default function ChatThreadPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
/** Mark everything visible as read, so the sender's ticks turn blue. */
|
/** Mark everything visible as read, so the sender's ticks turn blue. */
|
||||||
|
// Expire stale typers. A ticking clock rather than a timer per person: one
|
||||||
|
// interval regardless of how many are in the room, and it stops entirely
|
||||||
|
// once nobody is typing.
|
||||||
|
const [now, setNow] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (Object.keys(typingAt).length === 0) return;
|
||||||
|
const t = setInterval(() => setNow(Date.now()), 1000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [typingAt]);
|
||||||
|
|
||||||
|
// Ids only. Resolving names here would call nameFor before it is declared
|
||||||
|
// further down — a temporal-dead-zone crash that TypeScript does not catch,
|
||||||
|
// because the call would sit inside a .map callback.
|
||||||
|
// `now || Date.now()` keeps the first render correct while still depending
|
||||||
|
// on the ticking clock above.
|
||||||
|
const typingIds = Object.entries(typingAt)
|
||||||
|
.filter(([, at]) => (now || Date.now()) - at < TYPING_TTL_MS)
|
||||||
|
.map(([uid]) => uid);
|
||||||
|
|
||||||
|
/** Tell the thread we are composing, at most once every few seconds. */
|
||||||
|
const typingSentAt = useRef(0);
|
||||||
|
const notifyTyping = useCallback(() => {
|
||||||
|
const t = Date.now();
|
||||||
|
if (t - typingSentAt.current < TYPING_PING_MS) return;
|
||||||
|
typingSentAt.current = t;
|
||||||
|
chat.typing(id).catch(() => {});
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
/** Replace one message in place, after its reactions changed. */
|
||||||
|
const refreshMessage = useCallback(
|
||||||
|
async (messageId: string) => {
|
||||||
|
try {
|
||||||
|
const fresh = await chat.message(id, messageId);
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) => (m.id === messageId ? { ...m, ...fresh } : m)),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
/* a stale chip is not worth an error surface */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set or toggle a reaction. Picking the emoji you already chose removes it,
|
||||||
|
* which is what makes the chip and the double-tap heart both behave as
|
||||||
|
* toggles.
|
||||||
|
*/
|
||||||
|
const react = useCallback(
|
||||||
|
async (message: ChatMessage, emoji: string) => {
|
||||||
|
const mine = message.reactions?.find((r) => r.mine)?.emoji;
|
||||||
|
try {
|
||||||
|
if (mine === emoji) await chat.unreact(id, message.id);
|
||||||
|
else await chat.react(id, message.id, emoji);
|
||||||
|
// The server publishes a `reaction` event, but the actor is on the
|
||||||
|
// same channel and would otherwise wait a round trip to see it.
|
||||||
|
await refreshMessage(message.id);
|
||||||
|
} catch {
|
||||||
|
toast.error("Could not react");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[id, refreshMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Which message's reaction list is open, if any.
|
||||||
|
const [reactionsFor, setReactionsFor] = useState<string | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The trail does one of two things, never both: retract your reaction if
|
||||||
|
* you have one, otherwise show who reacted. Rolling both into one control
|
||||||
|
* keeps the bubble uncluttered, at the cost that seeing the list means
|
||||||
|
* removing your own reaction first.
|
||||||
|
*/
|
||||||
|
const onTrailClick = useCallback(
|
||||||
|
(message: ChatMessage) => {
|
||||||
|
const mine = message.reactions?.find((r) => r.mine)?.emoji;
|
||||||
|
if (mine) void react(message, mine);
|
||||||
|
else setReactionsFor(message.id);
|
||||||
|
},
|
||||||
|
[react],
|
||||||
|
);
|
||||||
|
|
||||||
const markRead = useCallback(
|
const markRead = useCallback(
|
||||||
(seq: number) => {
|
(seq: number) => {
|
||||||
if (!seq) return;
|
if (!seq) return;
|
||||||
chat.markRead(id, seq).catch(() => {});
|
// Refresh the nav badge afterwards: without this it stays lit until the
|
||||||
|
// next incoming message, since nothing else tells it this thread was
|
||||||
|
// read.
|
||||||
|
chat
|
||||||
|
.markRead(id, seq)
|
||||||
|
.then(refreshChatUnread)
|
||||||
|
.catch(() => {});
|
||||||
},
|
},
|
||||||
[id],
|
[id, refreshChatUnread],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -381,6 +498,14 @@ export default function ChatThreadPage() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
channel.bind("reaction", ({ message_id }: { message_id: string }) => {
|
||||||
|
void refreshMessage(message_id);
|
||||||
|
});
|
||||||
|
channel.bind("typing", ({ user_id }: { user_id: string }) => {
|
||||||
|
// Ignore your own keystrokes echoing back off the channel.
|
||||||
|
if (!user_id || user_id === user?.id) return;
|
||||||
|
setTypingAt((prev) => ({ ...prev, [user_id]: Date.now() }));
|
||||||
|
});
|
||||||
channel.bind("receipt", () => {
|
channel.bind("receipt", () => {
|
||||||
chat.receipts(id).then(setReceipts).catch(() => {});
|
chat.receipts(id).then(setReceipts).catch(() => {});
|
||||||
});
|
});
|
||||||
@@ -396,7 +521,7 @@ export default function ChatThreadPage() {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
cleanup?.();
|
cleanup?.();
|
||||||
};
|
};
|
||||||
}, [id, user]);
|
}, [id, user, refreshMessage]);
|
||||||
|
|
||||||
if (loading || !user) {
|
if (loading || !user) {
|
||||||
return (
|
return (
|
||||||
@@ -408,6 +533,10 @@ export default function ChatThreadPage() {
|
|||||||
|
|
||||||
const nameFor = (senderId: string | null | undefined) =>
|
const nameFor = (senderId: string | null | undefined) =>
|
||||||
senderName(senderId, meta?.participants ?? [], user.id);
|
senderName(senderId, meta?.participants ?? [], user.id);
|
||||||
|
// Groups need the actual sender, not the conversation's `peer` — `peer` is
|
||||||
|
// only set on DMs, so a group has no single counterpart to read from.
|
||||||
|
const senderFor = (senderId: string | null | undefined) =>
|
||||||
|
senderPeer(senderId, meta?.participants ?? []);
|
||||||
const title = meta ? conversationTitle(meta) : "Chat";
|
const title = meta ? conversationTitle(meta) : "Chat";
|
||||||
const muted = meta ? isMuted(meta) : false;
|
const muted = meta ? isMuted(meta) : false;
|
||||||
|
|
||||||
@@ -429,8 +558,31 @@ export default function ChatThreadPage() {
|
|||||||
// space, and calc() requires whitespace around a binary `-` — written
|
// space, and calc() requires whitespace around a binary `-` — written
|
||||||
// without it the whole declaration is invalid and dropped, which
|
// without it the whole declaration is invalid and dropped, which
|
||||||
// collapses this to height:auto.
|
// collapses this to height:auto.
|
||||||
"h-[calc(100dvh_-_3rem_-_4rem)] sm:h-[calc(100dvh_-_3.5rem_-_4rem)] md:h-[calc(100dvh_-_3.5rem)]",
|
// Only while the keyboard is closed; otherwise the inline style below
|
||||||
|
// takes over and these would fight it.
|
||||||
|
// The full viewport: a thread hides the app's header AND its tab bar,
|
||||||
|
// so there is no longer any chrome to subtract.
|
||||||
|
!keyboardOpen && "h-[100dvh]",
|
||||||
)}
|
)}
|
||||||
|
style={
|
||||||
|
keyboardOpen && viewport
|
||||||
|
? {
|
||||||
|
// Pinned to the visible strip above the keyboard. Fixed rather
|
||||||
|
// than in-flow because iOS scrolls the layout viewport instead
|
||||||
|
// of resizing it, so an in-flow column keeps its old height and
|
||||||
|
// hides the composer underneath. left/right 0 with the existing
|
||||||
|
// mx-auto keeps the desktop column centred.
|
||||||
|
position: "fixed",
|
||||||
|
top: viewport.offsetTop,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: viewport.height,
|
||||||
|
// Above the app's fixed bottom nav (z-40) so the composer can
|
||||||
|
// never be painted over by it.
|
||||||
|
zIndex: 50,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<header className="flex items-center gap-3 border-b border-border px-4 py-3">
|
<header className="flex items-center gap-3 border-b border-border px-4 py-3">
|
||||||
<Link
|
<Link
|
||||||
@@ -494,7 +646,7 @@ export default function ChatThreadPage() {
|
|||||||
<MessageList
|
<MessageList
|
||||||
messages={messages}
|
messages={messages}
|
||||||
selfId={user.id}
|
selfId={user.id}
|
||||||
peer={meta?.peer}
|
senderFor={senderFor}
|
||||||
receipts={receipts}
|
receipts={receipts}
|
||||||
isGroup={meta?.kind === "group"}
|
isGroup={meta?.kind === "group"}
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
@@ -505,6 +657,9 @@ export default function ChatThreadPage() {
|
|||||||
starredIds={starredIds}
|
starredIds={starredIds}
|
||||||
onRetry={retryMessage}
|
onRetry={retryMessage}
|
||||||
nameFor={nameFor}
|
nameFor={nameFor}
|
||||||
|
typingNames={typingIds.map(nameFor).filter(Boolean)}
|
||||||
|
onReact={react}
|
||||||
|
onTrailClick={onTrailClick}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -519,6 +674,7 @@ export default function ChatThreadPage() {
|
|||||||
replyToName={nameFor(replyTo?.sender_id)}
|
replyToName={nameFor(replyTo?.sender_id)}
|
||||||
onCancelReply={() => setReplyTo(null)}
|
onCancelReply={() => setReplyTo(null)}
|
||||||
onSend={sendMessage}
|
onSend={sendMessage}
|
||||||
|
onTyping={notifyTyping}
|
||||||
disabled={state !== "ready"}
|
disabled={state !== "ready"}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -527,6 +683,8 @@ export default function ChatThreadPage() {
|
|||||||
<MessageMenu
|
<MessageMenu
|
||||||
point={menu.point}
|
point={menu.point}
|
||||||
starred={starredIds.has(menu.message.id)}
|
starred={starredIds.has(menu.message.id)}
|
||||||
|
myReaction={menu.message.reactions?.find((r) => r.mine)?.emoji}
|
||||||
|
onReact={(emoji) => react(menu.message, emoji)}
|
||||||
onClose={() => setMenu(null)}
|
onClose={() => setMenu(null)}
|
||||||
actions={{
|
actions={{
|
||||||
onReply: () => setReplyTo(menu.message),
|
onReply: () => setReplyTo(menu.message),
|
||||||
@@ -548,6 +706,14 @@ export default function ChatThreadPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{reactionsFor && (
|
||||||
|
<ReactionsDrawer
|
||||||
|
conversationId={id}
|
||||||
|
messageId={reactionsFor}
|
||||||
|
onClose={() => setReactionsFor(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{forwarding && (
|
{forwarding && (
|
||||||
<ForwardDialog
|
<ForwardDialog
|
||||||
message={forwarding}
|
message={forwarding}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { SiteHeader } from "@/components/layout/site-header";
|
import { SiteHeader } from "@/components/layout/site-header";
|
||||||
import { SiteSidebar } from "@/components/layout/site-sidebar";
|
import { SiteSidebar } from "@/components/layout/site-sidebar";
|
||||||
import { SiteBottomNav } from "@/components/layout/site-bottom-nav";
|
import { SiteBottomNav } from "@/components/layout/site-bottom-nav";
|
||||||
|
import { AppShell } from "@/components/layout/app-shell";
|
||||||
import { SiteFooter } from "@/components/layout/site-footer";
|
import { SiteFooter } from "@/components/layout/site-footer";
|
||||||
import { VerifyEmailBanner } from "@/components/auth/verify-email-banner";
|
import { VerifyEmailBanner } from "@/components/auth/verify-email-banner";
|
||||||
import { PushPrompt } from "@/components/push/push-prompt";
|
import { PushPrompt } from "@/components/push/push-prompt";
|
||||||
@@ -28,11 +29,11 @@ export default function AppLayout({
|
|||||||
<>
|
<>
|
||||||
<VerifyEmailBanner />
|
<VerifyEmailBanner />
|
||||||
<SiteSidebar />
|
<SiteSidebar />
|
||||||
<div className="flex-1 flex flex-col md:pl-16 pb-16 md:pb-0">
|
<AppShell>
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
{children}
|
{children}
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
</div>
|
</AppShell>
|
||||||
<SiteBottomNav />
|
<SiteBottomNav />
|
||||||
<PushPrompt />
|
<PushPrompt />
|
||||||
</>
|
</>
|
||||||
|
|||||||
+15
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
import { Geist, Geist_Mono, Lora, Bricolage_Grotesque } from "next/font/google";
|
import { Geist, Geist_Mono, Lora, Bricolage_Grotesque } from "next/font/google";
|
||||||
import { ogDefault } from "@/lib/og";
|
import { ogDefault } from "@/lib/og";
|
||||||
@@ -8,6 +8,7 @@ import { AudioPlayerProvider, FloatingPlayer } from "@/components/audio-player";
|
|||||||
import { AuthProvider } from "@/components/auth/auth-provider";
|
import { AuthProvider } from "@/components/auth/auth-provider";
|
||||||
import { AuthGateProvider } from "@/components/auth/auth-gate";
|
import { AuthGateProvider } from "@/components/auth/auth-gate";
|
||||||
import { NotificationsProvider } from "@/components/notifications/notifications-provider";
|
import { NotificationsProvider } from "@/components/notifications/notifications-provider";
|
||||||
|
import { BroadcastModal } from "@/components/notifications/broadcast-modal";
|
||||||
import { SharePostProvider } from "@/components/social/share-post-provider";
|
import { SharePostProvider } from "@/components/social/share-post-provider";
|
||||||
import { Toaster } from "@/components/ui/toaster";
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
import { ServiceWorkerRegistrar } from "@/components/pwa/sw-register";
|
import { ServiceWorkerRegistrar } from "@/components/pwa/sw-register";
|
||||||
@@ -43,6 +44,16 @@ const bricolage = Bricolage_Grotesque({
|
|||||||
display: "swap",
|
display: "swap",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
// Ask the browser to shrink the layout viewport when the software keyboard
|
||||||
|
// opens, which is the native fix for a bottom-anchored composer. Chrome and
|
||||||
|
// Android honour it; Safari does not, which is why the chat thread also
|
||||||
|
// tracks window.visualViewport by hand.
|
||||||
|
interactiveWidget: "resizes-content",
|
||||||
|
};
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
// Resolves all relative OG/Twitter image URLs and canonical links to
|
// Resolves all relative OG/Twitter image URLs and canonical links to
|
||||||
// absolute URLs (required for correct social previews + SEO; Next warns
|
// absolute URLs (required for correct social previews + SEO; Next warns
|
||||||
@@ -105,6 +116,9 @@ export default function RootLayout({
|
|||||||
{/* Toaster and FloatingPlayer are global — toasts and
|
{/* Toaster and FloatingPlayer are global — toasts and
|
||||||
currently-playing audio survive navigation between
|
currently-playing audio survive navigation between
|
||||||
route groups. */}
|
route groups. */}
|
||||||
|
{/* Announcements: at most one modal, only when signed
|
||||||
|
in, dismissed server-side so it never repeats. */}
|
||||||
|
<BroadcastModal />
|
||||||
<FloatingPlayer />
|
<FloatingPlayer />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<ServiceWorkerRegistrar />
|
<ServiceWorkerRegistrar />
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search for people and add them to a group.
|
* Search for people and add them to a group. Bottom drawer on mobile, modal on
|
||||||
|
* desktop — see ResponsiveDialog.
|
||||||
*
|
*
|
||||||
* Search runs through the same-origin /api/search/users proxy the search page
|
* 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
|
* uses. It returns handles, which is exactly what the member API takes — no id
|
||||||
@@ -9,12 +10,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Check, Search, X } from "lucide-react";
|
import { Check, Search } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useAuth } from "@/components/auth/auth-provider";
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
|
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||||
import { UserAvatar } from "@/components/ui/user-avatar";
|
import { UserAvatar } from "@/components/ui/user-avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { chat } from "@/lib/chat";
|
import { chat } from "@/lib/chat";
|
||||||
|
import { ResponsiveDialog } from "@/components/chat/responsive-dialog";
|
||||||
|
|
||||||
type Candidate = { handle: string; name: string; avatar_url: string | null };
|
type Candidate = { handle: string; name: string; avatar_url: string | null };
|
||||||
|
|
||||||
@@ -31,6 +34,7 @@ export function AddMembersDialog({
|
|||||||
onAdded: () => void;
|
onAdded: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const isMobile = useMediaQuery("(max-width: 767px)");
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
// Results are stored with the query they belong to, so what's displayed can
|
// 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
|
// be derived: a stale page from a previous query simply stops matching, and
|
||||||
@@ -68,12 +72,6 @@ export function AddMembersDialog({
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [q, searchable]);
|
}, [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) =>
|
const toggle = (handle: string) =>
|
||||||
setPicked((prev) => {
|
setPicked((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -100,85 +98,70 @@ export function AddMembersDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 sm:items-center">
|
<ResponsiveDialog
|
||||||
<div className="absolute inset-0" onClick={onClose} aria-hidden />
|
title="Add members"
|
||||||
<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">
|
description="Search for people to add to this group."
|
||||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
onClose={onClose}
|
||||||
<h2 className="text-sm font-semibold text-foreground">Add members</h2>
|
toolbar={
|
||||||
<button
|
<div className="flex items-center gap-2 rounded-lg bg-muted/40 px-3 py-2">
|
||||||
type="button"
|
<Search className="size-4 shrink-0 text-muted-foreground" />
|
||||||
onClick={onClose}
|
<input
|
||||||
aria-label="Close"
|
// Not auto-focused on mobile: the keyboard would open mid-way
|
||||||
className="rounded-md p-1 text-muted-foreground hover:text-foreground"
|
// through the drawer's slide-up and fight the animation.
|
||||||
>
|
autoFocus={!isMobile}
|
||||||
<X className="size-4" />
|
value={query}
|
||||||
</button>
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search by name or handle"
|
||||||
|
className="flex-1 bg-transparent text-sm outline-none pointer-coarse:text-base placeholder:text-muted-foreground"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
<div className="border-b border-border p-3">
|
footer={
|
||||||
<div className="flex items-center gap-2 rounded-lg bg-muted/40 px-3 py-2">
|
<Button
|
||||||
<Search className="size-4 shrink-0 text-muted-foreground" />
|
className="w-full"
|
||||||
<input
|
disabled={picked.size === 0 || busy}
|
||||||
autoFocus
|
onClick={submit}
|
||||||
value={query}
|
>
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
{picked.size === 0
|
||||||
placeholder="Search by name or handle"
|
? "Select people to add"
|
||||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
: `Add ${picked.size} ${picked.size === 1 ? "person" : "people"}`}
|
||||||
/>
|
</Button>
|
||||||
</div>
|
}
|
||||||
</div>
|
>
|
||||||
|
{!searchable ? (
|
||||||
<div className="flex-1 overflow-y-auto">
|
<p className="p-4 text-sm text-muted-foreground">
|
||||||
{!searchable ? (
|
Type at least two characters to search.
|
||||||
<p className="p-4 text-sm text-muted-foreground">
|
</p>
|
||||||
Type at least two characters to search.
|
) : visible.length === 0 ? (
|
||||||
</p>
|
<p className="p-4 text-sm text-muted-foreground">No one found.</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) => (
|
||||||
<ul className="divide-y divide-border">
|
<li key={u.handle}>
|
||||||
{visible.map((u) => (
|
<button
|
||||||
<li key={u.handle}>
|
type="button"
|
||||||
<button
|
onClick={() => toggle(u.handle)}
|
||||||
type="button"
|
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-muted/60"
|
||||||
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}
|
||||||
<UserAvatar
|
name={u.name}
|
||||||
src={u.avatar_url}
|
className="size-9 shrink-0"
|
||||||
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>
|
||||||
<div className="min-w-0 flex-1">
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
<p className="truncate text-sm text-foreground">
|
@{u.handle}
|
||||||
{u.name}
|
</p>
|
||||||
</p>
|
</div>
|
||||||
<p className="truncate text-xs text-muted-foreground">
|
{picked.has(u.handle) && (
|
||||||
@{u.handle}
|
<Check className="size-4 shrink-0 text-primary" />
|
||||||
</p>
|
)}
|
||||||
</div>
|
</button>
|
||||||
{picked.has(u.handle) && (
|
</li>
|
||||||
<Check className="size-4 shrink-0 text-primary" />
|
))}
|
||||||
)}
|
</ul>
|
||||||
</button>
|
)}
|
||||||
</li>
|
</ResponsiveDialog>
|
||||||
))}
|
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Paperclip, SendHorizonal, X } from "lucide-react";
|
import { Paperclip, Smile, SendHorizonal, X } from "lucide-react";
|
||||||
import { LinkPreviewCard } from "@/components/chat/link-preview-card";
|
import { LinkPreviewCard } from "@/components/chat/link-preview-card";
|
||||||
import {
|
import {
|
||||||
ComposerAttachments,
|
ComposerAttachments,
|
||||||
type StagedAttachment,
|
type StagedAttachment,
|
||||||
} from "@/components/chat/composer-attachments";
|
} from "@/components/chat/composer-attachments";
|
||||||
|
import { StickerPicker } from "@/components/chat/sticker-picker";
|
||||||
import { compressImage } from "@/lib/compress-image";
|
import { compressImage } from "@/lib/compress-image";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
@@ -37,6 +38,7 @@ export function ChatComposer({
|
|||||||
replyTo,
|
replyTo,
|
||||||
onCancelReply,
|
onCancelReply,
|
||||||
onSend,
|
onSend,
|
||||||
|
onTyping,
|
||||||
conversationId,
|
conversationId,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
placeholder = "Message…",
|
placeholder = "Message…",
|
||||||
@@ -45,11 +47,15 @@ export function ChatComposer({
|
|||||||
conversationId: string;
|
conversationId: string;
|
||||||
replyTo: ChatMessage | null;
|
replyTo: ChatMessage | null;
|
||||||
onCancelReply: () => void;
|
onCancelReply: () => void;
|
||||||
|
/** Called on each keystroke; throttled by the caller. */
|
||||||
|
onTyping?: () => void;
|
||||||
onSend: (
|
onSend: (
|
||||||
body: string,
|
body: string,
|
||||||
replyToId?: string,
|
replyToId?: string,
|
||||||
previewUrl?: string,
|
previewUrl?: string,
|
||||||
attachments?: UploadedAttachment[],
|
attachments?: UploadedAttachment[],
|
||||||
|
/** Reward artwork sent on its own. */
|
||||||
|
stickerKey?: string,
|
||||||
) => void;
|
) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
@@ -64,6 +70,7 @@ export function ChatComposer({
|
|||||||
const [staged, setStaged] = useState<StagedAttachment[]>([]);
|
const [staged, setStaged] = useState<StagedAttachment[]>([]);
|
||||||
const ref = useRef<HTMLTextAreaElement>(null);
|
const ref = useRef<HTMLTextAreaElement>(null);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [pickingSticker, setPickingSticker] = useState(false);
|
||||||
const lookupSeq = useRef(0);
|
const lookupSeq = useRef(0);
|
||||||
|
|
||||||
// Grow with the content, up to a cap.
|
// Grow with the content, up to a cap.
|
||||||
@@ -250,6 +257,15 @@ export function ChatComposer({
|
|||||||
>
|
>
|
||||||
<Paperclip className="size-4" />
|
<Paperclip className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPickingSticker(true)}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label="Send a sticker"
|
||||||
|
className="inline-flex size-9 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:text-foreground disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Smile className="size-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -257,7 +273,13 @@ export function ChatComposer({
|
|||||||
value={value}
|
value={value}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
onChange={(e) => setValue(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setValue(e.target.value);
|
||||||
|
// Fired on every keystroke; the handler throttles itself, so the
|
||||||
|
// thread hears "still typing" every few seconds rather than per
|
||||||
|
// character.
|
||||||
|
onTyping?.();
|
||||||
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
// Enter sends on a physical keyboard; Shift+Enter makes a newline.
|
// Enter sends on a physical keyboard; Shift+Enter makes a newline.
|
||||||
// On touch keyboards Enter must insert a newline instead — there is
|
// On touch keyboards Enter must insert a newline instead — there is
|
||||||
@@ -271,7 +293,12 @@ export function ChatComposer({
|
|||||||
submit();
|
submit();
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 resize-none rounded-2xl border border-border bg-muted/40 px-3 py-2 text-sm",
|
// 16px on touch devices: iOS Safari zooms the whole page when a
|
||||||
|
// focused field computes below that, which on a composer means the
|
||||||
|
// thread jumps every time the keyboard opens. Pointer-based devices
|
||||||
|
// keep the app's 14px chrome.
|
||||||
|
"flex-1 resize-none rounded-2xl border border-border bg-muted/40 px-3 py-2",
|
||||||
|
"text-sm pointer-coarse:text-base",
|
||||||
"placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/40",
|
"placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/40",
|
||||||
"disabled:opacity-60",
|
"disabled:opacity-60",
|
||||||
)}
|
)}
|
||||||
@@ -289,6 +316,16 @@ export function ChatComposer({
|
|||||||
<SendHorizonal className="size-4" />
|
<SendHorizonal className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{pickingSticker && (
|
||||||
|
<StickerPicker
|
||||||
|
// Sent on its own, with no text: a sticker IS the message.
|
||||||
|
onPick={(sticker) =>
|
||||||
|
onSend("", undefined, undefined, undefined, sticker.key)
|
||||||
|
}
|
||||||
|
onClose={() => setPickingSticker(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ export function GroupSettingsTab({
|
|||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
maxLength={120}
|
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"
|
className="flex-1 rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm outline-none pointer-coarse:text-base focus:ring-1 focus:ring-primary/40"
|
||||||
/>
|
/>
|
||||||
<Button size="sm" disabled={!renamed || busy} onClick={rename}>
|
<Button size="sm" disabled={!renamed || busy} onClick={rename}>
|
||||||
Save
|
Save
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ import { FileText } from "lucide-react";
|
|||||||
import { LinkPreviewCard } from "@/components/chat/link-preview-card";
|
import { LinkPreviewCard } from "@/components/chat/link-preview-card";
|
||||||
import { MediaGrid } from "@/components/chat/media-grid";
|
import { MediaGrid } from "@/components/chat/media-grid";
|
||||||
import { MediaLightbox } from "@/components/chat/media-lightbox";
|
import { MediaLightbox } from "@/components/chat/media-lightbox";
|
||||||
|
import { QuoteCard } from "@/components/chat/quote-card";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { SharedPostCard } from "@/components/chat/shared-post-card";
|
||||||
import { attachmentUrl, type Attachment } from "@/lib/chat";
|
import { attachmentUrl, type Attachment } from "@/lib/chat";
|
||||||
|
|
||||||
function humanSize(bytes?: number | null): string {
|
function humanSize(bytes?: number | null): string {
|
||||||
@@ -50,13 +53,26 @@ export function MessageAttachments({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mt-1 flex flex-col gap-1.5">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-1.5",
|
||||||
|
// Separates attachments from body text above them. A shared post
|
||||||
|
// leads its bubble, so there is nothing to separate from.
|
||||||
|
attachments.some((a) => a.kind === "entity") ? undefined : "mt-1",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{media.length > 0 && (
|
{media.length > 0 && (
|
||||||
<MediaGrid items={media} onOpen={(i) => setLightboxAt(i)} />
|
<MediaGrid items={media} onOpen={(i) => setLightboxAt(i)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{rest.map((a) => {
|
{rest.map((a) => {
|
||||||
switch (a.kind) {
|
switch (a.kind) {
|
||||||
|
case "entity":
|
||||||
|
return <SharedPostCard key={a.id} attachment={a} />;
|
||||||
|
|
||||||
|
case "quote":
|
||||||
|
return <QuoteCard key={a.id} attachment={a} />;
|
||||||
|
|
||||||
case "link":
|
case "link":
|
||||||
return (
|
return (
|
||||||
<LinkPreviewCard
|
<LinkPreviewCard
|
||||||
|
|||||||
@@ -40,16 +40,23 @@ const CLAIM_PX = 10;
|
|||||||
const LONG_PRESS_MS = 450;
|
const LONG_PRESS_MS = 450;
|
||||||
/** Movement that cancels a long press — a drag is not a hold. */
|
/** Movement that cancels a long press — a drag is not a hold. */
|
||||||
const LONG_PRESS_SLOP_PX = 8;
|
const LONG_PRESS_SLOP_PX = 8;
|
||||||
|
/** Longest gap between two taps still read as a double tap. */
|
||||||
|
const DOUBLE_TAP_MS = 300;
|
||||||
|
/** How far the second tap may land from the first. */
|
||||||
|
const DOUBLE_TAP_SLOP_PX = 24;
|
||||||
|
|
||||||
export function MessageGestures({
|
export function MessageGestures({
|
||||||
onReply,
|
onReply,
|
||||||
onMenu,
|
onMenu,
|
||||||
|
onDoubleTap,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
onReply: () => void;
|
onReply: () => void;
|
||||||
/** Opens the context menu at the given viewport point. */
|
/** Opens the context menu at the given viewport point. */
|
||||||
onMenu: (point: { x: number; y: number }) => void;
|
onMenu: (point: { x: number; y: number }) => void;
|
||||||
|
/** Two quick taps in the same spot — sends a heart. */
|
||||||
|
onDoubleTap?: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
@@ -61,6 +68,8 @@ export function MessageGestures({
|
|||||||
const holdTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const holdTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
// Set once a long press fires, so the release doesn't also trigger a reply.
|
// Set once a long press fires, so the release doesn't also trigger a reply.
|
||||||
const consumed = useRef(false);
|
const consumed = useRef(false);
|
||||||
|
// When and where the last tap landed, for pairing it with the next one.
|
||||||
|
const lastTap = useRef<{ t: number; x: number; y: number } | null>(null);
|
||||||
|
|
||||||
const cancelHold = useCallback(() => {
|
const cancelHold = useCallback(() => {
|
||||||
if (holdTimer.current) {
|
if (holdTimer.current) {
|
||||||
@@ -124,10 +133,29 @@ export function MessageGestures({
|
|||||||
setOffset(Math.max(0, Math.min(travel, MAX_PX)));
|
setOffset(Math.max(0, Math.min(travel, MAX_PX)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPointerUp = () => {
|
const onPointerUp = (e: React.PointerEvent) => {
|
||||||
cancelHold();
|
cancelHold();
|
||||||
// A long press already acted; releasing must not also fire a reply.
|
// A long press already acted; releasing must not also fire a reply.
|
||||||
if (!consumed.current && claimed.current && offset >= TRIGGER_PX) onReply();
|
if (!consumed.current && claimed.current && offset >= TRIGGER_PX) {
|
||||||
|
onReply();
|
||||||
|
lastTap.current = null;
|
||||||
|
} else if (!consumed.current && !claimed.current && onDoubleTap) {
|
||||||
|
// A stationary tap. Paired with a recent nearby one, it is a double tap.
|
||||||
|
// Tracked here rather than with onDoubleClick so touch and mouse behave
|
||||||
|
// the same, and so a swipe or a long press can never count as a tap.
|
||||||
|
const now = Date.now();
|
||||||
|
const prev = lastTap.current;
|
||||||
|
const near =
|
||||||
|
prev !== null &&
|
||||||
|
Math.abs(e.clientX - prev.x) < DOUBLE_TAP_SLOP_PX &&
|
||||||
|
Math.abs(e.clientY - prev.y) < DOUBLE_TAP_SLOP_PX;
|
||||||
|
if (prev && near && now - prev.t < DOUBLE_TAP_MS) {
|
||||||
|
lastTap.current = null;
|
||||||
|
onDoubleTap();
|
||||||
|
} else {
|
||||||
|
lastTap.current = { t: now, x: e.clientX, y: e.clientY };
|
||||||
|
}
|
||||||
|
}
|
||||||
reset();
|
reset();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -135,7 +163,10 @@ export function MessageGestures({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative"
|
// Full width so the whole row is draggable — WhatsApp lets you start the
|
||||||
|
// gesture anywhere on the line, including the empty space opposite the
|
||||||
|
// bubble. The bubble caps its own width inside this.
|
||||||
|
className="relative w-full"
|
||||||
onPointerDown={onPointerDown}
|
onPointerDown={onPointerDown}
|
||||||
onPointerMove={onPointerMove}
|
onPointerMove={onPointerMove}
|
||||||
onPointerUp={onPointerUp}
|
onPointerUp={onPointerUp}
|
||||||
|
|||||||
@@ -13,8 +13,16 @@ import { MessageText } from "@/components/chat/message-text";
|
|||||||
import { MessageAttachments } from "@/components/chat/message-attachments";
|
import { MessageAttachments } from "@/components/chat/message-attachments";
|
||||||
import { MessageTicks } from "@/components/chat/message-ticks";
|
import { MessageTicks } from "@/components/chat/message-ticks";
|
||||||
import { tickState } from "@/lib/chat-format";
|
import { tickState } from "@/lib/chat-format";
|
||||||
|
import { HEART_EMOJI } from "@/components/chat/reaction-row";
|
||||||
|
import { StickerGrid } from "@/components/chat/sticker-grid";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ChatMessage, ChatPeer, Receipts, ReplyPreview } from "@/lib/chat";
|
import type {
|
||||||
|
Attachment,
|
||||||
|
ChatMessage,
|
||||||
|
ChatPeer,
|
||||||
|
Receipts,
|
||||||
|
ReplyPreview,
|
||||||
|
} from "@/lib/chat";
|
||||||
|
|
||||||
/** Time only — the day is already established by the date separator above. */
|
/** Time only — the day is already established by the date separator above. */
|
||||||
function clockTime(iso: string): string {
|
function clockTime(iso: string): string {
|
||||||
@@ -24,6 +32,36 @@ function clockTime(iso: string): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when a message carries a shared post, which renders as a standalone
|
||||||
|
* card rather than bubble content.
|
||||||
|
*/
|
||||||
|
function sharesAnEntity(m: ChatMessage): boolean {
|
||||||
|
return (m.attachments ?? []).some((a) => a.kind === "entity");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stickers on a message, if any. */
|
||||||
|
function stickersOn(m: ChatMessage): Attachment[] {
|
||||||
|
return (m.attachments ?? []).filter((a) => a.kind === "sticker");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when a message is nothing but stickers. Those render bare — artwork
|
||||||
|
* inside a coloured bubble reads as a pasted image rather than a sticker.
|
||||||
|
*/
|
||||||
|
function isStickerOnly(m: ChatMessage): boolean {
|
||||||
|
const atts = m.attachments ?? [];
|
||||||
|
return !m.body && atts.length > 0 && atts.every((a) => a.kind === "sticker");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Distinct emoji shown before the rest collapse into "+N". */
|
||||||
|
const MAX_TRAIL_EMOJI = 4;
|
||||||
|
|
||||||
|
/** People who reacted, across all emoji. */
|
||||||
|
function totalReactions(m: ChatMessage): number {
|
||||||
|
return (m.reactions ?? []).reduce((n, r) => n + r.count, 0);
|
||||||
|
}
|
||||||
|
|
||||||
function bubbleCorners(mine: boolean, index: number, total: number): string {
|
function bubbleCorners(mine: boolean, index: number, total: number): string {
|
||||||
if (total === 1) return "rounded-2xl";
|
if (total === 1) return "rounded-2xl";
|
||||||
const first = index === 0;
|
const first = index === 0;
|
||||||
@@ -45,12 +83,10 @@ function bubbleCorners(mine: boolean, index: number, total: number): string {
|
|||||||
*/
|
*/
|
||||||
function QuotedReply({
|
function QuotedReply({
|
||||||
reply,
|
reply,
|
||||||
mine,
|
|
||||||
selfId,
|
selfId,
|
||||||
nameFor,
|
nameFor,
|
||||||
}: {
|
}: {
|
||||||
reply: ReplyPreview;
|
reply: ReplyPreview;
|
||||||
mine: boolean;
|
|
||||||
selfId: string;
|
selfId: string;
|
||||||
nameFor: (senderId: string | null | undefined) => string;
|
nameFor: (senderId: string | null | undefined) => string;
|
||||||
}) {
|
}) {
|
||||||
@@ -63,12 +99,11 @@ function QuotedReply({
|
|||||||
: reply.sender_name || nameFor(reply.sender_id);
|
: reply.sender_name || nameFor(reply.sender_id);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
// Neutral on both sides: this now sits on the page background rather
|
||||||
"mb-1 rounded-lg border-l-2 px-2 py-1 text-xs",
|
// than inside a bubble, so the old primary-foreground treatment (white
|
||||||
mine
|
// text, for a primary bubble) would be invisible in light mode.
|
||||||
? "border-primary-foreground/50 bg-black/10 text-primary-foreground/90 dark:text-white/90"
|
// Spacing comes from the column's gap — no margin of its own.
|
||||||
: "border-primary/50 bg-background/60 text-muted-foreground",
|
className="w-full rounded-lg border-l-2 border-primary/40 bg-muted/60 px-2 py-1 text-xs text-muted-foreground"
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<span className="block font-medium">{who}</span>
|
<span className="block font-medium">{who}</span>
|
||||||
<span className="line-clamp-1 break-words">
|
<span className="line-clamp-1 break-words">
|
||||||
@@ -81,7 +116,7 @@ function QuotedReply({
|
|||||||
export function MessageGroup({
|
export function MessageGroup({
|
||||||
messages,
|
messages,
|
||||||
mine,
|
mine,
|
||||||
peer,
|
sender,
|
||||||
receipts,
|
receipts,
|
||||||
showAvatar,
|
showAvatar,
|
||||||
selfId,
|
selfId,
|
||||||
@@ -90,10 +125,14 @@ export function MessageGroup({
|
|||||||
starredIds,
|
starredIds,
|
||||||
onRetry,
|
onRetry,
|
||||||
nameFor,
|
nameFor,
|
||||||
|
onReact,
|
||||||
|
onTrailClick,
|
||||||
}: {
|
}: {
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
mine: boolean;
|
mine: boolean;
|
||||||
peer?: ChatPeer | null;
|
/** Who sent this group of messages. Undefined for Kautilya, system
|
||||||
|
* messages, or a member who has since left. */
|
||||||
|
sender?: ChatPeer;
|
||||||
receipts: Receipts | null;
|
receipts: Receipts | null;
|
||||||
/** Groups show an avatar; a DM's own side never does. */
|
/** Groups show an avatar; a DM's own side never does. */
|
||||||
showAvatar: boolean;
|
showAvatar: boolean;
|
||||||
@@ -107,6 +146,10 @@ export function MessageGroup({
|
|||||||
onRetry: (message: ChatMessage) => void;
|
onRetry: (message: ChatMessage) => void;
|
||||||
/** Names a quoted sender; supplied by the thread's participant list. */
|
/** Names a quoted sender; supplied by the thread's participant list. */
|
||||||
nameFor: (senderId: string | null | undefined) => string;
|
nameFor: (senderId: string | null | undefined) => string;
|
||||||
|
/** Set or toggle a reaction. Picking an existing one removes it. */
|
||||||
|
onReact: (message: ChatMessage, emoji: string) => void;
|
||||||
|
/** Tapping the trail: retract if you reacted, otherwise show who did. */
|
||||||
|
onTrailClick: (message: ChatMessage) => void;
|
||||||
}) {
|
}) {
|
||||||
const last = messages[messages.length - 1];
|
const last = messages[messages.length - 1];
|
||||||
|
|
||||||
@@ -119,20 +162,31 @@ export function MessageGroup({
|
|||||||
>
|
>
|
||||||
{!mine && showAvatar && (
|
{!mine && showAvatar && (
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
src={peer?.avatar_url}
|
src={sender?.avatar_url}
|
||||||
name={peer?.name ?? "?"}
|
name={sender?.name ?? "?"}
|
||||||
className="size-7 shrink-0"
|
className="size-7 shrink-0"
|
||||||
textClassName="text-[10px]"
|
textClassName="text-[10px]"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
// flex-1 rather than max-w: the column spans the row so a swipe can
|
||||||
|
// start anywhere on it. min-w-0 lets long words wrap instead of
|
||||||
|
// widening the row.
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex max-w-[78%] flex-col gap-0.5",
|
"flex min-w-0 flex-1 flex-col gap-0.5",
|
||||||
mine ? "items-end" : "items-start",
|
mine ? "items-end" : "items-start",
|
||||||
!mine && !showAvatar && "ml-9",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{/* Name the sender once per run of messages. An avatar alone is thin
|
||||||
|
identification in a group — two people with similar photos, or
|
||||||
|
none at all, are indistinguishable at 28px. */}
|
||||||
|
{!mine && showAvatar && (
|
||||||
|
<span className="px-1 text-[11px] font-medium text-muted-foreground">
|
||||||
|
{sender?.name ?? "Unknown"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{messages.map((m, i) => (
|
{messages.map((m, i) => (
|
||||||
// A deleted message has nothing to quote, so it isn't swipeable.
|
// A deleted message has nothing to quote, so it isn't swipeable.
|
||||||
<MessageGestures
|
<MessageGestures
|
||||||
@@ -140,66 +194,166 @@ export function MessageGroup({
|
|||||||
disabled={!!m.deleted_at || !!m.pending}
|
disabled={!!m.deleted_at || !!m.pending}
|
||||||
onReply={() => onReply(m)}
|
onReply={() => onReply(m)}
|
||||||
onMenu={(point) => onMenu(m, point)}
|
onMenu={(point) => onMenu(m, point)}
|
||||||
|
onDoubleTap={() => onReact(m, HEART_EMOJI)}
|
||||||
>
|
>
|
||||||
|
{m.reply_to && !m.deleted_at && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex w-full",
|
||||||
|
mine ? "justify-end" : "justify-start",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Outside the bubble, above it: the quote is context for the
|
||||||
|
reply, not part of what was said. Capped to the bubble's
|
||||||
|
own width so the two read as one stack. */}
|
||||||
|
<div className="max-w-[78%]">
|
||||||
|
<QuotedReply
|
||||||
|
reply={m.reply_to}
|
||||||
|
selfId={selfId}
|
||||||
|
nameFor={nameFor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/bubble relative px-3 py-2 text-sm",
|
"flex w-full",
|
||||||
bubbleCorners(mine, i, messages.length),
|
mine ? "justify-end" : "justify-start",
|
||||||
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 && (
|
<div
|
||||||
<QuotedReply
|
className={cn(
|
||||||
reply={m.reply_to}
|
"group/bubble relative max-w-[78%] text-sm",
|
||||||
mine={mine}
|
bubbleCorners(mine, i, messages.length),
|
||||||
selfId={selfId}
|
// A shared post is a card in its own right: it brings its
|
||||||
nameFor={nameFor}
|
// own border, rounding and background, so the bubble drops
|
||||||
/>
|
// its chrome rather than drawing a second frame around it.
|
||||||
)}
|
// Any caption gets the padding back on its own line below.
|
||||||
{m.deleted_at ? (
|
sharesAnEntity(m) || isStickerOnly(m)
|
||||||
<span>This message was deleted</span>
|
? "overflow-hidden bg-transparent"
|
||||||
) : (
|
: cn(
|
||||||
<>
|
"px-3 py-2",
|
||||||
{m.body && <MessageText body={m.body} entities={m.entities} />}
|
m.deleted_at
|
||||||
<MessageAttachments attachments={m.attachments} />
|
? "bg-muted italic text-muted-foreground"
|
||||||
{/* An optimistic bubble for a captionless attachment has
|
: mine
|
||||||
|
? "bg-primary text-primary-foreground dark:text-white"
|
||||||
|
: "bg-muted text-foreground",
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{m.deleted_at ? (
|
||||||
|
<span>This message was deleted</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* A shared post leads and its caption follows, the way
|
||||||
|
the post itself reads. Everything else keeps text
|
||||||
|
first, with attachments beneath it. */}
|
||||||
|
{isStickerOnly(m) ? (
|
||||||
|
<StickerGrid
|
||||||
|
conversationId={m.conversation_id}
|
||||||
|
messageId={m.id}
|
||||||
|
stickers={stickersOn(m)}
|
||||||
|
mine={mine}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
sharesAnEntity(m) && (
|
||||||
|
<MessageAttachments attachments={m.attachments} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{m.body && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"block",
|
||||||
|
// The bubble gave up its padding for the card, so a
|
||||||
|
// caption supplies its own.
|
||||||
|
sharesAnEntity(m)
|
||||||
|
? "rounded-b-lg border border-t-0 border-border px-3 py-2"
|
||||||
|
: undefined,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MessageText body={m.body} entities={m.entities} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!sharesAnEntity(m) && !isStickerOnly(m) && (
|
||||||
|
<MessageAttachments attachments={m.attachments} />
|
||||||
|
)}
|
||||||
|
{/* An optimistic bubble for a captionless attachment has
|
||||||
nothing to draw until the server answers — say so rather
|
nothing to draw until the server answers — say so rather
|
||||||
than render an empty box. */}
|
than render an empty box. */}
|
||||||
{m.pending && !m.body && m.attachments.length === 0 && (
|
{m.pending && !m.body && !m.attachments?.length && (
|
||||||
<span className="italic opacity-80">Sending attachment…</span>
|
<span className="italic opacity-80">
|
||||||
)}
|
Sending attachment…
|
||||||
{starredIds.has(m.id) && (
|
</span>
|
||||||
<Star
|
)}
|
||||||
className="mt-1 size-3 fill-current opacity-70"
|
{starredIds.has(m.id) && (
|
||||||
aria-label="Starred"
|
<Star
|
||||||
/>
|
className="mt-1 size-3 fill-current opacity-70"
|
||||||
)}
|
aria-label="Starred"
|
||||||
</>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Desktop affordance. Swiping is the mobile gesture, but a
|
{/* Desktop affordance. Swiping is the mobile gesture, but a
|
||||||
drag is not discoverable with a mouse — and a real button is
|
drag is not discoverable with a mouse — and a real button is
|
||||||
also reachable by keyboard, which a gesture never is. */}
|
also reachable by keyboard, which a gesture never is. */}
|
||||||
{!m.deleted_at && !m.pending && (
|
{!m.deleted_at && !m.pending && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onReply(m)}
|
onClick={() => onReply(m)}
|
||||||
aria-label="Reply to this message"
|
aria-label="Reply to this message"
|
||||||
className={cn(
|
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",
|
"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",
|
"opacity-0 transition-opacity group-hover/bubble:opacity-100 focus-visible:opacity-100",
|
||||||
"hover:text-foreground sm:block",
|
"hover:text-foreground sm:block",
|
||||||
mine ? "-left-9" : "-right-9",
|
mine ? "-left-9" : "-right-9",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Reply className="size-3.5" />
|
<Reply className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{m.reactions?.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
// ONE control, not one per emoji. Two different jobs depending
|
||||||
|
// on whether you are in the reaction list already: if you have
|
||||||
|
// reacted, this retracts it; if you have not, it opens the
|
||||||
|
// list of who reacted. Per-chip toggling would make "see who
|
||||||
|
// reacted" unreachable for anyone who had.
|
||||||
|
onClick={() => onTrailClick(m)}
|
||||||
|
aria-label={
|
||||||
|
m.reactions.some((r) => r.mine)
|
||||||
|
? "Remove your reaction"
|
||||||
|
: "See who reacted"
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"-mt-1 inline-flex items-center gap-0.5 rounded-full border bg-background px-1.5 py-0.5 text-xs",
|
||||||
|
mine ? "self-end mr-1" : "self-start ml-1",
|
||||||
|
m.reactions.some((r) => r.mine)
|
||||||
|
? "border-primary/40 bg-primary/10"
|
||||||
|
: "border-border",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{m.reactions.slice(0, MAX_TRAIL_EMOJI).map((r) => (
|
||||||
|
<span key={r.emoji}>{r.emoji}</span>
|
||||||
|
))}
|
||||||
|
{m.reactions.length > MAX_TRAIL_EMOJI && (
|
||||||
|
<span className="tabular-nums text-muted-foreground">
|
||||||
|
+{m.reactions.length - MAX_TRAIL_EMOJI}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Total people, not distinct emoji — "😂❤️ 5" reads as five
|
||||||
|
reactions, which is what it is. */}
|
||||||
|
{totalReactions(m) > 1 && (
|
||||||
|
<span className="ml-0.5 tabular-nums text-muted-foreground">
|
||||||
|
{totalReactions(m)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</MessageGestures>
|
</MessageGestures>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -223,8 +377,10 @@ export function MessageGroup({
|
|||||||
Not sent — retry
|
Not sent — retry
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{mine && !last.deleted_at && !last.failed && (
|
{mine &&
|
||||||
last.pending ? (
|
!last.deleted_at &&
|
||||||
|
!last.failed &&
|
||||||
|
(last.pending ? (
|
||||||
<Clock
|
<Clock
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="Sending"
|
aria-label="Sending"
|
||||||
@@ -232,8 +388,7 @@ export function MessageGroup({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<MessageTicks state={tickState(last.seq, receipts)} />
|
<MessageTicks state={tickState(last.seq, receipts)} />
|
||||||
)
|
))}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ function dayLabel(iso: string): string {
|
|||||||
export function MessageList({
|
export function MessageList({
|
||||||
messages,
|
messages,
|
||||||
selfId,
|
selfId,
|
||||||
peer,
|
senderFor,
|
||||||
receipts,
|
receipts,
|
||||||
isGroup,
|
isGroup,
|
||||||
hasMore,
|
hasMore,
|
||||||
@@ -55,11 +55,15 @@ export function MessageList({
|
|||||||
starredIds,
|
starredIds,
|
||||||
onRetry,
|
onRetry,
|
||||||
nameFor,
|
nameFor,
|
||||||
|
typingNames,
|
||||||
|
onReact,
|
||||||
|
onTrailClick,
|
||||||
}: {
|
}: {
|
||||||
/** Ascending by seq — oldest first. */
|
/** Ascending by seq — oldest first. */
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
selfId: string;
|
selfId: string;
|
||||||
peer?: ChatPeer | null;
|
/** Resolves a message's sender for their avatar and name. */
|
||||||
|
senderFor: (senderId: string | null | undefined) => ChatPeer | undefined;
|
||||||
receipts: Receipts | null;
|
receipts: Receipts | null;
|
||||||
isGroup: boolean;
|
isGroup: boolean;
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
@@ -70,6 +74,11 @@ export function MessageList({
|
|||||||
starredIds: Set<string>;
|
starredIds: Set<string>;
|
||||||
onRetry: (message: ChatMessage) => void;
|
onRetry: (message: ChatMessage) => void;
|
||||||
nameFor: (senderId: string | null | undefined) => string;
|
nameFor: (senderId: string | null | undefined) => string;
|
||||||
|
/** Who is composing right now. Empty when nobody is. */
|
||||||
|
typingNames: string[];
|
||||||
|
onReact: (message: ChatMessage, emoji: string) => void;
|
||||||
|
/** Tapping the reaction trail. */
|
||||||
|
onTrailClick: (message: ChatMessage) => void;
|
||||||
}) {
|
}) {
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const atBottomRef = useRef(true);
|
const atBottomRef = useRef(true);
|
||||||
@@ -133,7 +142,10 @@ export function MessageList({
|
|||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
onScroll={onScroll}
|
onScroll={onScroll}
|
||||||
className="flex-1 overflow-y-auto overscroll-contain px-4 py-4"
|
// overflow-x-hidden is load-bearing: `overflow-y: auto` computes the
|
||||||
|
// other axis to `auto` rather than `visible`, so the swipe-to-reply
|
||||||
|
// transform would otherwise give the thread a horizontal scrollbar.
|
||||||
|
className="flex-1 overflow-x-hidden overflow-y-auto overscroll-contain px-4 py-4"
|
||||||
>
|
>
|
||||||
{loadingMore && (
|
{loadingMore && (
|
||||||
<p className="pb-3 text-center text-xs text-muted-foreground">
|
<p className="pb-3 text-center text-xs text-muted-foreground">
|
||||||
@@ -160,7 +172,7 @@ export function MessageList({
|
|||||||
key={row.key}
|
key={row.key}
|
||||||
messages={row.messages}
|
messages={row.messages}
|
||||||
mine={row.senderId === selfId}
|
mine={row.senderId === selfId}
|
||||||
peer={peer}
|
sender={senderFor(row.senderId)}
|
||||||
receipts={receipts}
|
receipts={receipts}
|
||||||
showAvatar={isGroup}
|
showAvatar={isGroup}
|
||||||
selfId={selfId}
|
selfId={selfId}
|
||||||
@@ -169,10 +181,25 @@ export function MessageList({
|
|||||||
starredIds={starredIds}
|
starredIds={starredIds}
|
||||||
onRetry={onRetry}
|
onRetry={onRetry}
|
||||||
nameFor={nameFor}
|
nameFor={nameFor}
|
||||||
|
onReact={onReact}
|
||||||
|
onTrailClick={onTrailClick}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{typingNames.length > 0 && (
|
||||||
|
<p
|
||||||
|
aria-live="polite"
|
||||||
|
className="px-1 pt-2 text-xs italic text-muted-foreground"
|
||||||
|
>
|
||||||
|
{typingNames.length === 1
|
||||||
|
? `${typingNames[0]} is typing…`
|
||||||
|
: `${typingNames.slice(0, 2).join(", ")}${
|
||||||
|
typingNames.length > 2 ? " and others" : ""
|
||||||
|
} are typing…`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
StarOff,
|
StarOff,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { ReactionRow } from "@/components/chat/reaction-row";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export type MenuPoint = { x: number; y: number };
|
export type MenuPoint = { x: number; y: number };
|
||||||
@@ -38,11 +39,17 @@ const MARGIN = 8;
|
|||||||
export function MessageMenu({
|
export function MessageMenu({
|
||||||
point,
|
point,
|
||||||
starred,
|
starred,
|
||||||
|
myReaction,
|
||||||
|
onReact,
|
||||||
actions,
|
actions,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
point: MenuPoint;
|
point: MenuPoint;
|
||||||
starred: boolean;
|
starred: boolean;
|
||||||
|
/** The viewer's current emoji on this message, if any. */
|
||||||
|
myReaction?: string;
|
||||||
|
/** Picking the same emoji again is a toggle — the caller decides. */
|
||||||
|
onReact: (emoji: string) => void;
|
||||||
actions: MessageMenuActions;
|
actions: MessageMenuActions;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -89,8 +96,19 @@ export function MessageMenu({
|
|||||||
// min-w-56 rather than something tighter: "Delete for everyone" is the
|
// min-w-56 rather than something tighter: "Delete for everyone" is the
|
||||||
// longest label and, with its icon and padding, was wrapping or sitting
|
// longest label and, with its icon and padding, was wrapping or sitting
|
||||||
// flush against the edge at a narrower width.
|
// flush against the edge at a narrower width.
|
||||||
className="fixed z-50 min-w-56 overflow-hidden rounded-xl border border-border bg-background py-1 shadow-lg"
|
className="fixed z-50 min-w-56 overflow-hidden rounded-xl border border-border bg-background pb-1 shadow-lg"
|
||||||
>
|
>
|
||||||
|
{/* Reactions lead: they are the most common thing to want from a
|
||||||
|
long press, and putting them in this menu keeps one gesture
|
||||||
|
responsible for one surface. */}
|
||||||
|
<ReactionRow
|
||||||
|
current={myReaction}
|
||||||
|
onPick={(emoji) => {
|
||||||
|
onReact(emoji);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<Item icon={<Reply className="size-4" />} onClick={run(actions.onReply)}>
|
<Item icon={<Reply className="size-4" />} onClick={run(actions.onReply)}>
|
||||||
Reply
|
Reply
|
||||||
</Item>
|
</Item>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a group: name it, pick people, done.
|
* Create a group: name it, pick people, done. Bottom drawer on mobile, modal on
|
||||||
|
* desktop — see ResponsiveDialog.
|
||||||
*
|
*
|
||||||
* Members are named by handle, matching the member-management API — search
|
* Members are named by handle, matching the member-management API — search
|
||||||
* results already identify people that way, so nothing needs id plumbing.
|
* results already identify people that way, so nothing needs id plumbing.
|
||||||
@@ -12,15 +13,18 @@ import { useRouter } from "next/navigation";
|
|||||||
import { Check, Search, X } from "lucide-react";
|
import { Check, Search, X } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useAuth } from "@/components/auth/auth-provider";
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
|
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||||
import { UserAvatar } from "@/components/ui/user-avatar";
|
import { UserAvatar } from "@/components/ui/user-avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { chat } from "@/lib/chat";
|
import { chat } from "@/lib/chat";
|
||||||
|
import { ResponsiveDialog } from "@/components/chat/responsive-dialog";
|
||||||
|
|
||||||
type Candidate = { handle: string; name: string; avatar_url: string | null };
|
type Candidate = { handle: string; name: string; avatar_url: string | null };
|
||||||
|
|
||||||
export function NewGroupDialog({ onClose }: { onClose: () => void }) {
|
export function NewGroupDialog({ onClose }: { onClose: () => void }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const isMobile = useMediaQuery("(max-width: 767px)");
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
// Results are stored with the query they belong to, so what's displayed can
|
// Results are stored with the query they belong to, so what's displayed can
|
||||||
@@ -56,12 +60,6 @@ export function NewGroupDialog({ onClose }: { onClose: () => void }) {
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [q, searchable]);
|
}, [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) =>
|
const toggle = (u: Candidate) =>
|
||||||
setPicked((prev) => {
|
setPicked((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
@@ -85,29 +83,21 @@ export function NewGroupDialog({ onClose }: { onClose: () => void }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 sm:items-center">
|
<ResponsiveDialog
|
||||||
<div className="absolute inset-0" onClick={onClose} aria-hidden />
|
title="New group"
|
||||||
<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">
|
description="Name the group and choose who to include."
|
||||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
onClose={onClose}
|
||||||
<h2 className="text-sm font-semibold text-foreground">New group</h2>
|
toolbar={
|
||||||
<button
|
<div className="space-y-3">
|
||||||
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
|
<input
|
||||||
autoFocus
|
// Not auto-focused on mobile: the keyboard would open mid-way
|
||||||
|
// through the drawer's slide-up and fight the animation.
|
||||||
|
autoFocus={!isMobile}
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
maxLength={120}
|
maxLength={120}
|
||||||
placeholder="Group name"
|
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"
|
className="w-full rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm outline-none pointer-coarse:text-base focus:ring-1 focus:ring-primary/40"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-2 rounded-lg bg-muted/40 px-3 py-2">
|
<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" />
|
<Search className="size-4 shrink-0 text-muted-foreground" />
|
||||||
@@ -115,7 +105,7 @@ export function NewGroupDialog({ onClose }: { onClose: () => void }) {
|
|||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Add people"
|
placeholder="Add people"
|
||||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
className="flex-1 bg-transparent text-sm outline-none pointer-coarse:text-base placeholder:text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -135,54 +125,51 @@ export function NewGroupDialog({ onClose }: { onClose: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
<div className="flex-1 overflow-y-auto">
|
footer={
|
||||||
{!searchable ? (
|
<Button
|
||||||
<p className="p-4 text-sm text-muted-foreground">
|
className="w-full"
|
||||||
Search to add people. You can also add them later.
|
disabled={title.trim() === "" || busy}
|
||||||
</p>
|
onClick={create}
|
||||||
) : visible.length === 0 ? (
|
>
|
||||||
<p className="p-4 text-sm text-muted-foreground">No one found.</p>
|
Create group
|
||||||
) : (
|
</Button>
|
||||||
<ul className="divide-y divide-border">
|
}
|
||||||
{visible.map((u) => (
|
>
|
||||||
<li key={u.handle}>
|
{!searchable ? (
|
||||||
<button
|
<p className="p-4 text-sm text-muted-foreground">
|
||||||
type="button"
|
Search to add people. You can also add them later.
|
||||||
onClick={() => toggle(u)}
|
</p>
|
||||||
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-muted/60"
|
) : visible.length === 0 ? (
|
||||||
>
|
<p className="p-4 text-sm text-muted-foreground">No one found.</p>
|
||||||
<UserAvatar
|
) : (
|
||||||
src={u.avatar_url}
|
<ul className="divide-y divide-border">
|
||||||
name={u.name}
|
{visible.map((u) => (
|
||||||
className="size-9 shrink-0"
|
<li key={u.handle}>
|
||||||
/>
|
<button
|
||||||
<div className="min-w-0 flex-1">
|
type="button"
|
||||||
<p className="truncate text-sm text-foreground">{u.name}</p>
|
onClick={() => toggle(u)}
|
||||||
<p className="truncate text-xs text-muted-foreground">
|
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-muted/60"
|
||||||
@{u.handle}
|
>
|
||||||
</p>
|
<UserAvatar
|
||||||
</div>
|
src={u.avatar_url}
|
||||||
{picked.has(u.handle) && (
|
name={u.name}
|
||||||
<Check className="size-4 shrink-0 text-primary" />
|
className="size-9 shrink-0"
|
||||||
)}
|
/>
|
||||||
</button>
|
<div className="min-w-0 flex-1">
|
||||||
</li>
|
<p className="truncate text-sm text-foreground">{u.name}</p>
|
||||||
))}
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
</ul>
|
@{u.handle}
|
||||||
)}
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{picked.has(u.handle) && (
|
||||||
<div className="border-t border-border p-3">
|
<Check className="size-4 shrink-0 text-primary" />
|
||||||
<Button
|
)}
|
||||||
className="w-full"
|
</button>
|
||||||
disabled={title.trim() === "" || busy}
|
</li>
|
||||||
onClick={create}
|
))}
|
||||||
>
|
</ul>
|
||||||
Create group
|
)}
|
||||||
</Button>
|
</ResponsiveDialog>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A passage shared from the library, rendered as a quotation.
|
||||||
|
*
|
||||||
|
* Tapping it returns the reader to the exact phrase: the stored path carries
|
||||||
|
* the `?hl=` fragment that TextHighlighter resolves on arrival, scrolling to
|
||||||
|
* the passage and flashing it.
|
||||||
|
*
|
||||||
|
* The path is always app-relative — the BFF rejects anything else — so this
|
||||||
|
* can navigate with the router rather than leaving the app.
|
||||||
|
*
|
||||||
|
* Styling is deliberately bare. This sits INSIDE a chat bubble, which already
|
||||||
|
* supplies the padding, the background and the rounding; giving the quote its
|
||||||
|
* own would draw a second bubble within the first. A left rule and italics are
|
||||||
|
* enough to say "quotation", and every colour is inherited so the same markup
|
||||||
|
* reads correctly on your own primary-coloured bubble and on the muted one
|
||||||
|
* opposite.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import type { Attachment } from "@/lib/chat";
|
||||||
|
|
||||||
|
export function QuoteCard({ attachment }: { attachment: Attachment }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const path = attachment.url ?? "";
|
||||||
|
const passage = attachment.description ?? "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => path && router.push(path)}
|
||||||
|
disabled={!path}
|
||||||
|
className="block w-full border-l-2 border-current/30 pl-2.5 text-left transition-opacity hover:opacity-80 disabled:cursor-default disabled:hover:opacity-100"
|
||||||
|
>
|
||||||
|
{/* The passage leads; the citation follows in the quieter role a
|
||||||
|
reference normally takes. */}
|
||||||
|
<span className="block text-sm italic leading-snug">{passage}</span>
|
||||||
|
{attachment.title && (
|
||||||
|
<span className="mt-0.5 block truncate text-xs opacity-70">
|
||||||
|
{attachment.title}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The emoji row at the top of a message's long-press menu.
|
||||||
|
*
|
||||||
|
* Five quick reactions plus a "+" that opens the full set — a drawer on
|
||||||
|
* mobile, a modal on desktop, reusing ResponsiveDialog so it behaves like
|
||||||
|
* every other chat surface.
|
||||||
|
*
|
||||||
|
* It sits inside the context menu rather than being its own popover: long
|
||||||
|
* press is one gesture, and two surfaces competing for it would mean neither
|
||||||
|
* felt reliable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { EMOJI_GROUPS } from "@/components/ui/emoji-picker";
|
||||||
|
import { ResponsiveDialog } from "@/components/chat/responsive-dialog";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/** What a double-tap sends. Matches the server's own default. */
|
||||||
|
export const HEART_EMOJI = "❤️";
|
||||||
|
|
||||||
|
/** The five offered without opening the full set. */
|
||||||
|
export const QUICK_REACTIONS = [HEART_EMOJI, "😂", "🙏", "😮", "😢"];
|
||||||
|
|
||||||
|
export function ReactionRow({
|
||||||
|
current,
|
||||||
|
onPick,
|
||||||
|
}: {
|
||||||
|
/** The viewer's existing reaction, so it can be shown as selected. */
|
||||||
|
current?: string;
|
||||||
|
/** Picking the current emoji again clears it — the caller decides. */
|
||||||
|
onPick: (emoji: string) => void;
|
||||||
|
}) {
|
||||||
|
const [browsing, setBrowsing] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-1 border-b border-border px-2 py-2">
|
||||||
|
{QUICK_REACTIONS.map((emoji) => (
|
||||||
|
<button
|
||||||
|
key={emoji}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPick(emoji)}
|
||||||
|
aria-label={`React ${emoji}`}
|
||||||
|
aria-pressed={current === emoji}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex size-9 items-center justify-center rounded-full text-lg transition-colors",
|
||||||
|
current === emoji ? "bg-primary/15" : "hover:bg-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{emoji}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBrowsing(true)}
|
||||||
|
aria-label="More reactions"
|
||||||
|
className="inline-flex size-9 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{browsing && (
|
||||||
|
<ResponsiveDialog
|
||||||
|
title="Pick a reaction"
|
||||||
|
description="Choose any emoji to react with."
|
||||||
|
onClose={() => setBrowsing(false)}
|
||||||
|
>
|
||||||
|
<div className="space-y-4 p-4">
|
||||||
|
{EMOJI_GROUPS.map((group) => (
|
||||||
|
<section key={group.label}>
|
||||||
|
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
{group.label}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-8 gap-1">
|
||||||
|
{group.emojis.map((emoji) => (
|
||||||
|
<button
|
||||||
|
key={emoji}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setBrowsing(false);
|
||||||
|
onPick(emoji);
|
||||||
|
}}
|
||||||
|
aria-label={`React ${emoji}`}
|
||||||
|
className="inline-flex size-9 items-center justify-center rounded-md text-lg hover:bg-muted"
|
||||||
|
>
|
||||||
|
{emoji}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who reacted to a message, and with what.
|
||||||
|
*
|
||||||
|
* Fetched on open rather than shipped with every message: the thread renders
|
||||||
|
* from grouped counts, and sending the full membership of every reaction on
|
||||||
|
* every page would grow with the room.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { UserAvatar } from "@/components/ui/user-avatar";
|
||||||
|
import { ResponsiveDialog } from "@/components/chat/responsive-dialog";
|
||||||
|
import { chat, type ReactionDetail } from "@/lib/chat";
|
||||||
|
|
||||||
|
export function ReactionsDrawer({
|
||||||
|
conversationId,
|
||||||
|
messageId,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
conversationId: string;
|
||||||
|
messageId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [rows, setRows] = useState<ReactionDetail[] | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
chat
|
||||||
|
.reactionDetails(conversationId, messageId)
|
||||||
|
.then((r) => {
|
||||||
|
if (!cancelled) setRows(r);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setRows([]);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [conversationId, messageId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveDialog
|
||||||
|
title="Reactions"
|
||||||
|
description={
|
||||||
|
rows === null
|
||||||
|
? "Loading…"
|
||||||
|
: `${rows.length} ${rows.length === 1 ? "person" : "people"} reacted.`
|
||||||
|
}
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
{rows === null ? (
|
||||||
|
<p className="p-4 text-sm text-muted-foreground">Loading…</p>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<p className="p-4 text-sm text-muted-foreground">No reactions.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-border">
|
||||||
|
{rows.map((r) => (
|
||||||
|
<li
|
||||||
|
key={r.user.id}
|
||||||
|
className="flex items-center gap-3 px-4 py-2.5"
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
src={r.user.avatar_url}
|
||||||
|
name={r.user.name}
|
||||||
|
className="size-9 shrink-0"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm text-foreground">{r.user.name}</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
@{r.user.handle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 text-lg" aria-label={r.emoji}>
|
||||||
|
{r.emoji}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shell the chat dialogs sit in: a bottom drawer on mobile, a centred
|
||||||
|
* modal on desktop.
|
||||||
|
*
|
||||||
|
* Extracted because "Add members" and "New group" are the same shape — title,
|
||||||
|
* a fixed toolbar of inputs, a scrolling list of people, a confirm button —
|
||||||
|
* and each would otherwise carry its own copy of the drawer/modal split.
|
||||||
|
*
|
||||||
|
* Follows the pattern already used by ReportDialog and ShareModal: the same
|
||||||
|
* `useMediaQuery("(max-width: 767px)")` breakpoint, vaul's Drawer below it,
|
||||||
|
* and a portalled modal above.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerDescription,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
} from "@/components/ui/drawer";
|
||||||
|
|
||||||
|
export function ResponsiveDialog({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
onClose,
|
||||||
|
toolbar,
|
||||||
|
footer,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Fixed block under the title — search fields, selected chips. */
|
||||||
|
toolbar?: ReactNode;
|
||||||
|
/** Pinned action row at the bottom. */
|
||||||
|
footer?: ReactNode;
|
||||||
|
/** The scrolling region between toolbar and footer. */
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const isMobile = useMediaQuery("(max-width: 767px)");
|
||||||
|
|
||||||
|
// Desktop only: Escape to dismiss, and lock the page behind the modal. The
|
||||||
|
// drawer does both itself, and doubling up on the scroll lock fights vaul's
|
||||||
|
// own body handling on iOS.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMobile) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
document.body.style.overflow = prev;
|
||||||
|
};
|
||||||
|
}, [isMobile, onClose]);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<Drawer open onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DrawerContent className="bg-card">
|
||||||
|
<DrawerHeader className="text-left">
|
||||||
|
<DrawerTitle>{title}</DrawerTitle>
|
||||||
|
<DrawerDescription>{description}</DrawerDescription>
|
||||||
|
</DrawerHeader>
|
||||||
|
{toolbar && <div className="border-b border-border px-4 pb-3">{toolbar}</div>}
|
||||||
|
{/* Capped rather than flex-1: a drawer sizes to its content, so an
|
||||||
|
unbounded list would grow the sheet past the screen. */}
|
||||||
|
<div className="max-h-[45vh] overflow-y-auto overscroll-contain">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{footer && <div className="border-t border-border p-4">{footer}</div>}
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof document === "undefined") return null;
|
||||||
|
return createPortal(
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-100 bg-foreground/40 backdrop-blur-sm animate-in fade-in-0"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={title}
|
||||||
|
className="fixed left-1/2 top-1/2 z-101 w-full max-w-md -translate-x-1/2 -translate-y-1/2 px-4"
|
||||||
|
>
|
||||||
|
<div className="flex max-h-[80vh] flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-xl">
|
||||||
|
<div className="flex items-start gap-3 border-b border-border px-5 py-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h2 className="font-[family-name:var(--font-title)] text-lg font-semibold text-foreground">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-0.5 text-sm leading-relaxed text-muted-foreground">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
className="inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{toolbar && <div className="border-b border-border p-3">{toolbar}</div>}
|
||||||
|
<div className="flex-1 overflow-y-auto">{children}</div>
|
||||||
|
{footer && <div className="border-t border-border p-3">{footer}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send the selected passage to someone, from the reading toolbar.
|
||||||
|
*
|
||||||
|
* Two ways in, because they answer different questions:
|
||||||
|
*
|
||||||
|
* Frequent chats — people you already talk to. A grid of avatars, because
|
||||||
|
* you recognise these faces and want the shortest possible path.
|
||||||
|
* Friends — people you follow or who follow you but have never messaged.
|
||||||
|
* Rows with an explicit button, because starting a conversation is a bigger
|
||||||
|
* step than continuing one and shouldn't be a mis-tap away.
|
||||||
|
*
|
||||||
|
* Anyone with an existing thread is removed from Friends, so nobody appears
|
||||||
|
* twice and the second list is genuinely "people you haven't written to".
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Check, Loader2, Send } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
|
import { UserAvatar } from "@/components/ui/user-avatar";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ResponsiveDialog } from "@/components/chat/responsive-dialog";
|
||||||
|
import { chat, type ChatPeer, type QuotePayload } from "@/lib/chat";
|
||||||
|
import { relationships, type UserSummary } from "@/lib/social";
|
||||||
|
|
||||||
|
/** How many existing threads to offer before it stops being "frequent". */
|
||||||
|
const FREQUENT_LIMIT = 9;
|
||||||
|
/** Friends fetched from each side of the follow graph. */
|
||||||
|
const FRIENDS_PER_SIDE = 20;
|
||||||
|
|
||||||
|
type Frequent = { conversationId: string; peer: ChatPeer };
|
||||||
|
/** Per-person send state, keyed by handle. */
|
||||||
|
type SendState = "idle" | "sending" | "sent";
|
||||||
|
|
||||||
|
export function ShareToChatDialog({
|
||||||
|
body = "",
|
||||||
|
quote,
|
||||||
|
entity,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
/** Plain message text. Empty when sending a quote on its own. */
|
||||||
|
body?: string;
|
||||||
|
/** A passage from the library, sent as a tappable quotation. */
|
||||||
|
quote?: QuotePayload;
|
||||||
|
/** A post, sent as a card. Only the reference travels — the BFF resolves
|
||||||
|
* the contents and re-checks visibility for each viewer. */
|
||||||
|
entity?: { type: "post"; id: string };
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [frequent, setFrequent] = useState<Frequent[]>([]);
|
||||||
|
const [friends, setFriends] = useState<UserSummary[]>([]);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
// Derived rather than stored: with no session there is nothing to fetch, so
|
||||||
|
// "loading" is false immediately instead of needing an effect to say so.
|
||||||
|
const loading = !!user && !loaded;
|
||||||
|
const [states, setStates] = useState<Record<string, SendState>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// The toolbar gates this behind requireAuth; with no session there is
|
||||||
|
// nothing to fetch and `loading` is already false.
|
||||||
|
if (!user) return;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
// Existing threads first: they populate the grid AND tell us who to drop
|
||||||
|
// from Friends, so both lists come out of one pass.
|
||||||
|
const [inbox, followers, following] = await Promise.all([
|
||||||
|
chat.inbox("chats").catch(() => []),
|
||||||
|
relationships
|
||||||
|
.followers(user.handle, { limit: FRIENDS_PER_SIDE })
|
||||||
|
.catch(() => ({ users: [] as UserSummary[] })),
|
||||||
|
relationships
|
||||||
|
.following(user.handle, { limit: FRIENDS_PER_SIDE })
|
||||||
|
.catch(() => ({ users: [] as UserSummary[] })),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
// The inbox arrives newest-first, so "frequent" is really "most recent"
|
||||||
|
// — a reasonable stand-in until there's a message-count to rank by.
|
||||||
|
const dms: Frequent[] = inbox
|
||||||
|
.filter((c) => c.kind === "dm" && c.peer)
|
||||||
|
.slice(0, FREQUENT_LIMIT)
|
||||||
|
.map((c) => ({ conversationId: c.id, peer: c.peer as ChatPeer }));
|
||||||
|
|
||||||
|
const talkingTo = new Set(dms.map((d) => d.peer.handle));
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const rest = [...following.users, ...followers.users].filter((u) => {
|
||||||
|
// Following before followers, so mutuals show up under the side you
|
||||||
|
// chose. Self can appear in neither.
|
||||||
|
if (u.handle === user.handle || talkingTo.has(u.handle)) return false;
|
||||||
|
if (seen.has(u.handle)) return false;
|
||||||
|
seen.add(u.handle);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
setFrequent(dms);
|
||||||
|
setFriends(rest);
|
||||||
|
setLoaded(true);
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const mark = (handle: string, state: SendState) =>
|
||||||
|
setStates((prev) => ({ ...prev, [handle]: state }));
|
||||||
|
|
||||||
|
/** Sends into an existing thread, or opens one first. */
|
||||||
|
const sendTo = async (handle: string, conversationId?: string) => {
|
||||||
|
if (states[handle] === "sending" || states[handle] === "sent") return;
|
||||||
|
mark(handle, "sending");
|
||||||
|
try {
|
||||||
|
const id = conversationId ?? (await chat.openDM(handle)).id;
|
||||||
|
await chat.send(id, body, { quote, entity });
|
||||||
|
mark(handle, "sent");
|
||||||
|
} catch {
|
||||||
|
mark(handle, "idle");
|
||||||
|
toast.error("Could not send that message");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sentCount = Object.values(states).filter((s) => s === "sent").length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveDialog
|
||||||
|
title="Send to a chat"
|
||||||
|
description="Share this passage in a conversation."
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<Button variant="outline" className="w-full" onClick={onClose}>
|
||||||
|
{sentCount > 0 ? "Done" : "Cancel"}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<p className="p-4 text-sm text-muted-foreground">Loading…</p>
|
||||||
|
) : frequent.length === 0 && friends.length === 0 ? (
|
||||||
|
<p className="p-4 text-sm text-muted-foreground">
|
||||||
|
No one to send to yet. Follow someone to start a conversation.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{frequent.length > 0 && (
|
||||||
|
<section className="p-4">
|
||||||
|
<h3 className="mb-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Frequent chats
|
||||||
|
</h3>
|
||||||
|
<ul className="grid grid-cols-3 gap-3">
|
||||||
|
{frequent.map(({ conversationId, peer }) => {
|
||||||
|
const state = states[peer.handle] ?? "idle";
|
||||||
|
return (
|
||||||
|
<li key={peer.handle}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => sendTo(peer.handle, conversationId)}
|
||||||
|
disabled={state !== "idle"}
|
||||||
|
className="flex w-full flex-col items-center gap-1.5 rounded-lg p-2 text-center hover:bg-muted/60 disabled:opacity-70"
|
||||||
|
>
|
||||||
|
<span className="relative">
|
||||||
|
<UserAvatar
|
||||||
|
src={peer.avatar_url}
|
||||||
|
name={peer.name}
|
||||||
|
className="size-14"
|
||||||
|
/>
|
||||||
|
{state !== "idle" && (
|
||||||
|
<span className="absolute inset-0 flex items-center justify-center rounded-full bg-background/70">
|
||||||
|
{state === "sending" ? (
|
||||||
|
<Loader2 className="size-4 animate-spin text-foreground" />
|
||||||
|
) : (
|
||||||
|
<Check className="size-5 text-primary" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="w-full truncate text-xs text-foreground">
|
||||||
|
{peer.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{friends.length > 0 && (
|
||||||
|
<section className={frequent.length > 0 ? "border-t border-border" : ""}>
|
||||||
|
<h3 className="px-4 pb-2 pt-4 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Friends
|
||||||
|
</h3>
|
||||||
|
<ul className="divide-y divide-border">
|
||||||
|
{friends.map((u) => {
|
||||||
|
const state = states[u.handle] ?? "idle";
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={u.handle}
|
||||||
|
className="flex items-center gap-3 px-4 py-2.5"
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
src={u.avatar_url}
|
||||||
|
name={u.name}
|
||||||
|
className="size-9 shrink-0"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm text-foreground">
|
||||||
|
{u.name}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
@{u.handle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={state === "sent" ? "outline" : "default"}
|
||||||
|
disabled={state !== "idle"}
|
||||||
|
onClick={() => sendTo(u.handle)}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
{state === "sending" ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : state === "sent" ? (
|
||||||
|
<>
|
||||||
|
<Check className="size-4" /> Sent
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send className="size-4" /> Send
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A post shared into a chat, rendered as a card with its author.
|
||||||
|
*
|
||||||
|
* The contents are resolved by the BFF on every read, per viewer — never from
|
||||||
|
* a snapshot frozen at send time. So a post that has since been deleted or
|
||||||
|
* hidden arrives as `entity_unavailable` and renders as a tombstone rather
|
||||||
|
* than quietly showing content the reader is no longer allowed to see.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ImageOff } from "lucide-react";
|
||||||
|
import { UserAvatar } from "@/components/ui/user-avatar";
|
||||||
|
import type { Attachment } from "@/lib/chat";
|
||||||
|
|
||||||
|
export function SharedPostCard({ attachment }: { attachment: Attachment }) {
|
||||||
|
const post = attachment.entity;
|
||||||
|
|
||||||
|
if (!post || attachment.entity_unavailable) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-border bg-muted/30 px-3 py-2.5">
|
||||||
|
<ImageOff className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
This post is no longer available
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/u/${encodeURIComponent(post.author_handle)}/posts#post-${post.id}`}
|
||||||
|
className="block overflow-hidden rounded-lg border border-border bg-card transition-colors hover:bg-muted/40"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 px-3 py-2">
|
||||||
|
<UserAvatar
|
||||||
|
src={post.author_avatar_url ?? null}
|
||||||
|
name={post.author_name}
|
||||||
|
className="size-6 shrink-0"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-xs font-medium text-foreground">
|
||||||
|
{post.author_name}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate text-[11px] text-muted-foreground">
|
||||||
|
@{post.author_handle}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{post.image_url && (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={post.image_url}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="aspect-square w-full object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{post.caption && (
|
||||||
|
<span className="block px-3 py-2 text-sm leading-relaxed text-foreground line-clamp-3">
|
||||||
|
{post.caption}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{post.source_title && (
|
||||||
|
<span className="block truncate px-3 pb-2 text-xs text-muted-foreground">
|
||||||
|
{post.source_title}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stickers on a message: a 2-column grid, rendered without a chat bubble.
|
||||||
|
*
|
||||||
|
* Unlike the media grid, this never collapses into "+N more" — every sticker
|
||||||
|
* sent is drawn. They are small and deliberately playful, and hiding some
|
||||||
|
* behind a counter would make a wall of them read as an attachment list
|
||||||
|
* rather than what it is.
|
||||||
|
*
|
||||||
|
* Long-pressing or right-clicking one offers to keep it, which is the only
|
||||||
|
* way a sticker spreads beyond the person who earned it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { chat, type Attachment } from "@/lib/chat";
|
||||||
|
|
||||||
|
export function StickerGrid({
|
||||||
|
conversationId,
|
||||||
|
messageId,
|
||||||
|
stickers,
|
||||||
|
/** Own stickers are already in your collection — no point offering to save. */
|
||||||
|
mine,
|
||||||
|
}: {
|
||||||
|
conversationId: string;
|
||||||
|
messageId: string;
|
||||||
|
stickers: Attachment[];
|
||||||
|
mine: boolean;
|
||||||
|
}) {
|
||||||
|
const [saving, setSaving] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (stickers.length === 0) return null;
|
||||||
|
|
||||||
|
const save = async (a: Attachment) => {
|
||||||
|
const key = a.entity_id;
|
||||||
|
if (!key || mine || saving) return;
|
||||||
|
setSaving(key);
|
||||||
|
try {
|
||||||
|
await chat.saveSticker(conversationId, messageId, key);
|
||||||
|
toast.success(`Saved ${a.title ?? "sticker"} to your collection`);
|
||||||
|
} catch {
|
||||||
|
toast.error("Could not save that sticker");
|
||||||
|
} finally {
|
||||||
|
setSaving(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid w-fit grid-cols-2 gap-1">
|
||||||
|
{stickers.map((a) => (
|
||||||
|
<button
|
||||||
|
key={a.id}
|
||||||
|
type="button"
|
||||||
|
disabled={mine}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
if (mine) return;
|
||||||
|
e.preventDefault();
|
||||||
|
void save(a);
|
||||||
|
}}
|
||||||
|
title={mine ? (a.title ?? "") : `${a.title ?? "Sticker"} — hold to save`}
|
||||||
|
aria-label={a.title ?? "Sticker"}
|
||||||
|
className="relative size-20 disabled:cursor-default sm:size-24"
|
||||||
|
>
|
||||||
|
{a.image_url && (
|
||||||
|
<Image
|
||||||
|
src={a.image_url}
|
||||||
|
alt={a.title ?? ""}
|
||||||
|
fill
|
||||||
|
sizes="96px"
|
||||||
|
className="object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sticker drawer, opened from the composer.
|
||||||
|
*
|
||||||
|
* The collection is whatever the server says it is — rewards earned through
|
||||||
|
* sādhana plus stickers saved from a chat — so a sticker you were sent
|
||||||
|
* appears here the moment you keep it, with no client-side bookkeeping.
|
||||||
|
*
|
||||||
|
* Empty is a real state worth explaining: stickers are earned, so someone
|
||||||
|
* with none needs to know how to get one rather than seeing a blank sheet.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { ResponsiveDialog } from "@/components/chat/responsive-dialog";
|
||||||
|
import { chat, type Sticker } from "@/lib/chat";
|
||||||
|
|
||||||
|
export function StickerPicker({
|
||||||
|
onPick,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
onPick: (sticker: Sticker) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [stickers, setStickers] = useState<Sticker[] | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
chat
|
||||||
|
.stickers()
|
||||||
|
.then((s) => {
|
||||||
|
if (!cancelled) setStickers(s);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setStickers([]);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveDialog
|
||||||
|
title="Stickers"
|
||||||
|
description="Earned through sādhana, or saved from a chat."
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
{stickers === null ? (
|
||||||
|
<p className="p-4 text-sm text-muted-foreground">Loading…</p>
|
||||||
|
) : stickers.length === 0 ? (
|
||||||
|
<p className="p-4 text-sm text-muted-foreground">
|
||||||
|
No stickers yet. Keep your sādhana streak — a sacred reward arrives
|
||||||
|
every 7 days — or save one someone sends you.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-4 gap-2 p-4 sm:grid-cols-5">
|
||||||
|
{stickers.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onPick(s);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
title={s.name}
|
||||||
|
aria-label={s.name}
|
||||||
|
className="relative aspect-square rounded-lg p-1 transition-colors hover:bg-muted"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={s.image_url}
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
sizes="80px"
|
||||||
|
className="object-contain p-1"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The column that holds the header, page and footer.
|
||||||
|
*
|
||||||
|
* Exists as a client component for one reason: the bottom padding that
|
||||||
|
* reserves space for the mobile tab bar has to disappear wherever the tab bar
|
||||||
|
* does, and that decision depends on the route. Left in the server layout, the
|
||||||
|
* padding would survive the bar and leave a 64px gap under a chat composer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useHidesAppChrome } from "@/lib/use-chrome-visibility";
|
||||||
|
|
||||||
|
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||||
|
const noTabBar = useHidesAppChrome();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 flex-col md:pl-16",
|
||||||
|
// Matches SiteBottomNav's own visibility exactly.
|
||||||
|
noTabBar ? "pb-0" : "pb-16 md:pb-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The unread badge on a nav entry.
|
||||||
|
*
|
||||||
|
* Counts threads waiting on you, not messages: one busy group shouldn't read
|
||||||
|
* as twenty things needing attention. Muted threads are excluded server-side,
|
||||||
|
* so muting a noisy group clears the badge while its unread count stays
|
||||||
|
* visible in the inbox.
|
||||||
|
*
|
||||||
|
* Wraps its icon rather than being positioned by each caller, so the sidebar
|
||||||
|
* and bottom bar can't drift apart.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useNotifications } from "@/components/notifications/notifications-provider";
|
||||||
|
|
||||||
|
export function NavUnreadBadge({ children }: { children: React.ReactNode }) {
|
||||||
|
const { chatUnread } = useNotifications();
|
||||||
|
if (chatUnread <= 0) return <>{children}</>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="relative inline-flex">
|
||||||
|
{children}
|
||||||
|
<span
|
||||||
|
// Announced through the link's aria-label instead; this is decoration.
|
||||||
|
aria-hidden
|
||||||
|
className="absolute -right-1.5 -top-1 min-w-4 rounded-full bg-primary px-1 text-center text-[10px] font-semibold leading-4 text-white ring-2 ring-background"
|
||||||
|
>
|
||||||
|
{chatUnread > 9 ? "9+" : chatUnread}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The count, for callers that need it in an accessible name. */
|
||||||
|
export function useChatUnread(): number {
|
||||||
|
return useNotifications().chatUnread;
|
||||||
|
}
|
||||||
@@ -14,7 +14,12 @@ import Image from "next/image";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { Home, MessageCircle, Search, User as UserIcon } from "lucide-react";
|
import { Home, MessageCircle, Search, User as UserIcon } from "lucide-react";
|
||||||
|
import {
|
||||||
|
NavUnreadBadge,
|
||||||
|
useChatUnread,
|
||||||
|
} from "@/components/layout/nav-unread-badge";
|
||||||
import { useAuth } from "@/components/auth/auth-provider";
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
|
import { useHidesAppChrome } from "@/lib/use-chrome-visibility";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type Item = {
|
type Item = {
|
||||||
@@ -27,7 +32,11 @@ type Item = {
|
|||||||
|
|
||||||
export function SiteBottomNav() {
|
export function SiteBottomNav() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const hidden = useHidesAppChrome();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
// Read here rather than inside the badge so the count can also reach the
|
||||||
|
// link's accessible name — the badge itself is decorative.
|
||||||
|
const chatUnread = useChatUnread();
|
||||||
|
|
||||||
const items: Item[] = [
|
const items: Item[] = [
|
||||||
{ href: "/", label: "Home", icon: <Home className="size-5" /> },
|
{ href: "/", label: "Home", icon: <Home className="size-5" /> },
|
||||||
@@ -51,7 +60,7 @@ export function SiteBottomNav() {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/chat",
|
href: "/chats",
|
||||||
label: "Chat",
|
label: "Chat",
|
||||||
icon: <MessageCircle className="size-5" />,
|
icon: <MessageCircle className="size-5" />,
|
||||||
},
|
},
|
||||||
@@ -90,6 +99,9 @@ export function SiteBottomNav() {
|
|||||||
return pathname === item.href || pathname.startsWith(`${item.href}/`);
|
return pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A chat thread owns the bottom edge — its composer lives there.
|
||||||
|
if (hidden) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
aria-label="Primary"
|
aria-label="Primary"
|
||||||
@@ -113,7 +125,11 @@ export function SiteBottomNav() {
|
|||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={item.href}
|
href={item.href}
|
||||||
aria-label={item.label}
|
aria-label={
|
||||||
|
item.href === "/chats" && chatUnread > 0
|
||||||
|
? `${item.label} (${chatUnread} unread)`
|
||||||
|
: item.label
|
||||||
|
}
|
||||||
aria-current={active ? "page" : undefined}
|
aria-current={active ? "page" : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-12 h-12 rounded-md flex flex-col items-center justify-center",
|
"w-12 h-12 rounded-md flex flex-col items-center justify-center",
|
||||||
@@ -122,7 +138,11 @@ export function SiteBottomNav() {
|
|||||||
: "text-muted-foreground hover:text-foreground",
|
: "text-muted-foreground hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.icon}
|
{item.href === "/chats" ? (
|
||||||
|
<NavUnreadBadge>{item.icon}</NavUnreadBadge>
|
||||||
|
) : (
|
||||||
|
item.icon
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import Link from "next/link";
|
|||||||
import { ReadingSettings } from "@/components/reading-settings";
|
import { ReadingSettings } from "@/components/reading-settings";
|
||||||
import { NotificationBell } from "@/components/layout/notification-bell";
|
import { NotificationBell } from "@/components/layout/notification-bell";
|
||||||
import { useAuth } from "@/components/auth/auth-provider";
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
|
import { useHidesAppChrome } from "@/lib/use-chrome-visibility";
|
||||||
|
|
||||||
export function SiteHeader() {
|
export function SiteHeader() {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
@@ -33,6 +34,10 @@ export function SiteHeader() {
|
|||||||
// auth check we treat `loading` as anonymous so the gear doesn't flash
|
// auth check we treat `loading` as anonymous so the gear doesn't flash
|
||||||
// in and out — once auth resolves, it hides for signed-in users.
|
// in and out — once auth resolves, it hides for signed-in users.
|
||||||
const showMobileGear = !loading && !user;
|
const showMobileGear = !loading && !user;
|
||||||
|
// A chat thread has its own header — back button, name, avatar — so the
|
||||||
|
// wordmark bar would stack a second title above it.
|
||||||
|
const hidden = useHidesAppChrome();
|
||||||
|
if (hidden) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-30 bg-background/60 backdrop-blur-md">
|
<header className="sticky top-0 z-30 bg-background/60 backdrop-blur-md">
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { useState } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Menu } from "lucide-react";
|
import { Menu } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useHidesAppChrome } from "@/lib/use-chrome-visibility";
|
||||||
import {
|
import {
|
||||||
Drawer,
|
Drawer,
|
||||||
DrawerContent,
|
DrawerContent,
|
||||||
@@ -43,6 +44,10 @@ export function MobileNavToggle({
|
|||||||
items: ReadonlyArray<NavItem>;
|
items: ReadonlyArray<NavItem>;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
// A thread already has a back button in its own header; a hamburger beside
|
||||||
|
// it offers a second, conflicting way out.
|
||||||
|
const hidden = useHidesAppChrome();
|
||||||
|
if (hidden) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer open={open} onOpenChange={setOpen} direction="left">
|
<Drawer open={open} onOpenChange={setOpen} direction="left">
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ import {
|
|||||||
Settings2,
|
Settings2,
|
||||||
User as UserIcon,
|
User as UserIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
NavUnreadBadge,
|
||||||
|
useChatUnread,
|
||||||
|
} from "@/components/layout/nav-unread-badge";
|
||||||
import { useAuth } from "@/components/auth/auth-provider";
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
import { ReadingSettings } from "@/components/reading-settings";
|
import { ReadingSettings } from "@/components/reading-settings";
|
||||||
import { KautilyaIcon } from "@/components/selection-toolbar/shared";
|
import { KautilyaIcon } from "@/components/selection-toolbar/shared";
|
||||||
@@ -177,10 +181,15 @@ function SidebarLink({
|
|||||||
* settings popover is up) and the mouse isn't actually hovering. */
|
* settings popover is up) and the mouse isn't actually hovering. */
|
||||||
expandedOverride?: boolean;
|
expandedOverride?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const chatUnread = useChatUnread();
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={item.href}
|
href={item.href}
|
||||||
aria-label={item.label}
|
aria-label={
|
||||||
|
item.href === "/chats" && chatUnread > 0
|
||||||
|
? `${item.label} (${chatUnread} unread)`
|
||||||
|
: item.label
|
||||||
|
}
|
||||||
title={item.label}
|
title={item.label}
|
||||||
className={cn(
|
className={cn(
|
||||||
"mx-2 h-11 flex items-center gap-3 px-3 rounded-lg overflow-hidden whitespace-nowrap text-sm font-medium",
|
"mx-2 h-11 flex items-center gap-3 px-3 rounded-lg overflow-hidden whitespace-nowrap text-sm font-medium",
|
||||||
@@ -190,7 +199,13 @@ function SidebarLink({
|
|||||||
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="shrink-0">{item.icon}</span>
|
<span className="shrink-0">
|
||||||
|
{item.href === "/chats" ? (
|
||||||
|
<NavUnreadBadge>{item.icon}</NavUnreadBadge>
|
||||||
|
) : (
|
||||||
|
item.icon
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"transition-opacity duration-150",
|
"transition-opacity duration-150",
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The announcement surface: a modal on desktop, a drawer on mobile.
|
||||||
|
*
|
||||||
|
* Shown at most once per user, and that guarantee lives on the server — the
|
||||||
|
* dismissal is a row, not local storage — so it survives a new device, a
|
||||||
|
* cleared browser, and two tabs racing.
|
||||||
|
*
|
||||||
|
* It appears on load and on the realtime nudge, but never over a signed-out
|
||||||
|
* page: an announcement is addressed to a person, and there is nobody to
|
||||||
|
* address until they're signed in.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
|
import { useNotifications } from "@/components/notifications/notifications-provider";
|
||||||
|
import { ResponsiveDialog } from "@/components/chat/responsive-dialog";
|
||||||
|
import { MessageText } from "@/components/chat/message-text";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { broadcasts, type Broadcast } from "@/lib/broadcasts";
|
||||||
|
|
||||||
|
export function BroadcastModal() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { lastBroadcastAt } = useNotifications();
|
||||||
|
const [item, setItem] = useState<Broadcast | null>(null);
|
||||||
|
const [dismissing, setDismissing] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
if (!user?.id) return;
|
||||||
|
broadcasts
|
||||||
|
.pending()
|
||||||
|
.then(setItem)
|
||||||
|
.catch(() => {
|
||||||
|
/* announcements are never worth an error surface */
|
||||||
|
});
|
||||||
|
}, [user?.id]);
|
||||||
|
|
||||||
|
// On sign-in, and again whenever the realtime nudge fires. lastBroadcastAt
|
||||||
|
// changes on each publish, so this refetches without the event carrying any
|
||||||
|
// content of its own.
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load, lastBroadcastAt]);
|
||||||
|
|
||||||
|
const dismiss = useCallback(async () => {
|
||||||
|
if (!item || dismissing) return;
|
||||||
|
setDismissing(true);
|
||||||
|
// Close first: the dismissal is idempotent and the server already knows
|
||||||
|
// what this user has seen, so a failed write costs at most one repeat
|
||||||
|
// rather than a modal that won't go away.
|
||||||
|
const id = item.id;
|
||||||
|
setItem(null);
|
||||||
|
setDismissing(false);
|
||||||
|
try {
|
||||||
|
await broadcasts.markSeen(id);
|
||||||
|
} catch {
|
||||||
|
/* it will simply be offered again */
|
||||||
|
}
|
||||||
|
}, [item, dismissing]);
|
||||||
|
|
||||||
|
// Guard on the shape, not just presence: a malformed payload must not put
|
||||||
|
// an empty modal over every page in the app.
|
||||||
|
if (!item?.id || !item.title) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveDialog
|
||||||
|
title={item.title}
|
||||||
|
description=""
|
||||||
|
onClose={dismiss}
|
||||||
|
footer={
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{item.cta_url && item.cta_label ? (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" className="flex-1" onClick={dismiss}>
|
||||||
|
Not now
|
||||||
|
</Button>
|
||||||
|
<a
|
||||||
|
href={item.cta_url}
|
||||||
|
// Dismiss on the way out, so following the link also counts as
|
||||||
|
// having seen it.
|
||||||
|
onClick={dismiss}
|
||||||
|
{...(item.cta_url.startsWith("/")
|
||||||
|
? {}
|
||||||
|
: { target: "_blank", rel: "noopener noreferrer" })}
|
||||||
|
className="inline-flex flex-1 items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 dark:text-white"
|
||||||
|
>
|
||||||
|
{item.cta_label}
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button className="w-full" onClick={dismiss}>
|
||||||
|
Got it
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-3 p-4">
|
||||||
|
{item.banner_url && (
|
||||||
|
<div className="relative aspect-[2/1] w-full overflow-hidden rounded-lg bg-muted">
|
||||||
|
<Image
|
||||||
|
src={item.banner_url}
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 768px) 100vw, 28rem"
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-sm leading-relaxed text-foreground">
|
||||||
|
<MessageText body={item.body} entities={item.entities ?? []} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -50,6 +50,15 @@ const EVENTS: {
|
|||||||
label: "Sādhana reminders",
|
label: "Sādhana reminders",
|
||||||
description: "Daily nudges to chant your rounds and share a verse.",
|
description: "Daily nudges to chant your rounds and share a verse.",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "messages",
|
||||||
|
label: "Messages",
|
||||||
|
description: "New direct messages and group chats.",
|
||||||
|
// Push only. In-app delivery for chat is the inbox itself — a message
|
||||||
|
// highlights its row rather than entering the notification tray — and
|
||||||
|
// there is no such thing as an emailed chat message.
|
||||||
|
channels: ["push"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "community",
|
key: "community",
|
||||||
label: "Community",
|
label: "Community",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { useAuth } from "@/components/auth/auth-provider";
|
|||||||
import { channelAuthorizer } from "@/lib/realtime";
|
import { channelAuthorizer } from "@/lib/realtime";
|
||||||
import { feed, notifications } from "@/lib/social";
|
import { feed, notifications } from "@/lib/social";
|
||||||
import { sadhna } from "@/lib/sadhna";
|
import { sadhna } from "@/lib/sadhna";
|
||||||
|
import { chat } from "@/lib/chat";
|
||||||
|
|
||||||
type NotificationsContextValue = {
|
type NotificationsContextValue = {
|
||||||
/** Live unread count for the header bell. */
|
/** Live unread count for the header bell. */
|
||||||
@@ -51,6 +52,14 @@ type NotificationsContextValue = {
|
|||||||
* event name and is surfaced here rather than bumping `unread`.
|
* event name and is surfaced here rather than bumping `unread`.
|
||||||
*/
|
*/
|
||||||
lastChatActivity: ChatActivity | null;
|
lastChatActivity: ChatActivity | null;
|
||||||
|
/** Bumps when an announcement is published. Carries no content — consumers
|
||||||
|
* refetch /broadcasts/pending, which answers per user. */
|
||||||
|
lastBroadcastAt: number;
|
||||||
|
/** Threads holding unread messages — the Chats nav badge. Muted threads are
|
||||||
|
* excluded by the BFF, so muting a busy group clears the badge. */
|
||||||
|
chatUnread: number;
|
||||||
|
/** Re-read the authoritative chat count (after opening a thread). */
|
||||||
|
refreshChatUnread: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Payload of the BFF's `chat_activity` event. */
|
/** Payload of the BFF's `chat_activity` event. */
|
||||||
@@ -108,6 +117,8 @@ export function NotificationsProvider({
|
|||||||
}) {
|
}) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [unread, setUnread] = useState(0);
|
const [unread, setUnread] = useState(0);
|
||||||
|
const [chatUnread, setChatUnread] = useState(0);
|
||||||
|
const [lastBroadcastAt, setLastBroadcastAt] = useState(0);
|
||||||
const [lastEventAt, setLastEventAt] = useState(0);
|
const [lastEventAt, setLastEventAt] = useState(0);
|
||||||
const [lastChatActivity, setLastChatActivity] = useState<ChatActivity | null>(
|
const [lastChatActivity, setLastChatActivity] = useState<ChatActivity | null>(
|
||||||
null,
|
null,
|
||||||
@@ -124,12 +135,22 @@ export function NotificationsProvider({
|
|||||||
|
|
||||||
const clearUnread = useCallback(() => setUnread(0), []);
|
const clearUnread = useCallback(() => setUnread(0), []);
|
||||||
|
|
||||||
|
const refreshChatUnread = useCallback(() => {
|
||||||
|
chat
|
||||||
|
.unreadCount()
|
||||||
|
.then(setChatUnread)
|
||||||
|
.catch(() => {
|
||||||
|
/* badge is non-critical */
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Seed the count whenever the signed-in user changes (async — no
|
// Seed the count whenever the signed-in user changes (async — no
|
||||||
// synchronous setState in the effect body).
|
// synchronous setState in the effect body).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user?.id) return;
|
if (!user?.id) return;
|
||||||
refreshUnread();
|
refreshUnread();
|
||||||
}, [user?.id, refreshUnread]);
|
refreshChatUnread();
|
||||||
|
}, [user?.id, refreshUnread, refreshChatUnread]);
|
||||||
|
|
||||||
// Report the browser's timezone (once per signed-in session) so sādhana
|
// Report the browser's timezone (once per signed-in session) so sādhana
|
||||||
// reminders fire at the user's local clock time. Best-effort.
|
// reminders fire at the user's local clock time. Best-effort.
|
||||||
@@ -158,7 +179,7 @@ export function NotificationsProvider({
|
|||||||
};
|
};
|
||||||
window.addEventListener("pagehide", onLeave);
|
window.addEventListener("pagehide", onLeave);
|
||||||
return () => window.removeEventListener("pagehide", onLeave);
|
return () => window.removeEventListener("pagehide", onLeave);
|
||||||
}, [userId]);
|
}, [userId, refreshChatUnread]);
|
||||||
|
|
||||||
// Open the realtime connection while signed in.
|
// Open the realtime connection while signed in.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -197,13 +218,24 @@ export function NotificationsProvider({
|
|||||||
channel.bind("pusher:subscription_error", (status: unknown) => {
|
channel.bind("pusher:subscription_error", (status: unknown) => {
|
||||||
console.warn("notifications: realtime subscription failed", status);
|
console.warn("notifications: realtime subscription failed", status);
|
||||||
});
|
});
|
||||||
// Chat activity: no toast, no unread bump, nothing in the tray. The
|
// Chat activity: no toast, nothing in the tray. The inbox subscribes to
|
||||||
// inbox subscribes to this to highlight the affected row.
|
// this to highlight the affected row, and the nav badge re-reads its
|
||||||
|
// count — refetched rather than incremented, because the badge counts
|
||||||
|
// THREADS: a second message in a thread already counted must not bump
|
||||||
|
// it, and the sender's own message must not either.
|
||||||
channel.bind(
|
channel.bind(
|
||||||
"chat_activity",
|
"chat_activity",
|
||||||
(data: Omit<ChatActivity, "at">) => {
|
(data: Omit<ChatActivity, "at">) => {
|
||||||
if (!data?.conversation_id) return;
|
if (!data?.conversation_id) return;
|
||||||
setLastChatActivity({ ...data, at: Date.now() });
|
setLastChatActivity({ ...data, at: Date.now() });
|
||||||
|
refreshChatUnread();
|
||||||
|
// Ack delivery here rather than in the thread, so "delivered" means
|
||||||
|
// "reached their device" rather than "they happened to have the chat
|
||||||
|
// open". This event only reaches non-senders, so nobody acks their
|
||||||
|
// own message.
|
||||||
|
if (data.seq) {
|
||||||
|
chat.markDelivered(data.conversation_id, data.seq).catch(() => {});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
channel.bind("notification", (data: IncomingEvent) => {
|
channel.bind("notification", (data: IncomingEvent) => {
|
||||||
@@ -221,7 +253,18 @@ export function NotificationsProvider({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
// Announcements ride a PUBLIC channel: one publish reaches everyone in
|
||||||
|
// a single Soketi call rather than one per user, and the event is safe
|
||||||
|
// to broadcast because it carries nothing — the content comes from the
|
||||||
|
// authenticated fetch it triggers.
|
||||||
|
const announcements = pusher.subscribe("broadcasts");
|
||||||
|
announcements.bind("broadcast_published", () =>
|
||||||
|
setLastBroadcastAt(Date.now()),
|
||||||
|
);
|
||||||
|
|
||||||
cleanup = () => {
|
cleanup = () => {
|
||||||
|
announcements.unbind_all();
|
||||||
|
pusher.unsubscribe("broadcasts");
|
||||||
channel.unbind_all();
|
channel.unbind_all();
|
||||||
pusher.unsubscribe(channelName);
|
pusher.unsubscribe(channelName);
|
||||||
pusher.disconnect();
|
pusher.disconnect();
|
||||||
@@ -232,7 +275,7 @@ export function NotificationsProvider({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
cleanup?.();
|
cleanup?.();
|
||||||
};
|
};
|
||||||
}, [userId]);
|
}, [userId, refreshChatUnread]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NotificationsContext.Provider
|
<NotificationsContext.Provider
|
||||||
@@ -242,6 +285,9 @@ export function NotificationsProvider({
|
|||||||
refreshUnread,
|
refreshUnread,
|
||||||
clearUnread,
|
clearUnread,
|
||||||
lastChatActivity,
|
lastChatActivity,
|
||||||
|
chatUnread,
|
||||||
|
refreshChatUnread,
|
||||||
|
lastBroadcastAt,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -64,7 +64,9 @@ export function RewardCelebration({
|
|||||||
You've earned a reward!
|
You've earned a reward!
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
For {grant.milestone} days of unbroken sādhana 🙏
|
{grant.milestone !== null
|
||||||
|
? `For ${grant.milestone} days of unbroken sādhana 🙏`
|
||||||
|
: "A gift for you 🙏"}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="relative mx-auto my-6 flex size-48 items-center justify-center">
|
<div className="relative mx-auto my-6 flex size-48 items-center justify-center">
|
||||||
|
|||||||
@@ -15,9 +15,11 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Gift } from "lucide-react";
|
import { Gift, Sparkles } from "lucide-react";
|
||||||
import type { SadhnaRewardGrant } from "@/lib/sadhna";
|
import { sadhna, type SadhnaRewardGrant } from "@/lib/sadhna";
|
||||||
import { useMounted } from "@/lib/use-mounted";
|
import { useMounted } from "@/lib/use-mounted";
|
||||||
|
import { RewardCelebration } from "@/components/sadhna/reward-celebration";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function RewardsGallery({
|
export function RewardsGallery({
|
||||||
collected,
|
collected,
|
||||||
@@ -27,6 +29,11 @@ export function RewardsGallery({
|
|||||||
nextRewardDate: string | null;
|
nextRewardDate: string | null;
|
||||||
}) {
|
}) {
|
||||||
const [detail, setDetail] = useState<SadhnaRewardGrant | null>(null);
|
const [detail, setDetail] = useState<SadhnaRewardGrant | null>(null);
|
||||||
|
// The gift currently being opened — shown enlarged, over confetti.
|
||||||
|
const [unwrapping, setUnwrapping] = useState<SadhnaRewardGrant | null>(null);
|
||||||
|
// Gifts opened in this session. Held locally so the tile clears the moment
|
||||||
|
// the celebration is dismissed, rather than waiting for a page refresh.
|
||||||
|
const [justOpened, setJustOpened] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// De-dupe by reward (collected is newest-first, so the first wins).
|
// De-dupe by reward (collected is newest-first, so the first wins).
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@@ -38,6 +45,19 @@ export function RewardsGallery({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A gift stays wrapped until opened. Everything else — every streak
|
||||||
|
// reward — is simply collected.
|
||||||
|
const isWrapped = (g: SadhnaRewardGrant) =>
|
||||||
|
g.source === "gift" && !g.opened && !justOpened.has(g.reward.key);
|
||||||
|
|
||||||
|
const openGift = (g: SadhnaRewardGrant) => {
|
||||||
|
setUnwrapping(null);
|
||||||
|
setJustOpened((prev) => new Set(prev).add(g.reward.key));
|
||||||
|
// The tile has already cleared; a failed write only means it arrives
|
||||||
|
// wrapped again next load, which is better than blocking the celebration.
|
||||||
|
void sadhna.openGift(g.reward.key).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
<section className="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||||
<div className="mb-3 flex items-center gap-2">
|
<div className="mb-3 flex items-center gap-2">
|
||||||
@@ -61,20 +81,42 @@ export function RewardsGallery({
|
|||||||
<button
|
<button
|
||||||
key={g.reward.key}
|
key={g.reward.key}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDetail(g)}
|
onClick={() =>
|
||||||
className="group flex flex-col items-center gap-1.5 rounded-xl border border-border bg-background p-2 text-center transition-colors hover:border-primary/40 hover:bg-muted/40"
|
isWrapped(g) ? setUnwrapping(g) : setDetail(g)
|
||||||
|
}
|
||||||
|
aria-label={
|
||||||
|
isWrapped(g)
|
||||||
|
? "A gift is waiting — tap to open"
|
||||||
|
: g.reward.name
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"group flex flex-col items-center gap-1.5 rounded-xl border bg-background p-2 text-center transition-colors",
|
||||||
|
isWrapped(g)
|
||||||
|
? "border-primary/40 hover:border-primary"
|
||||||
|
: "border-border hover:border-primary/40 hover:bg-muted/40",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="relative aspect-square w-full">
|
<div className="relative aspect-square w-full">
|
||||||
<Image
|
<Image
|
||||||
src={g.reward.image_url}
|
src={g.reward.image_url}
|
||||||
alt={g.reward.name}
|
alt={isWrapped(g) ? "" : g.reward.name}
|
||||||
fill
|
fill
|
||||||
sizes="(max-width: 640px) 30vw, 120px"
|
sizes="(max-width: 640px) 30vw, 120px"
|
||||||
className="object-contain p-1"
|
className={cn(
|
||||||
|
"object-contain p-1 transition-all",
|
||||||
|
// Blurred, not hidden: the shape hints at what's coming
|
||||||
|
// without giving it away.
|
||||||
|
isWrapped(g) && "scale-95 blur-md",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
{isWrapped(g) && (
|
||||||
|
<span className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<Sparkles className="size-6 text-primary drop-shadow" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="line-clamp-2 text-[13px] font-medium leading-tight text-foreground">
|
<span className="line-clamp-2 text-[13px] font-medium leading-tight text-foreground">
|
||||||
{shortName(g.reward.name)}
|
{isWrapped(g) ? "A gift for you" : shortName(g.reward.name)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -93,6 +135,15 @@ export function RewardsGallery({
|
|||||||
{detail ? (
|
{detail ? (
|
||||||
<RewardDetail grant={detail} onClose={() => setDetail(null)} />
|
<RewardDetail grant={detail} onClose={() => setDetail(null)} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* Reuses the streak celebration wholesale — a gift should feel like
|
||||||
|
any other sacred reward arriving, not like a second mechanism. */}
|
||||||
|
{unwrapping ? (
|
||||||
|
<RewardCelebration
|
||||||
|
grant={unwrapping}
|
||||||
|
onClose={() => openGift(unwrapping)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -145,7 +196,11 @@ function RewardDetail({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<p className="mt-4 text-xs text-muted-foreground">
|
<p className="mt-4 text-xs text-muted-foreground">
|
||||||
Earned on day {grant.milestone} of your streak
|
{/* A gift has no milestone — saying "day null of your streak"
|
||||||
|
would be worse than saying nothing about where it came from. */}
|
||||||
|
{grant.milestone !== null
|
||||||
|
? `Earned on day ${grant.milestone} of your streak`
|
||||||
|
: "An occasional gift"}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -382,7 +382,10 @@ export function PostCommentsDrawer({
|
|||||||
rows={1}
|
rows={1}
|
||||||
maxLength={2000}
|
maxLength={2000}
|
||||||
placeholder={replyTo ? "Write a reply…" : "Add a comment…"}
|
placeholder={replyTo ? "Write a reply…" : "Add a comment…"}
|
||||||
className="w-full resize-none rounded-lg border border-border bg-background px-3 py-2 text-base sm:text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-primary/40 max-h-32"
|
// 16px wherever the pointer is coarse, so iOS Safari doesn't
|
||||||
|
// auto-zoom on focus. Keyed off pointer type rather than width:
|
||||||
|
// an iPad is wider than `sm` but still zooms a 14px field.
|
||||||
|
className="w-full resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm pointer-coarse:text-base text-foreground outline-none focus-visible:ring-2 focus-visible:ring-primary/40 max-h-32"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ import {
|
|||||||
type Comment,
|
type Comment,
|
||||||
type Post,
|
type Post,
|
||||||
} from "@/lib/social";
|
} from "@/lib/social";
|
||||||
|
import { useLongPress } from "@/lib/use-long-press";
|
||||||
|
import { ShareToChatDialog } from "@/components/chat/share-to-chat-dialog";
|
||||||
import { useAuth } from "@/components/auth/auth-provider";
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
import { useAuthGate } from "@/components/auth/auth-gate";
|
import { useAuthGate } from "@/components/auth/auth-gate";
|
||||||
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||||
@@ -62,6 +64,9 @@ export function PostActionsFooter({ post }: { post: Post }) {
|
|||||||
// on mobile native-share; sheet mount on desktop) so the Send icon
|
// on mobile native-share; sheet mount on desktop) so the Send icon
|
||||||
// can render a spinner while the user waits.
|
// can render a spinner while the user waits.
|
||||||
const [sharePending, setSharePending] = useState(false);
|
const [sharePending, setSharePending] = useState(false);
|
||||||
|
// Opens the chat picker for this post. The post travels as a reference,
|
||||||
|
// not as text — the BFF renders the card and re-checks who may see it.
|
||||||
|
const [sharingToChat, setSharingToChat] = useState(false);
|
||||||
// Optimistic local mirror of post.viewer_has_reshared so the swap
|
// Optimistic local mirror of post.viewer_has_reshared so the swap
|
||||||
// happens the instant the user taps Reshare. Reconciles to the
|
// happens the instant the user taps Reshare. Reconciles to the
|
||||||
// server's value via rollback if the API call fails.
|
// server's value via rollback if the API call fails.
|
||||||
@@ -140,7 +145,16 @@ export function PostActionsFooter({ post }: { post: Post }) {
|
|||||||
// parallel, then navigator.share) and the desktop/no-native-share
|
// parallel, then navigator.share) and the desktop/no-native-share
|
||||||
// path (mount the custom SharePostSheet). Provider's openShareSheet
|
// path (mount the custom SharePostSheet). Provider's openShareSheet
|
||||||
// is always-resolves, so a single try/finally toggles the spinner.
|
// is always-resolves, so a single try/finally toggles the spinner.
|
||||||
async function onSendClick() {
|
// Tap → send to a chat. Hold → the platform's own share sheet, for
|
||||||
|
// everything outside the app. The link text is identical either way; only
|
||||||
|
// the destination differs.
|
||||||
|
function onSendClick() {
|
||||||
|
// No URL to resolve: sharing in-app sends the post id, so this is
|
||||||
|
// instant where the native sheet has to await the shortener.
|
||||||
|
setSharingToChat(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSendHold() {
|
||||||
if (sharePending) return;
|
if (sharePending) return;
|
||||||
setSharePending(true);
|
setSharePending(true);
|
||||||
try {
|
try {
|
||||||
@@ -150,6 +164,12 @@ export function PostActionsFooter({ post }: { post: Post }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sendGestures = useLongPress({
|
||||||
|
onClick: onSendClick,
|
||||||
|
onLongPress: onSendHold,
|
||||||
|
disabled: sharePending,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between px-2">
|
<div className="flex items-center justify-between px-2">
|
||||||
@@ -197,8 +217,8 @@ export function PostActionsFooter({ post }: { post: Post }) {
|
|||||||
{showSend ? (
|
{showSend ? (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
label={sharePending ? "Preparing share link…" : "Send"}
|
label={sharePending ? "Preparing share link…" : "Send"}
|
||||||
onClick={onSendClick}
|
|
||||||
disabled={sharePending}
|
disabled={sharePending}
|
||||||
|
{...sendGestures}
|
||||||
icon={
|
icon={
|
||||||
sharePending ? (
|
sharePending ? (
|
||||||
<Loader2
|
<Loader2
|
||||||
@@ -270,29 +290,37 @@ export function PostActionsFooter({ post }: { post: Post }) {
|
|||||||
{/* SharePostSheet is mounted globally by SharePostProvider, so
|
{/* SharePostSheet is mounted globally by SharePostProvider, so
|
||||||
no per-card instance lives here. The provider's
|
no per-card instance lives here. The provider's
|
||||||
openShareSheet swaps the active post under the hood. */}
|
openShareSheet swaps the active post under the hood. */}
|
||||||
|
|
||||||
|
{sharingToChat && (
|
||||||
|
<ShareToChatDialog
|
||||||
|
entity={{ type: "post", id: post.id }}
|
||||||
|
onClose={() => setSharingToChat(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActionButton({
|
function ActionButton({
|
||||||
label,
|
label,
|
||||||
onClick,
|
|
||||||
icon,
|
icon,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
...buttonProps
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
onClick: () => void;
|
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
/** Greys out the button and blocks clicks. Used by the Send button
|
/** Greys out the button and blocks clicks. Used by the Send button
|
||||||
* while the short URL is being resolved on the mobile native-share
|
* while the short URL is being resolved on the mobile native-share
|
||||||
* path so users can't fire navigator.share before the URL is
|
* path so users can't fire navigator.share before the URL is
|
||||||
* ready. */
|
* ready. */
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}) {
|
/** onClick, plus the pointer handlers the Send button needs to tell a
|
||||||
|
* tap from a hold. */
|
||||||
|
} & React.ComponentProps<"button">) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
{...buttonProps}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
title={label}
|
title={label}
|
||||||
|
|||||||
@@ -38,8 +38,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import type { Post } from "@/lib/social";
|
import type { Post } from "@/lib/social";
|
||||||
import { createShortLink } from "@/lib/share";
|
import { buildPostShareText } from "@/lib/post-share-text";
|
||||||
import { structuredReferenceForPost } from "@/lib/source-reference";
|
|
||||||
import { useAuth } from "@/components/auth/auth-provider";
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||||
import { SharePostSheet } from "./share-post-sheet";
|
import { SharePostSheet } from "./share-post-sheet";
|
||||||
@@ -131,30 +130,17 @@ export function SharePostProvider({
|
|||||||
// it.
|
// it.
|
||||||
// 3. On any non-AbortError, fall back to the custom sheet.
|
// 3. On any non-AbortError, fall back to the custom sheet.
|
||||||
try {
|
try {
|
||||||
const longPath = `/u/${encodeURIComponent(handle)}/posts#post-${post.id}`;
|
|
||||||
const hasImage = !!post.images?.[0];
|
const hasImage = !!post.images?.[0];
|
||||||
const [code, imageFile] = await Promise.all([
|
const [share, imageFile] = await Promise.all([
|
||||||
createShortLink(longPath),
|
buildPostShareText(post, handle),
|
||||||
hasImage ? fetchShareImageFile(post.id) : Promise.resolve(null),
|
hasImage ? fetchShareImageFile(post.id) : Promise.resolve(null),
|
||||||
]);
|
]);
|
||||||
const origin = window.location.origin;
|
if (!share) {
|
||||||
const url = code
|
setActivePost(post);
|
||||||
? new URL(`/share/${code}`, origin).toString()
|
return;
|
||||||
: new URL(longPath, origin).toString();
|
}
|
||||||
|
|
||||||
const ref = structuredReferenceForPost(
|
const payload: ShareData = { title: share.title, text: share.text };
|
||||||
post.source_url,
|
|
||||||
post.source_title,
|
|
||||||
);
|
|
||||||
const lines = [`“${post.caption}”`, "", "Source:", ref.title];
|
|
||||||
if (ref.detail) lines.push(ref.detail);
|
|
||||||
lines.push(url);
|
|
||||||
const text = lines.join("\n");
|
|
||||||
const title = ref.detail
|
|
||||||
? `${ref.title} — ${ref.detail}`
|
|
||||||
: ref.title;
|
|
||||||
|
|
||||||
const payload: ShareData = { title, text };
|
|
||||||
// Only attach files when the platform actually accepts them.
|
// Only attach files when the platform actually accepts them.
|
||||||
// canShare returns false on Chrome desktop / Firefox even
|
// canShare returns false on Chrome desktop / Firefox even
|
||||||
// though navigator.share itself exists, and we want to fail
|
// though navigator.share itself exists, and we want to fail
|
||||||
|
|||||||
@@ -6,16 +6,16 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
Link as LinkIcon,
|
Link as LinkIcon,
|
||||||
|
MessageCircle,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Send,
|
Send,
|
||||||
Share2,
|
Share2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { formatCopyText, getSourceReference } from "@/lib/source-reference";
|
import { formatCopyText, getSourceReference } from "@/lib/source-reference";
|
||||||
import { buildHighlightUrl } from "@/lib/text-highlight";
|
import { buildHighlightPath, buildHighlightUrl } from "@/lib/text-highlight";
|
||||||
import { bookmarks, SocialError } from "@/lib/social";
|
import { bookmarks, SocialError } from "@/lib/social";
|
||||||
import {
|
import {
|
||||||
KautilyaIcon,
|
|
||||||
useMediaQuery,
|
useMediaQuery,
|
||||||
useCopyFeedback,
|
useCopyFeedback,
|
||||||
type Position,
|
type Position,
|
||||||
@@ -25,6 +25,8 @@ import { FloatingToolbar } from "./selection-toolbar/floating-toolbar";
|
|||||||
import { CollectionsDrawer } from "./selection-toolbar/collections-drawer";
|
import { CollectionsDrawer } from "./selection-toolbar/collections-drawer";
|
||||||
import { MorePopover, type MoreItem } from "./selection-toolbar/more-popover";
|
import { MorePopover, type MoreItem } from "./selection-toolbar/more-popover";
|
||||||
import { ComposePost } from "./selection-toolbar/compose-post";
|
import { ComposePost } from "./selection-toolbar/compose-post";
|
||||||
|
import { ShareToChatDialog } from "@/components/chat/share-to-chat-dialog";
|
||||||
|
import type { QuotePayload } from "@/lib/chat";
|
||||||
import { useAuth } from "./auth/auth-provider";
|
import { useAuth } from "./auth/auth-provider";
|
||||||
import { useAuthGate } from "./auth/auth-gate";
|
import { useAuthGate } from "./auth/auth-gate";
|
||||||
|
|
||||||
@@ -34,6 +36,8 @@ import { useAuthGate } from "./auth/auth-gate";
|
|||||||
|
|
||||||
export function TextSelectionToolbar() {
|
export function TextSelectionToolbar() {
|
||||||
const [selectedText, setSelectedText] = useState("");
|
const [selectedText, setSelectedText] = useState("");
|
||||||
|
// The passage to share, captured at click time. Non-null opens the dialog.
|
||||||
|
const [chatQuote, setChatQuote] = useState<QuotePayload | null>(null);
|
||||||
const [toolbarPos, setToolbarPos] = useState<Position | null>(null);
|
const [toolbarPos, setToolbarPos] = useState<Position | null>(null);
|
||||||
const [showMore, setShowMore] = useState(false);
|
const [showMore, setShowMore] = useState(false);
|
||||||
const [morePos, setMorePos] = useState<Position>({ x: 0, y: 0 });
|
const [morePos, setMorePos] = useState<Position>({ x: 0, y: 0 });
|
||||||
@@ -337,6 +341,21 @@ export function TextSelectionToolbar() {
|
|||||||
setShowCollections(true);
|
setShowCollections(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onChat() {
|
||||||
|
// Snapshot the passage first: requireAuth may open a login flow, and
|
||||||
|
// finishAction clears the selection — the dialog outlives both.
|
||||||
|
const ref = getSourceReference();
|
||||||
|
const quote: QuotePayload = {
|
||||||
|
// Relative, not absolute: the BFF only accepts in-app paths for quotes.
|
||||||
|
path: buildHighlightPath(selectedText),
|
||||||
|
title: ref ? (ref.detail ? `${ref.title} — ${ref.detail}` : ref.title) : "",
|
||||||
|
text: selectedText,
|
||||||
|
};
|
||||||
|
if (!(await requireAuth())) return;
|
||||||
|
setChatQuote(quote);
|
||||||
|
finishAction();
|
||||||
|
}
|
||||||
|
|
||||||
async function onPost() {
|
async function onPost() {
|
||||||
if (!(await requireAuth())) return;
|
if (!(await requireAuth())) return;
|
||||||
if (isMobile) saveAndClearSelection();
|
if (isMobile) saveAndClearSelection();
|
||||||
@@ -418,14 +437,11 @@ export function TextSelectionToolbar() {
|
|||||||
|
|
||||||
const isAuthed = !!user;
|
const isAuthed = !!user;
|
||||||
|
|
||||||
const askAction: ToolbarAction = {
|
const chatAction: ToolbarAction = {
|
||||||
key: "ask",
|
key: "chat",
|
||||||
label: "Ask Kautilya",
|
label: "Chat",
|
||||||
icon: <KautilyaIcon className="size-3.5" />,
|
icon: <MessageCircle className="size-3.5" />,
|
||||||
onClick: () => {
|
onClick: onChat,
|
||||||
// TODO: Implement Ask Kautilya
|
|
||||||
finishAction();
|
|
||||||
},
|
|
||||||
className: "text-amber-400 hover:bg-amber-400/15 hover:text-amber-300",
|
className: "text-amber-400 hover:bg-amber-400/15 hover:text-amber-300",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -445,7 +461,7 @@ export function TextSelectionToolbar() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const authedToolbarActions: ToolbarAction[] = [
|
const authedToolbarActions: ToolbarAction[] = [
|
||||||
askAction,
|
chatAction,
|
||||||
{
|
{
|
||||||
key: "post",
|
key: "post",
|
||||||
label: "Post",
|
label: "Post",
|
||||||
@@ -466,7 +482,6 @@ export function TextSelectionToolbar() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const publicToolbarActions: ToolbarAction[] = [
|
const publicToolbarActions: ToolbarAction[] = [
|
||||||
askAction,
|
|
||||||
{
|
{
|
||||||
key: "copy",
|
key: "copy",
|
||||||
label: "Copy",
|
label: "Copy",
|
||||||
@@ -515,6 +530,17 @@ export function TextSelectionToolbar() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const publicMoreItems: MoreItem[] = [
|
const publicMoreItems: MoreItem[] = [
|
||||||
|
{
|
||||||
|
key: "chat-public",
|
||||||
|
label: "Chat",
|
||||||
|
description: "Send this passage to someone",
|
||||||
|
icon: <MessageCircle className="size-5" />,
|
||||||
|
locked: true,
|
||||||
|
onClick: async () => {
|
||||||
|
setShowMore(false);
|
||||||
|
await onChat();
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "share-as-post",
|
key: "share-as-post",
|
||||||
label: "Share as Post",
|
label: "Share as Post",
|
||||||
@@ -601,6 +627,13 @@ export function TextSelectionToolbar() {
|
|||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
selectedText={selectedText}
|
selectedText={selectedText}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{chatQuote !== null && (
|
||||||
|
<ShareToChatDialog
|
||||||
|
quote={chatQuote}
|
||||||
|
onClose={() => setChatQuote(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { cn } from "@/lib/utils";
|
|||||||
|
|
||||||
// A curated, broadly-useful set — enough to feel like a picker without
|
// A curated, broadly-useful set — enough to feel like a picker without
|
||||||
// shipping a full emoji database. Grouped for easy scanning.
|
// shipping a full emoji database. Grouped for easy scanning.
|
||||||
const EMOJI_GROUPS: { label: string; emojis: string[] }[] = [
|
export const EMOJI_GROUPS: { label: string; emojis: string[] }[] = [
|
||||||
{
|
{
|
||||||
label: "Smileys",
|
label: "Smileys",
|
||||||
emojis: [
|
emojis: [
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Browser-side client for admin announcements.
|
||||||
|
*
|
||||||
|
* Deliberately thin: an announcement is fetched, shown once, and dismissed.
|
||||||
|
* Nothing is cached client-side — "have I seen this?" is the server's answer,
|
||||||
|
* so it survives a new device, a cleared browser, and a second tab.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { MessageEntity } from "@/lib/chat";
|
||||||
|
|
||||||
|
const BFF_BASE = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080";
|
||||||
|
|
||||||
|
async function call<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(`${BFF_BASE}/api/v1${path}`, {
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`broadcast request failed: ${res.status}`);
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
const body = (await res.json().catch(() => ({}))) as {
|
||||||
|
data?: T;
|
||||||
|
success?: boolean;
|
||||||
|
};
|
||||||
|
// NOT `body.data ?? body`: /pending answers {data: null} when nothing is
|
||||||
|
// waiting, and `??` would fall through and hand back the whole envelope —
|
||||||
|
// which is truthy, so the modal would try to render it as an announcement.
|
||||||
|
return (body.data ?? (null as unknown as T)) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Broadcast = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
/** Formatting spans over `body`, same shape chat messages use. */
|
||||||
|
entities: MessageEntity[];
|
||||||
|
banner_url?: string | null;
|
||||||
|
cta_label?: string | null;
|
||||||
|
cta_url?: string | null;
|
||||||
|
published_at?: string | null;
|
||||||
|
expires_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const broadcasts = {
|
||||||
|
/** The announcement waiting for this user, or null. */
|
||||||
|
pending: () => call<Broadcast | null>("/broadcasts/pending"),
|
||||||
|
|
||||||
|
/** Dismiss it. Idempotent, so a double-tap or two tabs are both fine. */
|
||||||
|
markSeen: (id: string) =>
|
||||||
|
call<void>(`/broadcasts/${id}/seen`, { method: "POST" }),
|
||||||
|
};
|
||||||
+6
-1
@@ -29,10 +29,15 @@ export function buildEntityTree(
|
|||||||
body: string,
|
body: string,
|
||||||
entities: MessageEntity[],
|
entities: MessageEntity[],
|
||||||
): EntityNode[] {
|
): EntityNode[] {
|
||||||
|
// Defensive on both inputs: this renders content from the network, and a
|
||||||
|
// missing body should show nothing rather than take down the screen it is
|
||||||
|
// on. The broadcast modal renders through here too, and it mounts on every
|
||||||
|
// page — so a crash here is not confined to chat.
|
||||||
|
const text = body ?? "";
|
||||||
const sorted = [...(entities ?? [])].sort(
|
const sorted = [...(entities ?? [])].sort(
|
||||||
(a, b) => a.offset - b.offset || b.length - a.length,
|
(a, b) => a.offset - b.offset || b.length - a.length,
|
||||||
);
|
);
|
||||||
return build(body, 0, body.length, sorted);
|
return build(text, 0, text.length, sorted);
|
||||||
}
|
}
|
||||||
|
|
||||||
function build(
|
function build(
|
||||||
|
|||||||
+134
-1
@@ -119,6 +119,8 @@ export type ChatMessage = {
|
|||||||
reply_to?: ReplyPreview | null;
|
reply_to?: ReplyPreview | null;
|
||||||
/** Always an array — media, link cards and shared entities. */
|
/** Always an array — media, link cards and shared entities. */
|
||||||
attachments: Attachment[];
|
attachments: Attachment[];
|
||||||
|
/** Emoji chips, grouped with counts. `mine` marks the viewer's own. */
|
||||||
|
reactions: Reaction[];
|
||||||
/**
|
/**
|
||||||
* Client-only. Set on a message shown optimistically before the server has
|
* Client-only. Set on a message shown optimistically before the server has
|
||||||
* acknowledged it; never sent by the BFF.
|
* acknowledged it; never sent by the BFF.
|
||||||
@@ -168,7 +170,33 @@ export type AttachmentKind =
|
|||||||
| "audio"
|
| "audio"
|
||||||
| "file"
|
| "file"
|
||||||
| "link"
|
| "link"
|
||||||
| "entity";
|
| "entity"
|
||||||
|
| "quote"
|
||||||
|
| "sticker";
|
||||||
|
|
||||||
|
/** One emoji on a message, grouped across everyone who chose it. */
|
||||||
|
export type Reaction = {
|
||||||
|
emoji: string;
|
||||||
|
count: number;
|
||||||
|
/** The viewer's own choice — highlight the chip, and tapping it removes. */
|
||||||
|
mine: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A piece of reward artwork the caller may send. */
|
||||||
|
export type Sticker = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
image_url: string;
|
||||||
|
/** Came from a chat rather than from sādhana. */
|
||||||
|
saved: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One person's reaction, for the "who reacted" list. */
|
||||||
|
export type ReactionDetail = {
|
||||||
|
user: ChatPeer;
|
||||||
|
emoji: string;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type Attachment = {
|
export type Attachment = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -187,6 +215,25 @@ export type Attachment = {
|
|||||||
description?: string | null;
|
description?: string | null;
|
||||||
site_name?: string | null;
|
site_name?: string | null;
|
||||||
image_url?: string | null;
|
image_url?: string | null;
|
||||||
|
|
||||||
|
/** Shared entity. `entity` is resolved per viewer on every read, so it can
|
||||||
|
* be absent even though the message still references a post. */
|
||||||
|
entity_type?: "post" | null;
|
||||||
|
entity_id?: string | null;
|
||||||
|
entity?: SharedPost | null;
|
||||||
|
/** The post was deleted, hidden, or is no longer visible to this viewer. */
|
||||||
|
entity_unavailable?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The post card the BFF resolves for a shared-post attachment. */
|
||||||
|
export type SharedPost = {
|
||||||
|
id: string;
|
||||||
|
author_handle: string;
|
||||||
|
author_name: string;
|
||||||
|
author_avatar_url?: string | null;
|
||||||
|
caption: string;
|
||||||
|
image_url?: string | null;
|
||||||
|
source_title?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** How two people in a DM stand with each other. */
|
/** How two people in a DM stand with each other. */
|
||||||
@@ -231,6 +278,10 @@ export const chat = {
|
|||||||
* Opens (or returns) the DM with a profile. Idempotent, and keyed by handle
|
* Opens (or returns) the DM with a profile. Idempotent, and keyed by handle
|
||||||
* because that is what a profile page has.
|
* because that is what a profile page has.
|
||||||
*/
|
*/
|
||||||
|
/** Threads waiting on the caller. Muted ones are excluded — see the BFF. */
|
||||||
|
unreadCount: () =>
|
||||||
|
call<{ count: number }>("/chat/unread").then((r) => r.count),
|
||||||
|
|
||||||
openDM: (handle: string) =>
|
openDM: (handle: string) =>
|
||||||
call<{ id: string; kind: ConversationKind }>("/chat/conversations/dm", {
|
call<{ id: string; kind: ConversationKind }>("/chat/conversations/dm", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -259,6 +310,11 @@ export const chat = {
|
|||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
previewUrl?: string;
|
previewUrl?: string;
|
||||||
attachments?: UploadedAttachment[];
|
attachments?: UploadedAttachment[];
|
||||||
|
quote?: QuotePayload;
|
||||||
|
/** Share a post as a card. Only the reference — the BFF resolves it. */
|
||||||
|
entity?: { type: "post"; id: string };
|
||||||
|
/** Send reward artwork. Ownership is checked server-side. */
|
||||||
|
stickerKey?: string;
|
||||||
},
|
},
|
||||||
) =>
|
) =>
|
||||||
call<ChatMessage>(`/chat/conversations/${conversationId}/messages`, {
|
call<ChatMessage>(`/chat/conversations/${conversationId}/messages`, {
|
||||||
@@ -270,6 +326,9 @@ export const chat = {
|
|||||||
// Only the URL: the BFF fetches the card's contents itself.
|
// Only the URL: the BFF fetches the card's contents itself.
|
||||||
preview_url: opts?.previewUrl ?? null,
|
preview_url: opts?.previewUrl ?? null,
|
||||||
attachments: opts?.attachments ?? [],
|
attachments: opts?.attachments ?? [],
|
||||||
|
quote: opts?.quote ?? null,
|
||||||
|
entity: opts?.entity ?? null,
|
||||||
|
sticker_key: opts?.stickerKey ?? null,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -382,6 +441,52 @@ export const chat = {
|
|||||||
body: JSON.stringify({ seq }),
|
body: JSON.stringify({ seq }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/** One hydrated message — reaction counts are per viewer, so a client told
|
||||||
|
* "message X changed" has to ask rather than compute. */
|
||||||
|
message: (conversationId: string, messageId: string) =>
|
||||||
|
call<ChatMessage>(
|
||||||
|
`/chat/conversations/${conversationId}/messages/${messageId}`,
|
||||||
|
),
|
||||||
|
|
||||||
|
/** Tell the thread someone is composing. Ephemeral — nothing is stored. */
|
||||||
|
typing: (conversationId: string) =>
|
||||||
|
call<void>(`/chat/conversations/${conversationId}/typing`, {
|
||||||
|
method: "POST",
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Everything the caller can send: earned through sādhana, or saved from
|
||||||
|
* a chat. */
|
||||||
|
stickers: () => call<Sticker[]>("/chat/stickers"),
|
||||||
|
|
||||||
|
/** Keep a sticker someone sent. Keyed to the message it arrived on, which
|
||||||
|
* is what proves it was actually sent to you. */
|
||||||
|
saveSticker: (conversationId: string, messageId: string, key: string) =>
|
||||||
|
call<void>(
|
||||||
|
`/chat/conversations/${conversationId}/messages/${messageId}/sticker`,
|
||||||
|
{ method: "POST", body: JSON.stringify({ key }) },
|
||||||
|
),
|
||||||
|
|
||||||
|
/** Who reacted, and with what. Only fetched when the list is opened —
|
||||||
|
* the thread renders from the grouped counts on each message. */
|
||||||
|
reactionDetails: (conversationId: string, messageId: string) =>
|
||||||
|
call<ReactionDetail[]>(
|
||||||
|
`/chat/conversations/${conversationId}/messages/${messageId}/reactions`,
|
||||||
|
),
|
||||||
|
|
||||||
|
/** Set the caller's reaction, replacing any previous one. */
|
||||||
|
react: (conversationId: string, messageId: string, emoji: string) =>
|
||||||
|
call<void>(
|
||||||
|
`/chat/conversations/${conversationId}/messages/${messageId}/reaction`,
|
||||||
|
{ method: "PUT", body: JSON.stringify({ emoji }) },
|
||||||
|
),
|
||||||
|
|
||||||
|
/** Remove the caller's reaction. Idempotent. */
|
||||||
|
unreact: (conversationId: string, messageId: string) =>
|
||||||
|
call<void>(
|
||||||
|
`/chat/conversations/${conversationId}/messages/${messageId}/reaction`,
|
||||||
|
{ method: "DELETE" },
|
||||||
|
),
|
||||||
|
|
||||||
markDelivered: (conversationId: string, seq: number) =>
|
markDelivered: (conversationId: string, seq: number) =>
|
||||||
call<void>(`/chat/conversations/${conversationId}/delivered`, {
|
call<void>(`/chat/conversations/${conversationId}/delivered`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -399,6 +504,19 @@ export const chat = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** A file already uploaded to the private bucket, ready to attach on send. */
|
/** A file already uploaded to the private bucket, ready to attach on send. */
|
||||||
|
/**
|
||||||
|
* A passage shared from the library.
|
||||||
|
*
|
||||||
|
* `path` is app-relative on purpose — the BFF refuses anything absolute or
|
||||||
|
* protocol-relative, because a quote renders as trusted in-app content and
|
||||||
|
* must not be able to carry the reader off-site.
|
||||||
|
*/
|
||||||
|
export type QuotePayload = {
|
||||||
|
path: string;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type UploadedAttachment = {
|
export type UploadedAttachment = {
|
||||||
key: string;
|
key: string;
|
||||||
mime: string;
|
mime: string;
|
||||||
@@ -551,6 +669,21 @@ export function followSummary(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Resolves a sender id to a display name using a thread's participants. */
|
/** Resolves a sender id to a display name using a thread's participants. */
|
||||||
|
/**
|
||||||
|
* The participant who sent a message, or undefined for Kautilya, a system
|
||||||
|
* message, or someone who has since been removed.
|
||||||
|
*
|
||||||
|
* Groups need this rather than the conversation's `peer`: `peer` is only set
|
||||||
|
* on DMs, so a group has no single counterpart to read an avatar from.
|
||||||
|
*/
|
||||||
|
export function senderPeer(
|
||||||
|
senderId: string | null | undefined,
|
||||||
|
participants: ChatPeer[],
|
||||||
|
): ChatPeer | undefined {
|
||||||
|
if (!senderId) return undefined;
|
||||||
|
return participants.find((p) => p.id === senderId);
|
||||||
|
}
|
||||||
|
|
||||||
export function senderName(
|
export function senderName(
|
||||||
senderId: string | null | undefined,
|
senderId: string | null | undefined,
|
||||||
participants: ChatPeer[],
|
participants: ChatPeer[],
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Builds the shareable text for a post — the caption, its source reference,
|
||||||
|
* and a link.
|
||||||
|
*
|
||||||
|
* Extracted so the native share sheet and "send to a chat" quote a post
|
||||||
|
* identically. They differ only in where the text ends up, and two copies of
|
||||||
|
* this would drift the moment either format changed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createShortLink } from "@/lib/share";
|
||||||
|
import { structuredReferenceForPost } from "@/lib/source-reference";
|
||||||
|
import type { Post } from "@/lib/social";
|
||||||
|
|
||||||
|
export type PostShareText = {
|
||||||
|
/** Short link when the shortener answered, canonical URL otherwise. */
|
||||||
|
url: string;
|
||||||
|
/** Source reference on one line, for a native share sheet's title slot. */
|
||||||
|
title: string;
|
||||||
|
/** Caption, source reference and link — the body of the share. */
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `viewerHandle` covers the case where the BFF's create response omits
|
||||||
|
* author_handle: the poster IS the signed-in user right after posting.
|
||||||
|
* Returns null when neither yields a handle, since there is no post URL
|
||||||
|
* without one.
|
||||||
|
*/
|
||||||
|
export async function buildPostShareText(
|
||||||
|
post: Post,
|
||||||
|
viewerHandle: string | undefined,
|
||||||
|
): Promise<PostShareText | null> {
|
||||||
|
const handle = post.author_handle ?? viewerHandle ?? "";
|
||||||
|
if (!handle) return null;
|
||||||
|
|
||||||
|
const longPath = `/u/${encodeURIComponent(handle)}/posts#post-${post.id}`;
|
||||||
|
const code = await createShortLink(longPath);
|
||||||
|
const origin = window.location.origin;
|
||||||
|
const url = code
|
||||||
|
? new URL(`/share/${code}`, origin).toString()
|
||||||
|
: new URL(longPath, origin).toString();
|
||||||
|
|
||||||
|
const ref = structuredReferenceForPost(post.source_url, post.source_title);
|
||||||
|
const lines = [`“${post.caption}”`, "", "Source:", ref.title];
|
||||||
|
if (ref.detail) lines.push(ref.detail);
|
||||||
|
lines.push(url);
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
title: ref.detail ? `${ref.title} — ${ref.detail}` : ref.title,
|
||||||
|
text: lines.join("\n"),
|
||||||
|
};
|
||||||
|
}
|
||||||
+11
-1
@@ -111,9 +111,13 @@ export type SadhnaReward = {
|
|||||||
|
|
||||||
/** A reward earned at a streak milestone (a multiple of 7 days). */
|
/** A reward earned at a streak milestone (a multiple of 7 days). */
|
||||||
export type SadhnaRewardGrant = {
|
export type SadhnaRewardGrant = {
|
||||||
milestone: number;
|
/** The streak day this was earned on. Null for an occasional gift. */
|
||||||
|
milestone: number | null;
|
||||||
granted_at: string;
|
granted_at: string;
|
||||||
reward: SadhnaReward;
|
reward: SadhnaReward;
|
||||||
|
source: "streak" | "gift";
|
||||||
|
/** False while a gift is still wrapped — the gallery blurs those. */
|
||||||
|
opened: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── API ───────────────────────────────────────────────────────────────
|
// ─── API ───────────────────────────────────────────────────────────────
|
||||||
@@ -140,6 +144,12 @@ export const sadhna = {
|
|||||||
rewards: () =>
|
rewards: () =>
|
||||||
call<{ collected: SadhnaRewardGrant[]; total: number }>("/sadhna/rewards"),
|
call<{ collected: SadhnaRewardGrant[]; total: number }>("/sadhna/rewards"),
|
||||||
|
|
||||||
|
/** Open one gift, after its celebration is dismissed. Idempotent. */
|
||||||
|
openGift: (rewardKey: string) =>
|
||||||
|
call<void>(`/sadhna/rewards/gifts/${encodeURIComponent(rewardKey)}/open`, {
|
||||||
|
method: "POST",
|
||||||
|
}),
|
||||||
|
|
||||||
/** Acknowledge pending celebrations after showing the confetti. */
|
/** Acknowledge pending celebrations after showing the confetti. */
|
||||||
ackRewards: () => call<void>("/sadhna/rewards/ack", { method: "POST" }),
|
ackRewards: () => call<void>("/sadhna/rewards/ack", { method: "POST" }),
|
||||||
|
|
||||||
|
|||||||
@@ -712,6 +712,9 @@ export const notifications = {
|
|||||||
// ─── Notification preferences ──────────────────────────────────────────
|
// ─── Notification preferences ──────────────────────────────────────────
|
||||||
|
|
||||||
export type PrefEvent =
|
export type PrefEvent =
|
||||||
|
/** Chat push. Delivered by the chat service directly, not the notification
|
||||||
|
* tray — a new message highlights its inbox row instead. */
|
||||||
|
| "messages"
|
||||||
| "follows"
|
| "follows"
|
||||||
| "post_interactions"
|
| "post_interactions"
|
||||||
| "sadhna_reminders"
|
| "sadhna_reminders"
|
||||||
|
|||||||
@@ -303,6 +303,18 @@ export function buildSearchResultUrl(
|
|||||||
/**
|
/**
|
||||||
* Build the full URL with highlight fragment for the selected text.
|
* Build the full URL with highlight fragment for the selected text.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* The highlight link as an app-relative path ("/books/bg/2/13?hl=…").
|
||||||
|
*
|
||||||
|
* Chat quotes store a path rather than a full URL: the BFF refuses absolute
|
||||||
|
* links there, since a quote renders as trusted in-app content and must not be
|
||||||
|
* able to carry the reader off-site.
|
||||||
|
*/
|
||||||
|
export function buildHighlightPath(selectedText: string): string {
|
||||||
|
const url = new URL(buildHighlightUrl(selectedText));
|
||||||
|
return url.pathname + url.search + url.hash;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildHighlightUrl(selectedText: string): string {
|
export function buildHighlightUrl(selectedText: string): string {
|
||||||
const fragment = encodeTextFragment(selectedText);
|
const fragment = encodeTextFragment(selectedText);
|
||||||
if (!fragment) return window.location.href;
|
if (!fragment) return window.location.href;
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the app's own chrome should step aside for the page.
|
||||||
|
*
|
||||||
|
* A chat thread is a full-height surface with its own header and a composer
|
||||||
|
* pinned to the bottom: the tab bar competes with the composer for the same
|
||||||
|
* edge, the hamburger duplicates a back button already on screen, and the
|
||||||
|
* wordmark header stacks a second title bar above the thread's own.
|
||||||
|
*
|
||||||
|
* One hook so the header, the nav, the hamburger and the layout's bottom
|
||||||
|
* padding can never disagree — if they did, hiding one would leave a gap
|
||||||
|
* where it used to be. The desktop sidebar is deliberately unaffected: it is
|
||||||
|
* the only way out of a thread on a wide screen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True on a thread itself (`/chats/<id>`) but NOT on the inbox or a chat's
|
||||||
|
* profile page: those are ordinary scrolling pages where the tab bar belongs.
|
||||||
|
*/
|
||||||
|
export function useHidesAppChrome(): boolean {
|
||||||
|
const pathname = usePathname();
|
||||||
|
if (!pathname?.startsWith("/chats/")) return false;
|
||||||
|
// "", "chats", "<id>" — anything deeper is a sub-page like /profile.
|
||||||
|
return pathname.split("/").filter(Boolean).length === 2;
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distinguishes a tap from a press-and-hold on the same control.
|
||||||
|
*
|
||||||
|
* Built on Pointer Events so one implementation covers touch, mouse and pen.
|
||||||
|
* Two details make it behave:
|
||||||
|
*
|
||||||
|
* - A hold that fires must swallow the click that follows it, or the tap
|
||||||
|
* action runs straight after the hold action.
|
||||||
|
* - Small finger movement during a hold is normal, so the press only
|
||||||
|
* cancels once it drifts past a slop threshold — otherwise holds fail
|
||||||
|
* constantly on touch.
|
||||||
|
*
|
||||||
|
* Timing and slop match the chat bubbles' gesture handler so a hold feels the
|
||||||
|
* same everywhere in the app.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
const LONG_PRESS_MS = 450;
|
||||||
|
const LONG_PRESS_SLOP_PX = 8;
|
||||||
|
|
||||||
|
export function useLongPress({
|
||||||
|
onClick,
|
||||||
|
onLongPress,
|
||||||
|
disabled = false,
|
||||||
|
}: {
|
||||||
|
onClick: () => void;
|
||||||
|
onLongPress: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const start = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
const fired = useRef(false);
|
||||||
|
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
if (timer.current) clearTimeout(timer.current);
|
||||||
|
timer.current = null;
|
||||||
|
start.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// A hold can end with the pointer outside the button, or with the component
|
||||||
|
// unmounting behind whatever the hold opened; either would leak the timer.
|
||||||
|
useEffect(() => clear, [clear]);
|
||||||
|
|
||||||
|
const onPointerDown = (e: React.PointerEvent) => {
|
||||||
|
if (disabled) return;
|
||||||
|
fired.current = false;
|
||||||
|
start.current = { x: e.clientX, y: e.clientY };
|
||||||
|
timer.current = setTimeout(() => {
|
||||||
|
fired.current = true;
|
||||||
|
clear();
|
||||||
|
onLongPress();
|
||||||
|
}, LONG_PRESS_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerMove = (e: React.PointerEvent) => {
|
||||||
|
if (!start.current) return;
|
||||||
|
const dx = e.clientX - start.current.x;
|
||||||
|
const dy = e.clientY - start.current.y;
|
||||||
|
if (Math.abs(dx) > LONG_PRESS_SLOP_PX || Math.abs(dy) > LONG_PRESS_SLOP_PX) {
|
||||||
|
clear();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
onPointerDown,
|
||||||
|
onPointerMove,
|
||||||
|
onPointerUp: clear,
|
||||||
|
onPointerCancel: clear,
|
||||||
|
onPointerLeave: clear,
|
||||||
|
// Suppress iOS's press-and-hold callout, which would otherwise cover the
|
||||||
|
// sheet the hold just opened.
|
||||||
|
onContextMenu: (e: React.MouseEvent) => {
|
||||||
|
if (fired.current) e.preventDefault();
|
||||||
|
},
|
||||||
|
onClick: () => {
|
||||||
|
// The hold already acted; the browser's trailing click is not a tap.
|
||||||
|
if (fired.current) {
|
||||||
|
fired.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!disabled) onClick();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks the *visual* viewport — the part of the page not covered by the
|
||||||
|
* software keyboard.
|
||||||
|
*
|
||||||
|
* Why this exists: on iOS Safari `100dvh` does not shrink when the keyboard
|
||||||
|
* opens. The dynamic viewport units respond to browser chrome collapsing, not
|
||||||
|
* to the keyboard, so a composer pinned to the bottom of a `100dvh` column
|
||||||
|
* ends up underneath it. `window.visualViewport` is the only thing iOS updates
|
||||||
|
* reliably, so the thread is sized from that instead while the keyboard is up.
|
||||||
|
*
|
||||||
|
* `offsetTop` matters as much as `height`: iOS scrolls the layout viewport to
|
||||||
|
* reveal the focused field rather than resizing it, so a panel pinned to the
|
||||||
|
* visible area has to follow that offset or it drifts off-screen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export type VisualViewport = {
|
||||||
|
/** Visible height in px, excluding the keyboard. */
|
||||||
|
height: number;
|
||||||
|
/** How far the layout viewport has scrolled to reveal the focused field. */
|
||||||
|
offsetTop: number;
|
||||||
|
/** True once the visible area is materially shorter than the window. */
|
||||||
|
keyboardOpen: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A URL bar collapsing costs ~50–60px; a keyboard costs several hundred. The
|
||||||
|
* threshold keeps ordinary chrome changes from being read as a keyboard.
|
||||||
|
*/
|
||||||
|
const KEYBOARD_THRESHOLD_PX = 120;
|
||||||
|
|
||||||
|
export function useVisualViewport(): VisualViewport | null {
|
||||||
|
const [state, setState] = useState<VisualViewport | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const vv = window.visualViewport;
|
||||||
|
// Absent on older browsers — callers fall back to their CSS height.
|
||||||
|
if (!vv) return;
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
setState({
|
||||||
|
height: vv.height,
|
||||||
|
offsetTop: vv.offsetTop,
|
||||||
|
keyboardOpen: window.innerHeight - vv.height > KEYBOARD_THRESHOLD_PX,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
apply();
|
||||||
|
vv.addEventListener("resize", apply);
|
||||||
|
// iOS fires scroll (not resize) as the layout viewport shifts under the
|
||||||
|
// keyboard, so both are needed to stay pinned.
|
||||||
|
vv.addEventListener("scroll", apply);
|
||||||
|
return () => {
|
||||||
|
vv.removeEventListener("resize", apply);
|
||||||
|
vv.removeEventListener("scroll", apply);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user