feat(private-room): connect moments feed
Docker Image / Build and Push Docker Image (push) Successful in 9m29s

This commit is contained in:
2026-07-08 16:29:08 +08:00
parent 77b496da2b
commit c46b9b4cdd
41 changed files with 3107 additions and 281 deletions
@@ -0,0 +1,260 @@
import { describe, expect, it } from "vitest";
import { createActor, fromPromise, waitFor } from "xstate";
import {
PrivateRoomMomentsResponse,
PrivateRoomUnlockResponse,
} from "@/data/dto/private-room";
import { privateRoomMachine } from "@/stores/private-room/private-room-machine";
const baseMoment = {
momentId: "schedule:91",
source: "elio_schedules",
sourceId: "91",
characterId: "elio",
author: {
id: "elio",
name: "Elio Silvestri",
avatarUrl: null,
},
createdAt: "2026-07-01T08:11:35+00:00",
publishedAt: "2026-07-01T08:11:35+00:00",
timeText: "7 days ago",
title: "Paris morning",
content: null,
text: null,
textPreview: "Unlock to view private room photos",
mediaCount: 1,
images: [
{
url: null,
type: "image",
locked: true,
index: 0,
},
],
locked: true,
unlocked: false,
unlockCost: 40,
unlockCostPerImage: 40,
requiredCredits: 40,
currency: "credits",
lockDetail: {
locked: true,
showContent: false,
showUpgrade: true,
reason: "private_room_moment",
hint: "40 credits · Unlock photo",
actionLabel: "Unlock",
type: "private_room_moment",
requiredCredits: 40,
currentCredits: 100,
shortfallCredits: 0,
mediaCount: 1,
unlockCostPerImage: 40,
},
};
function makeMomentsResponse(
overrides: Partial<Parameters<typeof PrivateRoomMomentsResponse.from>[0]> = {},
): PrivateRoomMomentsResponse {
return PrivateRoomMomentsResponse.from({
profile: {
characterId: "elio",
displayName: "Elio Silvestri",
authorName: "Elio Silvestri",
avatarUrl: null,
coverUrl: null,
title: "Elio Private room",
subtitle: "Join me, unlock my private room",
},
items: [baseMoment],
nextCursor: null,
hasMore: false,
creditBalance: 100,
unlockCostDefault: 40,
unlockCostPerImage: 40,
currency: "credits",
source: "elio_schedules",
...overrides,
});
}
function makeUnlockResponse(
overrides: Partial<Parameters<typeof PrivateRoomUnlockResponse.from>[0]> = {},
): PrivateRoomUnlockResponse {
return PrivateRoomUnlockResponse.from({
...baseMoment,
locked: false,
unlocked: true,
images: [
{
url: "https://example.supabase.co/storage/v1/object/public/elio-schedules/photo.jpg",
type: "image",
locked: false,
index: 0,
},
],
reason: "ok",
creditsCharged: 40,
creditBalance: 60,
...overrides,
});
}
function createTestMachine(
options: {
firstPage?: PrivateRoomMomentsResponse;
morePage?: PrivateRoomMomentsResponse;
unlockResponse?: PrivateRoomUnlockResponse;
} = {},
) {
return privateRoomMachine.provide({
actors: {
loadMoments: fromPromise(async () => options.firstPage ?? makeMomentsResponse()),
loadMoreMoments: fromPromise(async () =>
options.morePage ??
makeMomentsResponse({
items: [
{
...baseMoment,
momentId: "schedule:92",
sourceId: "92",
},
],
hasMore: false,
nextCursor: null,
}),
),
unlockMoment: fromPromise(async () =>
options.unlockResponse ?? makeUnlockResponse(),
),
},
});
}
describe("privateRoomMachine", () => {
it("loads the first page", async () => {
const actor = createActor(createTestMachine()).start();
actor.send({ type: "PrivateRoomInit" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
const context = actor.getSnapshot().context;
expect(context.profile?.displayName).toBe("Elio Silvestri");
expect(context.items).toHaveLength(1);
expect(context.creditBalance).toBe(100);
actor.stop();
});
it("appends more moments without overwriting existing items", async () => {
const actor = createActor(
createTestMachine({
firstPage: makeMomentsResponse({
hasMore: true,
nextCursor: "cursor-1",
}),
}),
).start();
actor.send({ type: "PrivateRoomInit" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
actor.send({ type: "PrivateRoomLoadMore" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
expect(actor.getSnapshot().context.items.map((item) => item.momentId)).toEqual([
"schedule:91",
"schedule:92",
]);
actor.stop();
});
it("replaces a locked moment after unlock success", async () => {
const actor = createActor(createTestMachine()).start();
actor.send({ type: "PrivateRoomInit" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
actor.send({
type: "PrivateRoomUnlockRequested",
momentId: "schedule:91",
});
actor.send({ type: "PrivateRoomUnlockConfirmed" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
const context = actor.getSnapshot().context;
expect(context.items[0]?.unlocked).toBe(true);
expect(context.items[0]?.images[0]?.url).toContain("https://");
expect(context.unlockSuccessNonce).toBe(1);
actor.stop();
});
it("exposes a paywall request for insufficient credits", async () => {
const actor = createActor(
createTestMachine({
unlockResponse: makeUnlockResponse({
...baseMoment,
reason: "insufficient_credits",
locked: true,
unlocked: false,
creditBalance: 10,
currentCredits: 10,
shortfallCredits: 30,
}),
}),
).start();
actor.send({ type: "PrivateRoomInit" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
actor.send({
type: "PrivateRoomUnlockRequested",
momentId: "schedule:91",
});
actor.send({ type: "PrivateRoomUnlockConfirmed" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
const context = actor.getSnapshot().context;
expect(context.items[0]?.unlocked).toBe(false);
expect(context.unlockPaywallRequest).toMatchObject({
momentId: "schedule:91",
reason: "insufficient_credits",
shortfallCredits: 30,
});
actor.stop();
});
it("refreshes and keeps an unlock error when the cost changes", async () => {
const actor = createActor(
createTestMachine({
unlockResponse: makeUnlockResponse({
...baseMoment,
reason: "cost_changed",
locked: true,
unlocked: false,
}),
}),
).start();
actor.send({ type: "PrivateRoomInit" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
actor.send({
type: "PrivateRoomUnlockRequested",
momentId: "schedule:91",
});
actor.send({ type: "PrivateRoomUnlockConfirmed" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
expect(actor.getSnapshot().context.unlockErrorMessage).toBe(
"Unlock price changed. Please confirm again.",
);
actor.stop();
});
});
+4
View File
@@ -0,0 +1,4 @@
export * from "./private-room-context";
export * from "./private-room-events";
export * from "./private-room-machine";
export * from "./private-room-state";
@@ -0,0 +1,96 @@
"use client";
import {
type Dispatch,
type ReactNode,
createContext,
useContext,
useMemo,
} from "react";
import { useMachine } from "@xstate/react";
import { privateRoomMachine } from "./private-room-machine";
import type {
PrivateRoomEvent,
PrivateRoomState as MachineContext,
} from "./private-room-machine";
export interface PrivateRoomContextState {
status: string;
profile: MachineContext["profile"];
items: MachineContext["items"];
nextCursor: string | null;
hasMore: boolean;
creditBalance: number;
errorMessage: string | null;
unlockingMomentId: string | null;
unlockErrorMessage: string | null;
pendingConfirmMomentId: string | null;
unlockPaywallRequest: MachineContext["unlockPaywallRequest"];
unlockSuccessNonce: number;
isLoading: boolean;
isLoadingMore: boolean;
isUnlocking: boolean;
}
const PrivateRoomStateCtx = createContext<PrivateRoomContextState | null>(null);
const PrivateRoomDispatchCtx = createContext<Dispatch<PrivateRoomEvent> | null>(
null,
);
export interface PrivateRoomProviderProps {
children: ReactNode;
}
export function PrivateRoomProvider({ children }: PrivateRoomProviderProps) {
const [state, send] = useMachine(privateRoomMachine);
const privateRoomState = useMemo<PrivateRoomContextState>(
() => ({
status: String(state.value),
profile: state.context.profile,
items: state.context.items,
nextCursor: state.context.nextCursor,
hasMore: state.context.hasMore,
creditBalance: state.context.creditBalance,
errorMessage: state.context.errorMessage,
unlockingMomentId: state.context.unlockingMomentId,
unlockErrorMessage: state.context.unlockErrorMessage,
pendingConfirmMomentId: state.context.pendingConfirmMomentId,
unlockPaywallRequest: state.context.unlockPaywallRequest,
unlockSuccessNonce: state.context.unlockSuccessNonce,
isLoading: state.matches("loading"),
isLoadingMore: state.matches("loadingMore"),
isUnlocking: state.matches("unlocking"),
}),
[state],
);
return (
<PrivateRoomStateCtx.Provider value={privateRoomState}>
<PrivateRoomDispatchCtx.Provider value={send}>
{children}
</PrivateRoomDispatchCtx.Provider>
</PrivateRoomStateCtx.Provider>
);
}
export function usePrivateRoomState(): PrivateRoomContextState {
const ctx = useContext(PrivateRoomStateCtx);
if (!ctx) {
throw new Error(
"usePrivateRoomState must be used inside <PrivateRoomProvider>",
);
}
return ctx;
}
export function usePrivateRoomDispatch(): Dispatch<PrivateRoomEvent> {
const ctx = useContext(PrivateRoomDispatchCtx);
if (!ctx) {
throw new Error(
"usePrivateRoomDispatch must be used inside <PrivateRoomProvider>",
);
}
return ctx;
}
@@ -0,0 +1,8 @@
export type PrivateRoomEvent =
| { type: "PrivateRoomInit" }
| { type: "PrivateRoomRefresh" }
| { type: "PrivateRoomLoadMore" }
| { type: "PrivateRoomUnlockRequested"; momentId: string }
| { type: "PrivateRoomUnlockConfirmed" }
| { type: "PrivateRoomUnlockCancelled" }
| { type: "PrivateRoomUnlockPaywallConsumed" };
@@ -0,0 +1,51 @@
import { fromPromise } from "xstate";
import type {
PrivateRoomMomentsResponse,
PrivateRoomUnlockResponse,
} from "@/data/dto/private-room";
import { getPrivateRoomRepository } from "@/data/repositories/private_room_repository";
import { Result } from "@/utils";
import {
PRIVATE_ROOM_CHARACTER,
PRIVATE_ROOM_PAGE_SIZE,
} from "./private-room-machine.helpers";
export const loadPrivateRoomMomentsActor =
fromPromise<PrivateRoomMomentsResponse>(async () => {
const repo = getPrivateRoomRepository();
const result = await repo.getMoments({
character: PRIVATE_ROOM_CHARACTER,
limit: PRIVATE_ROOM_PAGE_SIZE,
});
if (Result.isErr(result)) throw result.error;
return result.data;
});
export const loadMorePrivateRoomMomentsActor = fromPromise<
PrivateRoomMomentsResponse,
{ cursor: string | null }
>(async ({ input }) => {
const repo = getPrivateRoomRepository();
const result = await repo.getMoments({
character: PRIVATE_ROOM_CHARACTER,
limit: PRIVATE_ROOM_PAGE_SIZE,
cursor: input.cursor,
});
if (Result.isErr(result)) throw result.error;
return result.data;
});
export const unlockPrivateRoomMomentActor = fromPromise<
PrivateRoomUnlockResponse,
{ momentId: string; expectedCost: number }
>(async ({ input }) => {
const repo = getPrivateRoomRepository();
const result = await repo.unlockMoment(
input.momentId,
input.expectedCost,
);
if (Result.isErr(result)) throw result.error;
return result.data;
});
@@ -0,0 +1,88 @@
import type {
PrivateRoomMoment,
PrivateRoomMomentsResponse,
PrivateRoomUnlockResponse,
} from "@/data/dto/private-room";
import type {
PrivateRoomState,
PrivateRoomUnlockPaywallRequest,
} from "./private-room-state";
export const PRIVATE_ROOM_CHARACTER = "elio";
export const PRIVATE_ROOM_PAGE_SIZE = 20;
export function applyMomentsResponse(
response: PrivateRoomMomentsResponse,
): Partial<PrivateRoomState> {
return {
profile: response.profile,
items: response.items,
nextCursor: response.nextCursor,
hasMore: response.hasMore,
creditBalance: response.creditBalance,
errorMessage: null,
};
}
export function appendMomentsResponse(
context: PrivateRoomState,
response: PrivateRoomMomentsResponse,
): Partial<PrivateRoomState> {
const knownIds = new Set(context.items.map((item) => item.momentId));
const nextItems = [
...context.items,
...response.items.filter((item) => !knownIds.has(item.momentId)),
];
return {
profile: response.profile,
items: nextItems,
nextCursor: response.nextCursor,
hasMore: response.hasMore,
creditBalance: response.creditBalance,
errorMessage: null,
};
}
export function replaceMoment(
items: readonly PrivateRoomMoment[],
nextMoment: PrivateRoomMoment,
): PrivateRoomMoment[] {
return items.map((item) =>
item.momentId === nextMoment.momentId ? nextMoment : item,
);
}
export function toPrivateRoomErrorMessage(error: unknown): string {
if (error instanceof Error && error.message) return error.message;
return "Private room is temporarily unavailable. Please try again.";
}
export function toUnlockErrorMessage(
response: PrivateRoomUnlockResponse,
): string | null {
switch (response.reason) {
case "cost_changed":
return "Unlock price changed. Please confirm again.";
case "not_found":
return "This private room post is no longer available.";
case "no_media":
return "This private room post has no photos to unlock.";
case "deduct_failed":
return "Credit deduction failed. Please try again.";
default:
return response.unlocked ? null : "Unlock failed. Please try again.";
}
}
export function toPaywallRequest(
response: PrivateRoomUnlockResponse,
): PrivateRoomUnlockPaywallRequest {
return {
momentId: response.momentId,
reason: response.reason,
requiredCredits: response.requiredCredits,
currentCredits: response.currentCredits || response.creditBalance,
shortfallCredits: response.shortfallCredits,
};
}
@@ -0,0 +1,217 @@
import { assign, setup } from "xstate";
import type { PrivateRoomMoment } from "@/data/dto/private-room";
import type { PrivateRoomEvent } from "./private-room-events";
import {
appendMomentsResponse,
applyMomentsResponse,
replaceMoment,
toPaywallRequest,
toPrivateRoomErrorMessage,
toUnlockErrorMessage,
} from "./private-room-machine.helpers";
import {
loadMorePrivateRoomMomentsActor,
loadPrivateRoomMomentsActor,
unlockPrivateRoomMomentActor,
} from "./private-room-machine.actors";
import { initialState, type PrivateRoomState } from "./private-room-state";
export type { PrivateRoomEvent } from "./private-room-events";
export type { PrivateRoomState } from "./private-room-state";
export { initialState } from "./private-room-state";
function getPendingMoment(context: PrivateRoomState): PrivateRoomMoment | null {
if (!context.pendingConfirmMomentId) return null;
return (
context.items.find(
(item) => item.momentId === context.pendingConfirmMomentId,
) ?? null
);
}
export const privateRoomMachine = setup({
types: {
context: {} as PrivateRoomState,
events: {} as PrivateRoomEvent,
},
actors: {
loadMoments: loadPrivateRoomMomentsActor,
loadMoreMoments: loadMorePrivateRoomMomentsActor,
unlockMoment: unlockPrivateRoomMomentActor,
},
}).createMachine({
id: "privateRoom",
initial: "idle",
context: initialState,
states: {
idle: {
on: {
PrivateRoomInit: "loading",
},
},
loading: {
entry: assign({
errorMessage: null,
unlockPaywallRequest: null,
}),
invoke: {
src: "loadMoments",
onDone: {
target: "ready",
actions: assign(({ event }) => applyMomentsResponse(event.output)),
},
onError: {
target: "failed",
actions: assign(({ event }) => ({
errorMessage: toPrivateRoomErrorMessage(event.error),
})),
},
},
},
failed: {
on: {
PrivateRoomInit: "loading",
PrivateRoomRefresh: "loading",
},
},
ready: {
on: {
PrivateRoomInit: {
target: "loading",
},
PrivateRoomRefresh: {
target: "loading",
},
PrivateRoomLoadMore: [
{
guard: ({ context }) =>
context.hasMore && context.nextCursor !== null,
target: "loadingMore",
},
],
PrivateRoomUnlockRequested: {
actions: assign(({ event }) => ({
pendingConfirmMomentId: event.momentId,
unlockErrorMessage: null,
unlockPaywallRequest: null,
})),
},
PrivateRoomUnlockCancelled: {
actions: assign({
pendingConfirmMomentId: null,
}),
},
PrivateRoomUnlockConfirmed: [
{
guard: ({ context }) => getPendingMoment(context) !== null,
target: "unlocking",
},
],
PrivateRoomUnlockPaywallConsumed: {
actions: assign({
unlockPaywallRequest: null,
}),
},
},
},
loadingMore: {
invoke: {
src: "loadMoreMoments",
input: ({ context }) => ({
cursor: context.nextCursor,
}),
onDone: {
target: "ready",
actions: assign(({ context, event }) =>
appendMomentsResponse(context, event.output),
),
},
onError: {
target: "ready",
actions: assign(({ event }) => ({
errorMessage: toPrivateRoomErrorMessage(event.error),
})),
},
},
},
unlocking: {
entry: assign(({ context }) => ({
unlockingMomentId: context.pendingConfirmMomentId,
unlockErrorMessage: null,
unlockPaywallRequest: null,
})),
invoke: {
src: "unlockMoment",
input: ({ context }) => {
const pendingMoment = getPendingMoment(context);
return {
momentId: pendingMoment?.momentId ?? "",
expectedCost: pendingMoment?.unlockCost ?? 0,
};
},
onDone: [
{
guard: ({ event }) => event.output.unlocked,
target: "ready",
actions: assign(({ context, event }) => ({
items: replaceMoment(context.items, event.output),
creditBalance: event.output.creditBalance || context.creditBalance,
pendingConfirmMomentId: null,
unlockingMomentId: null,
unlockErrorMessage: null,
unlockSuccessNonce: context.unlockSuccessNonce + 1,
})),
},
{
guard: ({ event }) =>
event.output.reason === "insufficient_credits",
target: "ready",
actions: assign(({ context, event }) => ({
items: replaceMoment(context.items, event.output),
creditBalance: event.output.creditBalance || context.creditBalance,
pendingConfirmMomentId: null,
unlockingMomentId: null,
unlockErrorMessage: null,
unlockPaywallRequest: toPaywallRequest(event.output),
})),
},
{
guard: ({ event }) => event.output.reason === "cost_changed",
target: "loading",
actions: assign(({ event }) => ({
pendingConfirmMomentId: null,
unlockingMomentId: null,
unlockErrorMessage: toUnlockErrorMessage(event.output),
})),
},
{
target: "ready",
actions: assign(({ context, event }) => ({
items: replaceMoment(context.items, event.output),
pendingConfirmMomentId: null,
unlockingMomentId: null,
unlockErrorMessage: toUnlockErrorMessage(event.output),
})),
},
],
onError: {
target: "ready",
actions: assign(({ event }) => ({
pendingConfirmMomentId: null,
unlockingMomentId: null,
unlockErrorMessage: toPrivateRoomErrorMessage(event.error),
})),
},
},
},
},
});
export type PrivateRoomMachine = typeof privateRoomMachine;
@@ -0,0 +1,40 @@
import type {
PrivateRoomMoment,
PrivateRoomProfile,
} from "@/data/dto/private-room";
export interface PrivateRoomUnlockPaywallRequest {
momentId: string;
reason: string;
requiredCredits: number;
currentCredits: number;
shortfallCredits: number;
}
export interface PrivateRoomState {
profile: PrivateRoomProfile | null;
items: PrivateRoomMoment[];
nextCursor: string | null;
hasMore: boolean;
creditBalance: number;
errorMessage: string | null;
unlockingMomentId: string | null;
unlockErrorMessage: string | null;
pendingConfirmMomentId: string | null;
unlockPaywallRequest: PrivateRoomUnlockPaywallRequest | null;
unlockSuccessNonce: number;
}
export const initialState: PrivateRoomState = {
profile: null,
items: [],
nextCursor: null,
hasMore: false,
creditBalance: 0,
errorMessage: null,
unlockingMomentId: null,
unlockErrorMessage: null,
pendingConfirmMomentId: null,
unlockPaywallRequest: null,
unlockSuccessNonce: 0,
};