Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions apps/web/src/chats/ChatAnswerRequestPolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ import type {
ProfileId,
ShowId,
} from "@showtime/contracts";
import { planChatAnswerRequests, type ChatAnswerRequestSequences } from "./ChatAnswerRequestPolicy";
import {
planChatAnswerRequests,
reconcileChatAnswerRequests,
type AnswerRequest,
type ChatAnswerRequestSequences,
} from "./ChatAnswerRequestPolicy";

const showId = "show_0000000000000001" as ShowId;
const channelId = "channel_0000000000000001" as ChatChannelId;
Expand All @@ -31,14 +36,14 @@ const message = (sequence: number, overrides: Partial<ChatMessage> = {}): ChatMe
...overrides,
});

const request = (sequence: number, overrides: Partial<ChatMessage> = {}): ChatMessage =>
const request = (sequence: number, overrides: Partial<ChatMessage> = {}): AnswerRequest =>
message(sequence, {
answer: {
template: "{{answer}}" as NonNullable<ChatMessage["answer"]>["template"],
fields: [{ name: "answer", type: "text" }],
},
...overrides,
});
}) as AnswerRequest;

const channel = (overrides: Partial<ChatChannel> = {}): ChatChannel => ({
id: channelId,
Expand Down Expand Up @@ -128,4 +133,17 @@ describe("chat answer request policy", () => {
expect(planned.requests).toEqual([]);
expect(planned.sequences.get(channelId)).toBe(2);
});

it("drops queued requests when their channel is deleted", () => {
const stale = request(2);
const available = request(3, { channelId: addedChannelId });

const reconciled = reconcileChatAnswerRequests({
queued: [stale, available],
incoming: [available],
channelIds: new Set([addedChannelId]),
});

expect(reconciled).toEqual([available]);
});
});
20 changes: 20 additions & 0 deletions apps/web/src/chats/ChatAnswerRequestPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,26 @@ export type AnswerRequest = ChatMessage & { readonly answer: ChatPresetAnswer };

export type ChatAnswerRequestSequences = ReadonlyMap<ChatChannelId, ChatSequence>;

export const reconcileChatAnswerRequests = ({
queued,
incoming,
channelIds,
}: {
readonly queued: ReadonlyArray<AnswerRequest>;
readonly incoming: ReadonlyArray<AnswerRequest>;
readonly channelIds: ReadonlySet<ChatChannelId>;
}): ReadonlyArray<AnswerRequest> => {
const next = queued.filter((request) => channelIds.has(request.channelId));
for (const request of incoming) {
if (channelIds.has(request.channelId) && !next.some((item) => item.id === request.id)) {
next.push(request);
}
}
return next.length === queued.length && next.every((request, index) => request === queued[index])
? queued
: next;
};

const isAnswerRequest = (message: ChatMessage): message is AnswerRequest =>
message.answer !== undefined;

Expand Down
8 changes: 5 additions & 3 deletions apps/web/src/chats/ChatNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ export type { ChatOpenRequest } from "./ChatNavigationState";

const eventName = "showtime-chat-open";

const activeShowId = () => {
const activeChatShowId = () => {
const pathname = router.state.location.pathname;
if (!pathname.includes("/live/") && !pathname.endsWith("/chat")) return undefined;
for (const match of router.state.matches) {
const showId = (match.params as { readonly showId?: unknown }).showId;
if (typeof showId === "string") return showId;
Expand All @@ -14,8 +16,8 @@ const activeShowId = () => {
};

const chatNavigation = makeChatNavigation({
getActiveShowId: activeShowId,
navigateToShow: (showId) => router.navigate({ to: "/shows/$showId", params: { showId } }),
getActiveShowId: activeChatShowId,
navigateToShow: (showId) => router.navigate({ to: "/shows/$showId/chat", params: { showId } }),
publishOpenRequest: () => window.dispatchEvent(new Event(eventName)),
});

Expand Down
133 changes: 133 additions & 0 deletions apps/web/src/components/chats/ChatAnswerPrompts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import * as React from "react";
import { useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";
import type { ChatSnapshot, Profile, ShowId } from "@showtime/contracts";
import { chatAtoms, profileAtoms } from "@/client";
import { registerChatAnswerDialog } from "@/chats/ChatAnswerDialogPresence";
import {
planChatAnswerRequests,
reconcileChatAnswerRequests,
type AnswerRequest,
type ChatAnswerRequestSequences,
} from "@/chats/ChatAnswerRequestPolicy";
import { ChatPresetAnswerDialog } from "@/components/chats/ChatPresetAnswer";
import { useSelectedProfile } from "@/profiles";

export function ChatAnswerPrompts({
showId,
chatOpen,
}: {
readonly showId: ShowId;
readonly chatOpen: boolean;
}) {
const profilesResult = useAtomValue(profileAtoms.state);
const profileState = AsyncResult.isSuccess(profilesResult) ? profilesResult.value : undefined;
const { selected } = useSelectedProfile(profileState);

return selected ? (
<ProfileChatAnswerPrompts
key={`${showId}:${selected.id}`}
showId={showId}
chatOpen={chatOpen}
profile={selected}
profiles={profileState?.profiles ?? []}
/>
) : null;
}

function ProfileChatAnswerPrompts({
showId,
chatOpen,
profile,
profiles,
}: {
readonly showId: ShowId;
readonly chatOpen: boolean;
readonly profile: Profile;
readonly profiles: ReadonlyArray<Profile>;
}) {
const result = useAtomValue(chatAtoms(showId, profile.id).state);
const snapshot = AsyncResult.isSuccess(result) ? result.value : undefined;

return (
<ReadyChatAnswerPrompts
showId={showId}
chatOpen={chatOpen}
profile={profile}
profiles={profiles}
snapshot={snapshot}
/>
);
}

function ReadyChatAnswerPrompts({
showId,
chatOpen,
profile,
profiles,
snapshot,
}: {
readonly showId: ShowId;
readonly chatOpen: boolean;
readonly profile: Profile;
readonly profiles: ReadonlyArray<Profile>;
readonly snapshot?: ChatSnapshot;
}) {
const [pendingAnswers, setPendingAnswers] = React.useState<ReadonlyArray<AnswerRequest>>([]);
const newestSequences = React.useRef<ChatAnswerRequestSequences | undefined>(undefined);

React.useLayoutEffect(() => {
if (chatOpen) return;
return registerChatAnswerDialog(showId, profile.id);
}, [chatOpen, profile.id, showId]);

React.useEffect(() => {
if (!snapshot) return;
const { requests, sequences } = planChatAnswerRequests({
channels: snapshot.channels,
profileId: profile.id,
previousSequences: newestSequences.current,
shouldPrompt: !chatOpen,
});
newestSequences.current = sequences;
const channelIds = new Set(snapshot.channels.map((channel) => channel.id));
setPendingAnswers((current) =>
reconcileChatAnswerRequests({ queued: current, incoming: requests, channelIds }),
);
}, [chatOpen, profile.id, snapshot]);

React.useEffect(() => {
if (chatOpen) setPendingAnswers([]);
}, [chatOpen]);

const pendingAnswer = pendingAnswers[0];
const pendingChannel = pendingAnswer
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
? snapshot?.channels.find((channel) => channel.id === pendingAnswer.channelId)
: undefined;
const answered = Boolean(
pendingAnswer &&
pendingChannel?.messages.some(
(message) =>
message.replyToMessageId === pendingAnswer.id && message.senderProfileId === profile.id,
),
);
const dismiss = () => setPendingAnswers((current) => current.slice(1));

return (
<ChatPresetAnswerDialog
open={Boolean(pendingAnswer) && !chatOpen}
onOpenChange={(open) => {
if (!open) dismiss();
}}
showId={showId}
profileId={profile.id}
request={pendingAnswer}
senderName={
profiles.find((candidate) => candidate.id === pendingAnswer?.senderProfileId)?.name ??
"the sender"
}
answered={answered}
onAnswered={dismiss}
/>
);
}
94 changes: 5 additions & 89 deletions apps/web/src/components/chats/ChatDrawer.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,11 @@
import * as React from "react";
import { useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";
import type { ChatChannelId, ChatSnapshot, Profile, ProfileId, ShowId } from "@showtime/contracts";
import type { ChatChannelId, Profile, ProfileId, ShowId } from "@showtime/contracts";
import { chatAtoms, profileAtoms } from "@/client";
import { registerChatAnswerDialog } from "@/chats/ChatAnswerDialogPresence";
import {
planChatAnswerRequests,
type AnswerRequest,
type ChatAnswerRequestSequences,
} from "@/chats/ChatAnswerRequestPolicy";
import { consumeChatOpenRequest, subscribeChatOpenRequests } from "@/chats/ChatNavigation";
import { ChatAnswerPrompts } from "@/components/chats/ChatAnswerPrompts";
import { ChatWorkspace } from "@/components/chats/ChatWorkspace";
import { ChatPresetAnswerDialog } from "@/components/chats/ChatPresetAnswer";
import { ProfileSwitcher } from "@/components/profiles/ProfileSwitcher";
import { Badge } from "@/components/ui/badge";
import {
Expand All @@ -37,39 +31,23 @@ export function ChatDrawer(props: ChatDrawerProps) {
const { selected } = useSelectedProfile(profileState);

return selected ? (
<ProfileChatDrawer
key={`${props.showId}:${selected.id}`}
{...props}
profile={selected}
profiles={profileState?.profiles ?? []}
/>
<ProfileChatDrawer key={`${props.showId}:${selected.id}`} {...props} profile={selected} />
) : (
<ChatDrawerView {...props} unreadCount={0} />
);
}

function ProfileChatDrawer({
profile,
profiles,
...props
}: ChatDrawerProps & {
readonly profile: Profile;
readonly profiles: ReadonlyArray<Profile>;
}) {
const result = useAtomValue(chatAtoms(props.showId, profile.id).state);
const snapshot = AsyncResult.isSuccess(result) ? result.value : undefined;
const unreadCount = AsyncResult.isSuccess(result)
? result.value.channels.reduce((total, channel) => total + channel.unreadCount, 0)
: 0;
return (
<ChatDrawerView
{...props}
unreadCount={unreadCount}
profile={profile}
profiles={profiles}
snapshot={snapshot}
/>
);
return <ChatDrawerView {...props} unreadCount={unreadCount} />;
}

function ChatDrawerView({
Expand All @@ -79,21 +57,13 @@ function ChatDrawerView({
onOpenChange,
trigger,
onSelectedChannelChange,
profile,
profiles = [],
snapshot,
}: ChatDrawerProps & {
readonly unreadCount: number;
readonly profile?: Profile;
readonly profiles?: ReadonlyArray<Profile>;
readonly snapshot?: ChatSnapshot;
}) {
const [internalOpen, setInternalOpen] = React.useState(false);
const open = controlledOpen ?? internalOpen;
const setOpen = onOpenChange ?? setInternalOpen;
const [selectedChannelId, setSelectedChannelId] = React.useState<ChatChannelId>();
const [pendingAnswers, setPendingAnswers] = React.useState<ReadonlyArray<AnswerRequest>>([]);
const newestSequences = React.useRef<ChatAnswerRequestSequences | undefined>(undefined);
const selectChannel = React.useCallback(
(channelId: ChatChannelId) => {
setSelectedChannelId(channelId);
Expand All @@ -113,11 +83,6 @@ function ChatDrawerView({
return () => query.removeEventListener("change", update);
}, []);

React.useLayoutEffect(() => {
if (!profile || open) return;
return registerChatAnswerDialog(showId, profile.id);
}, [open, profile, showId]);

React.useEffect(() => {
const openRequestedChat = () => {
const request = consumeChatOpenRequest(showId);
Expand All @@ -129,39 +94,6 @@ function ChatDrawerView({
return subscribeChatOpenRequests(openRequestedChat);
}, [selectChannel, setOpen, showId]);

React.useEffect(() => {
if (!snapshot || !profile) return;
const { requests, sequences } = planChatAnswerRequests({
channels: snapshot.channels,
profileId: profile.id,
previousSequences: newestSequences.current,
shouldPrompt: !open,
});
newestSequences.current = sequences;
if (requests.length > 0)
setPendingAnswers((current) => [
...current,
...requests.filter((request) => !current.some((item) => item.id === request.id)),
]);
}, [open, profile, snapshot]);

React.useEffect(() => {
if (open) setPendingAnswers([]);
}, [open]);

const pendingAnswer = pendingAnswers[0];
const pendingChannel = pendingAnswer
? snapshot?.channels.find((channel) => channel.id === pendingAnswer.channelId)
: undefined;
const pendingAnswered = Boolean(
pendingAnswer &&
pendingChannel?.messages.some(
(message) =>
message.replyToMessageId === pendingAnswer.id && message.senderProfileId === profile?.id,
),
);
const dismissPendingAnswer = () => setPendingAnswers((current) => current.slice(1));

return (
<>
<Drawer open={open} onOpenChange={setOpen} swipeDirection={isMobile ? "down" : "right"}>
Expand All @@ -188,23 +120,7 @@ function ChatDrawerView({
</div>
</DrawerContent>
</Drawer>
{profile && (
<ChatPresetAnswerDialog
open={Boolean(pendingAnswer) && !open}
onOpenChange={(nextOpen) => {
if (!nextOpen) dismissPendingAnswer();
}}
showId={showId}
profileId={profile.id}
request={pendingAnswer}
senderName={
profiles.find((candidate) => candidate.id === pendingAnswer?.senderProfileId)?.name ??
"the sender"
}
answered={pendingAnswered}
onAnswered={dismissPendingAnswer}
/>
)}
<ChatAnswerPrompts showId={showId} chatOpen={open} />
</>
);
}
Expand Down
Loading
Loading