feat(characters): use local character catalog
This commit is contained in:
@@ -9,18 +9,22 @@ export function getChatImageOverlayMessageId(input: {
|
||||
return value && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
export function buildChatImageOverlayUrl(messageId: string): `/chat?${string}` {
|
||||
export function buildChatImageOverlayUrl(
|
||||
messageId: string,
|
||||
basePath: string = ROUTES.chat,
|
||||
): string {
|
||||
const params = new URLSearchParams({
|
||||
[CHAT_IMAGE_QUERY_PARAM]: messageId,
|
||||
});
|
||||
return `${ROUTES.chat}?${params.toString()}` as const;
|
||||
return `${basePath}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function buildChatWithoutImageOverlayUrl(input: {
|
||||
toString: () => string;
|
||||
}): string {
|
||||
export function buildChatWithoutImageOverlayUrl(
|
||||
input: { toString: () => string },
|
||||
basePath: string = ROUTES.chat,
|
||||
): string {
|
||||
const params = new URLSearchParams(input.toString());
|
||||
params.delete(CHAT_IMAGE_QUERY_PARAM);
|
||||
const query = params.toString();
|
||||
return query ? `${ROUTES.chat}?${query}` : ROUTES.chat;
|
||||
return query ? `${basePath}?${query}` : basePath;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
useChatDispatch,
|
||||
useChatState,
|
||||
} from "@/stores/chat/chat-context";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
import { useActiveCharacterRoutes } from "@/providers/character-provider";
|
||||
|
||||
import { MobileShell } from "@/app/_components/core";
|
||||
|
||||
@@ -46,6 +46,7 @@ const chatShellStyle = {
|
||||
|
||||
export function ChatScreen() {
|
||||
const router = useRouter();
|
||||
const characterRoutes = useActiveCharacterRoutes();
|
||||
const searchParams = useSearchParams();
|
||||
const state = useChatState();
|
||||
const chatDispatch = useChatDispatch();
|
||||
@@ -63,10 +64,10 @@ export function ChatScreen() {
|
||||
: state.historyMessages;
|
||||
const imageMessageId = getChatImageOverlayMessageId(searchParams);
|
||||
const imageReturnUrl = imageMessageId
|
||||
? buildChatImageOverlayUrl(imageMessageId)
|
||||
: ROUTES.chat;
|
||||
? buildChatImageOverlayUrl(imageMessageId, characterRoutes.chat)
|
||||
: characterRoutes.chat;
|
||||
const unlockCoordinator = useChatUnlockCoordinator({
|
||||
defaultReturnUrl: ROUTES.chat,
|
||||
defaultReturnUrl: characterRoutes.chat,
|
||||
imageMessageId,
|
||||
imageReturnUrl,
|
||||
promotion: state.promotion,
|
||||
@@ -113,10 +114,12 @@ export function ChatScreen() {
|
||||
if (!imageMessageId || !state.historyLoaded || selectedImageMessage) {
|
||||
return;
|
||||
}
|
||||
router.replace(buildChatWithoutImageOverlayUrl(searchParams), {
|
||||
scroll: false,
|
||||
});
|
||||
router.replace(
|
||||
buildChatWithoutImageOverlayUrl(searchParams, characterRoutes.chat),
|
||||
{ scroll: false },
|
||||
);
|
||||
}, [
|
||||
characterRoutes.chat,
|
||||
imageMessageId,
|
||||
router,
|
||||
searchParams,
|
||||
@@ -137,13 +140,16 @@ export function ChatScreen() {
|
||||
}
|
||||
|
||||
function handleOpenImage(messageId: string): void {
|
||||
router.push(buildChatImageOverlayUrl(messageId), { scroll: false });
|
||||
router.push(buildChatImageOverlayUrl(messageId, characterRoutes.chat), {
|
||||
scroll: false,
|
||||
});
|
||||
}
|
||||
|
||||
function handleCloseImageViewer(): void {
|
||||
router.replace(buildChatWithoutImageOverlayUrl(searchParams), {
|
||||
scroll: false,
|
||||
});
|
||||
router.replace(
|
||||
buildChatWithoutImageOverlayUrl(searchParams, characterRoutes.chat),
|
||||
{ scroll: false },
|
||||
);
|
||||
}
|
||||
|
||||
function handleUnlockImagePaywall(): void {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getCharacterBySlug } from "@/data/constants/character";
|
||||
import { CharacterProvider } from "@/providers/character-provider";
|
||||
|
||||
import { BrowserHintOverlay } from "../browser-hint-overlay";
|
||||
import { ChatHeader } from "../chat-header";
|
||||
import { ChatInsufficientCreditsBanner } from "../chat-insufficient-credits-banner";
|
||||
@@ -35,6 +38,19 @@ describe("chat Tailwind components", () => {
|
||||
expect(userHtml).toContain("%2Fuser-avatar.png");
|
||||
});
|
||||
|
||||
it("renders the active character avatar", () => {
|
||||
const maya = getCharacterBySlug("maya");
|
||||
expect(maya).not.toBeNull();
|
||||
const html = renderToStaticMarkup(
|
||||
<CharacterProvider character={maya!}>
|
||||
<MessageAvatar isFromAI={true} />
|
||||
</CharacterProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain("%2Fimages%2Favatar%2Fmaya.png");
|
||||
expect(html).toContain('alt="Maya Tan"');
|
||||
});
|
||||
|
||||
it("renders TextBubble AI and user visual variants", () => {
|
||||
const aiHtml = renderToStaticMarkup(
|
||||
<TextBubble content={"hello\nthere"} isFromAI={true} />,
|
||||
@@ -172,7 +188,7 @@ describe("chat Tailwind components", () => {
|
||||
expect(guestHtml).not.toContain('aria-label="Back to home"');
|
||||
expect(guestHtml).not.toContain('aria-label="Menu"');
|
||||
expect(memberHtml).toContain("Offer");
|
||||
expect(memberHtml).toContain('href="/splash"');
|
||||
expect(memberHtml).toContain('href="/characters/elio/splash"');
|
||||
expect(memberHtml).toContain('aria-label="Back to home"');
|
||||
expect(memberHtml).toContain('aria-label="Menu"');
|
||||
expect(memberHtml).toContain('data-analytics-key="chat.open_menu"');
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { ReactNode } from "react";
|
||||
import { Lock, Menu } from "lucide-react";
|
||||
|
||||
import { BackButton } from "@/app/_components";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
import { useActiveCharacterRoutes } from "@/providers/character-provider";
|
||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||
|
||||
import { BrowserHintOverlay } from "./browser-hint-overlay";
|
||||
@@ -26,6 +26,7 @@ export function ChatHeader({
|
||||
showBrowserHint = false,
|
||||
}: ChatHeaderProps) {
|
||||
const navigator = useAppNavigator();
|
||||
const characterRoutes = useActiveCharacterRoutes();
|
||||
|
||||
return (
|
||||
<header className="flex shrink-0 flex-col items-stretch bg-transparent p-0">
|
||||
@@ -35,7 +36,7 @@ export function ChatHeader({
|
||||
data-analytics-key="chat.open_signup"
|
||||
data-analytics-label="Open sign up"
|
||||
className="flex w-full cursor-pointer items-center justify-center gap-(--spacing-sm,8px) border-0 bg-accent px-(--spacing-md,12px) py-(--spacing-sm,8px) text-center text-(length:--responsive-caption,var(--font-size-sm,12px)) font-medium text-white"
|
||||
onClick={() => navigator.openAuth(ROUTES.chat)}
|
||||
onClick={() => navigator.openAuth(characterRoutes.chat)}
|
||||
aria-label="Sign up to unlock more features"
|
||||
>
|
||||
<Lock
|
||||
@@ -52,7 +53,7 @@ export function ChatHeader({
|
||||
{!isGuest ? (
|
||||
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-(--spacing-sm,8px) px-(--spacing-md,12px) py-(--spacing-sm,8px)">
|
||||
<BackButton
|
||||
href={ROUTES.splash}
|
||||
href={characterRoutes.splash}
|
||||
variant="dark"
|
||||
ariaLabel="Back to home"
|
||||
analyticsKey="chat.back_to_home"
|
||||
@@ -67,7 +68,7 @@ export function ChatHeader({
|
||||
data-analytics-key="chat.open_menu"
|
||||
data-analytics-label="Open chat menu"
|
||||
className="flex size-(--responsive-icon-button-size,42px) cursor-pointer items-center justify-center rounded-full border border-[rgba(255,255,255,0.12)] bg-[rgba(13,11,20,0.7)] text-(--color-text-primary,#fff) shadow-[0_10px_24px_rgba(0,0,0,0.2)] backdrop-blur-md transition-[background,transform] duration-150 hover:bg-[rgba(28,24,39,0.82)] active:scale-96 focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#f657a0]"
|
||||
onClick={() => navigator.push(ROUTES.sidebar)}
|
||||
onClick={() => navigator.push(characterRoutes.sidebar)}
|
||||
aria-label="Menu"
|
||||
>
|
||||
<Menu size={24} aria-hidden="true" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ImageIcon, LockKeyhole } from "lucide-react";
|
||||
import { useActiveCharacter } from "@/providers/character-provider";
|
||||
|
||||
export interface LockedImageMessageCardProps {
|
||||
hint?: string | null;
|
||||
@@ -13,6 +14,8 @@ export function LockedImageMessageCard({
|
||||
isUnlocking = false,
|
||||
onUnlock,
|
||||
}: LockedImageMessageCardProps) {
|
||||
const character = useActiveCharacter();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-[min(72vw,280px)] overflow-hidden rounded-(--responsive-card-radius-sm,18px) rounded-tl-none border border-[rgba(246,87,160,0.2)] bg-white shadow-[0_4px_14px_rgba(246,87,160,0.14)]"
|
||||
@@ -29,7 +32,7 @@ export function LockedImageMessageCard({
|
||||
<p className="m-0 text-(length:--responsive-body,14px) leading-[1.4] text-[#3c3b3b]">
|
||||
{hint && hint.length > 0
|
||||
? hint
|
||||
: "Elio sent you a locked image."}
|
||||
: `${character.shortName} sent you a locked image.`}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import Image from "next/image";
|
||||
|
||||
import { UserMessageAvatar } from "@/app/_components";
|
||||
import { useActiveCharacter } from "@/providers/character-provider";
|
||||
|
||||
export interface MessageAvatarProps {
|
||||
isFromAI: boolean;
|
||||
@@ -12,12 +13,14 @@ const AVATAR_CLASS_NAME =
|
||||
"flex size-(--chat-avatar-size,43px) shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-(--color-avatar-border,#fbf3f5) bg-(--color-avatar-border,#fbf3f5) shadow-(--shadow-input-box,0_1px_2px_rgba(0,0,0,0.1))";
|
||||
|
||||
export function MessageAvatar({ isFromAI, userAvatarUrl }: MessageAvatarProps) {
|
||||
const character = useActiveCharacter();
|
||||
|
||||
if (isFromAI) {
|
||||
return (
|
||||
<div className={AVATAR_CLASS_NAME} aria-label="AI avatar">
|
||||
<Image
|
||||
src="/images/avatar/elio.png"
|
||||
alt="Elio"
|
||||
src={character.assets.avatar}
|
||||
alt={character.displayName}
|
||||
width={43}
|
||||
height={43}
|
||||
priority
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { LockKeyhole } from "lucide-react";
|
||||
import { useActiveCharacter } from "@/providers/character-provider";
|
||||
|
||||
export interface PrivateMessageCardProps {
|
||||
hint?: string | null;
|
||||
@@ -13,6 +14,8 @@ export function PrivateMessageCard({
|
||||
isUnlocking = false,
|
||||
onUnlock,
|
||||
}: PrivateMessageCardProps) {
|
||||
const character = useActiveCharacter();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="max-w-[min(72vw,280px)] rounded-(--responsive-card-radius-sm,18px) rounded-tl-none border border-[rgba(246,87,160,0.2)] bg-[linear-gradient(180deg,rgba(255,244,248,0.95),rgba(255,255,255,0.95)),#ffffff] p-(--responsive-card-padding,14px) text-[#3c3b3b] shadow-[0_4px_12px_rgba(246,87,160,0.12)]"
|
||||
@@ -28,7 +31,7 @@ export function PrivateMessageCard({
|
||||
<p className="m-0 text-(length:--responsive-body,14px) leading-[1.4] text-[#3c3b3b]">
|
||||
{hint && hint.length > 0
|
||||
? hint
|
||||
: "Elio has a private message for you."}
|
||||
: `${character.shortName} has a private message for you.`}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { LockKeyhole, Pause, Play } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useCachedChatMediaUrl } from "@/lib/chat/use_cached_chat_media_url";
|
||||
import { useActiveCharacter } from "@/providers/character-provider";
|
||||
import styles from "./voice-bubble.module.css";
|
||||
|
||||
export interface VoiceBubbleProps {
|
||||
@@ -27,6 +28,7 @@ export function VoiceBubble({
|
||||
isUnlocking = false,
|
||||
onUnlock,
|
||||
}: VoiceBubbleProps) {
|
||||
const character = useActiveCharacter();
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [errorMediaUrl, setErrorMediaUrl] = useState<string | null>(null);
|
||||
@@ -123,7 +125,7 @@ export function VoiceBubble({
|
||||
<p className={styles.hint}>
|
||||
{hint && hint.length > 0
|
||||
? hint
|
||||
: "Elio has a locked voice message for you."}
|
||||
: `${character.shortName} has a locked voice message for you.`}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { ChatRouteProviders } from "@/providers/chat-route-providers";
|
||||
|
||||
export default function ChatLayout({ children }: { children: ReactNode }) {
|
||||
return <ChatRouteProviders>{children}</ChatRouteProviders>;
|
||||
export default function LegacyChatLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
}
|
||||
|
||||
+17
-16
@@ -1,20 +1,21 @@
|
||||
import { Suspense } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { PageLoadingFallback } from "@/app/_components/core";
|
||||
import { ChatScreen } from "@/app/chat/chat-screen";
|
||||
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
|
||||
import {
|
||||
appendRouteSearchParams,
|
||||
getCharacterRoutes,
|
||||
type RouteSearchParams,
|
||||
} from "@/router/routes";
|
||||
|
||||
export default function ChatPage() {
|
||||
return (
|
||||
<Suspense fallback={<ChatFallback />}>
|
||||
<ChatScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatFallback() {
|
||||
return (
|
||||
<PageLoadingFallback background="#111111" tone="dark">
|
||||
Loading chat...
|
||||
</PageLoadingFallback>
|
||||
export default async function ChatPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<RouteSearchParams>;
|
||||
}) {
|
||||
redirect(
|
||||
appendRouteSearchParams(
|
||||
getCharacterRoutes(DEFAULT_CHARACTER_SLUG).chat,
|
||||
await searchParams,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user