feat: chat stickers
This commit is contained in:
@@ -168,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 = {
|
||||||
@@ -188,7 +189,13 @@ export default function ChatThreadPage() {
|
|||||||
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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -53,6 +54,8 @@ export function ChatComposer({
|
|||||||
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;
|
||||||
@@ -67,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.
|
||||||
@@ -253,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}
|
||||||
@@ -303,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,15 @@ 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 { 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 {
|
||||||
@@ -33,6 +40,20 @@ function sharesAnEntity(m: ChatMessage): boolean {
|
|||||||
return (m.attachments ?? []).some((a) => a.kind === "entity");
|
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". */
|
/** Distinct emoji shown before the rest collapse into "+N". */
|
||||||
const MAX_TRAIL_EMOJI = 4;
|
const MAX_TRAIL_EMOJI = 4;
|
||||||
|
|
||||||
@@ -208,7 +229,7 @@ export function MessageGroup({
|
|||||||
// own border, rounding and background, so the bubble drops
|
// own border, rounding and background, so the bubble drops
|
||||||
// its chrome rather than drawing a second frame around it.
|
// its chrome rather than drawing a second frame around it.
|
||||||
// Any caption gets the padding back on its own line below.
|
// Any caption gets the padding back on its own line below.
|
||||||
sharesAnEntity(m)
|
sharesAnEntity(m) || isStickerOnly(m)
|
||||||
? "overflow-hidden bg-transparent"
|
? "overflow-hidden bg-transparent"
|
||||||
: cn(
|
: cn(
|
||||||
"px-3 py-2",
|
"px-3 py-2",
|
||||||
@@ -227,8 +248,17 @@ export function MessageGroup({
|
|||||||
{/* A shared post leads and its caption follows, the way
|
{/* A shared post leads and its caption follows, the way
|
||||||
the post itself reads. Everything else keeps text
|
the post itself reads. Everything else keeps text
|
||||||
first, with attachments beneath it. */}
|
first, with attachments beneath it. */}
|
||||||
{sharesAnEntity(m) && (
|
{isStickerOnly(m) ? (
|
||||||
|
<StickerGrid
|
||||||
|
conversationId={m.conversation_id}
|
||||||
|
messageId={m.id}
|
||||||
|
stickers={stickersOn(m)}
|
||||||
|
mine={mine}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
sharesAnEntity(m) && (
|
||||||
<MessageAttachments attachments={m.attachments} />
|
<MessageAttachments attachments={m.attachments} />
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
{m.body && (
|
{m.body && (
|
||||||
<span
|
<span
|
||||||
@@ -244,7 +274,7 @@ export function MessageGroup({
|
|||||||
<MessageText body={m.body} entities={m.entities} />
|
<MessageText body={m.body} entities={m.entities} />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!sharesAnEntity(m) && (
|
{!sharesAnEntity(m) && !isStickerOnly(m) && (
|
||||||
<MessageAttachments attachments={m.attachments} />
|
<MessageAttachments attachments={m.attachments} />
|
||||||
)}
|
)}
|
||||||
{/* An optimistic bubble for a captionless attachment has
|
{/* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
+26
-1
@@ -171,7 +171,8 @@ export type AttachmentKind =
|
|||||||
| "file"
|
| "file"
|
||||||
| "link"
|
| "link"
|
||||||
| "entity"
|
| "entity"
|
||||||
| "quote";
|
| "quote"
|
||||||
|
| "sticker";
|
||||||
|
|
||||||
/** One emoji on a message, grouped across everyone who chose it. */
|
/** One emoji on a message, grouped across everyone who chose it. */
|
||||||
export type Reaction = {
|
export type Reaction = {
|
||||||
@@ -181,6 +182,15 @@ export type Reaction = {
|
|||||||
mine: boolean;
|
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. */
|
/** One person's reaction, for the "who reacted" list. */
|
||||||
export type ReactionDetail = {
|
export type ReactionDetail = {
|
||||||
user: ChatPeer;
|
user: ChatPeer;
|
||||||
@@ -303,6 +313,8 @@ export const chat = {
|
|||||||
quote?: QuotePayload;
|
quote?: QuotePayload;
|
||||||
/** Share a post as a card. Only the reference — the BFF resolves it. */
|
/** Share a post as a card. Only the reference — the BFF resolves it. */
|
||||||
entity?: { type: "post"; id: string };
|
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`, {
|
||||||
@@ -316,6 +328,7 @@ export const chat = {
|
|||||||
attachments: opts?.attachments ?? [],
|
attachments: opts?.attachments ?? [],
|
||||||
quote: opts?.quote ?? null,
|
quote: opts?.quote ?? null,
|
||||||
entity: opts?.entity ?? null,
|
entity: opts?.entity ?? null,
|
||||||
|
sticker_key: opts?.stickerKey ?? null,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -441,6 +454,18 @@ export const chat = {
|
|||||||
method: "POST",
|
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 —
|
/** Who reacted, and with what. Only fetched when the list is opened —
|
||||||
* the thread renders from the grouped counts on each message. */
|
* the thread renders from the grouped counts on each message. */
|
||||||
reactionDetails: (conversationId: string, messageId: string) =>
|
reactionDetails: (conversationId: string, messageId: string) =>
|
||||||
|
|||||||
+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" }),
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user