feat: chat stickers
This commit is contained in:
@@ -13,12 +13,13 @@
|
||||
*/
|
||||
|
||||
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 {
|
||||
ComposerAttachments,
|
||||
type StagedAttachment,
|
||||
} from "@/components/chat/composer-attachments";
|
||||
import { StickerPicker } from "@/components/chat/sticker-picker";
|
||||
import { compressImage } from "@/lib/compress-image";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
@@ -53,6 +54,8 @@ export function ChatComposer({
|
||||
replyToId?: string,
|
||||
previewUrl?: string,
|
||||
attachments?: UploadedAttachment[],
|
||||
/** Reward artwork sent on its own. */
|
||||
stickerKey?: string,
|
||||
) => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
@@ -67,6 +70,7 @@ export function ChatComposer({
|
||||
const [staged, setStaged] = useState<StagedAttachment[]>([]);
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [pickingSticker, setPickingSticker] = useState(false);
|
||||
const lookupSeq = useRef(0);
|
||||
|
||||
// Grow with the content, up to a cap.
|
||||
@@ -253,6 +257,15 @@ export function ChatComposer({
|
||||
>
|
||||
<Paperclip className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickingSticker(true)}
|
||||
disabled={disabled}
|
||||
aria-label="Send a sticker"
|
||||
className="inline-flex size-9 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:text-foreground disabled:opacity-40"
|
||||
>
|
||||
<Smile className="size-4" />
|
||||
</button>
|
||||
|
||||
<textarea
|
||||
ref={ref}
|
||||
@@ -303,6 +316,16 @@ export function ChatComposer({
|
||||
<SendHorizonal className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{pickingSticker && (
|
||||
<StickerPicker
|
||||
// Sent on its own, with no text: a sticker IS the message.
|
||||
onPick={(sticker) =>
|
||||
onSend("", undefined, undefined, undefined, sticker.key)
|
||||
}
|
||||
onClose={() => setPickingSticker(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,8 +14,15 @@ import { MessageAttachments } from "@/components/chat/message-attachments";
|
||||
import { MessageTicks } from "@/components/chat/message-ticks";
|
||||
import { tickState } from "@/lib/chat-format";
|
||||
import { HEART_EMOJI } from "@/components/chat/reaction-row";
|
||||
import { StickerGrid } from "@/components/chat/sticker-grid";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { 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. */
|
||||
function clockTime(iso: string): string {
|
||||
@@ -33,6 +40,20 @@ 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;
|
||||
|
||||
@@ -208,7 +229,7 @@ export function MessageGroup({
|
||||
// own border, rounding and background, so the bubble drops
|
||||
// its chrome rather than drawing a second frame around it.
|
||||
// Any caption gets the padding back on its own line below.
|
||||
sharesAnEntity(m)
|
||||
sharesAnEntity(m) || isStickerOnly(m)
|
||||
? "overflow-hidden bg-transparent"
|
||||
: cn(
|
||||
"px-3 py-2",
|
||||
@@ -227,8 +248,17 @@ export function MessageGroup({
|
||||
{/* A shared post leads and its caption follows, the way
|
||||
the post itself reads. Everything else keeps text
|
||||
first, with attachments beneath it. */}
|
||||
{sharesAnEntity(m) && (
|
||||
<MessageAttachments attachments={m.attachments} />
|
||||
{isStickerOnly(m) ? (
|
||||
<StickerGrid
|
||||
conversationId={m.conversation_id}
|
||||
messageId={m.id}
|
||||
stickers={stickersOn(m)}
|
||||
mine={mine}
|
||||
/>
|
||||
) : (
|
||||
sharesAnEntity(m) && (
|
||||
<MessageAttachments attachments={m.attachments} />
|
||||
)
|
||||
)}
|
||||
{m.body && (
|
||||
<span
|
||||
@@ -244,7 +274,7 @@ export function MessageGroup({
|
||||
<MessageText body={m.body} entities={m.entities} />
|
||||
</span>
|
||||
)}
|
||||
{!sharesAnEntity(m) && (
|
||||
{!sharesAnEntity(m) && !isStickerOnly(m) && (
|
||||
<MessageAttachments attachments={m.attachments} />
|
||||
)}
|
||||
{/* An optimistic bubble for a captionless attachment has
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user