From 1a54c691bc2482fd43490c9315cb6e49f063cd3b Mon Sep 17 00:00:00 2001
From: uonr <dev@yuru.me>
Date: Sat, 4 May 2024 12:38:01 +0900
Subject: [PATCH] feat(spa): initial in game state

---
 apps/spa/components/compose/Compose.tsx       |  5 +-
 .../components/compose/InGameSwitchButton.tsx | 21 +++++-
 .../spa/components/compose/useSendPreview.tsx | 19 +++--
 .../components/pane-channel/ChannelPane.tsx   |  4 +-
 .../pane-channel/NameEditContent.tsx          |  4 +-
 .../components/pane-channel/SelfPreview.tsx   |  2 +-
 apps/spa/components/pane-channel/useSend.tsx  |  6 +-
 apps/spa/hooks/useChannelAtoms.tsx            | 13 ++--
 apps/spa/hooks/useChatList.tsx                | 11 ++-
 apps/spa/state/compose.actions.ts             |  3 +-
 apps/spa/state/compose.reducer.ts             | 70 +++++++++----------
 11 files changed, 95 insertions(+), 63 deletions(-)

diff --git a/apps/spa/components/compose/Compose.tsx b/apps/spa/components/compose/Compose.tsx
index b264663d7..db8ab3bd6 100644
--- a/apps/spa/components/compose/Compose.tsx
+++ b/apps/spa/components/compose/Compose.tsx
@@ -54,7 +54,10 @@ export const Compose = ({ member, channelAtoms }: Props) => {
     return <EditMessageBanner currentUser={member.user} />;
   }, [isEditing, member.user]);
   const fileButton = useMemo(() => <FileButton />, []);
-  const inGameSwitchButton = useMemo(() => <InGameSwitchButton />, []);
+  const inGameSwitchButton = useMemo(
+    () => <InGameSwitchButton channelId={member.channel.channelId} />,
+    [member.channel.channelId],
+  );
   const addDiceButton = useMemo(() => <AddDiceButton />, []);
   const sendButton = useMemo(
     () => <SendButton send={send} currentUser={member.user} isEditing={isEditing} />,
diff --git a/apps/spa/components/compose/InGameSwitchButton.tsx b/apps/spa/components/compose/InGameSwitchButton.tsx
index abba8b71e..f6610f641 100644
--- a/apps/spa/components/compose/InGameSwitchButton.tsx
+++ b/apps/spa/components/compose/InGameSwitchButton.tsx
@@ -4,17 +4,32 @@ import { FC } from 'react';
 import { useIntl } from 'react-intl';
 import { useChannelAtoms } from '../../hooks/useChannelAtoms';
 import { InComposeButton } from './InComposeButton';
+import { useQueryChannel } from '../../hooks/useQueryChannel';
 
-interface Props {}
+interface Props {
+  channelId: string;
+}
 
-export const InGameSwitchButton: FC<Props> = () => {
+export const InGameSwitchButton: FC<Props> = ({ channelId }) => {
+  const { data: channel } = useQueryChannel(channelId);
   const { inGameAtom, composeAtom } = useChannelAtoms();
   const intl = useIntl();
   const inGame = useAtomValue(inGameAtom);
   const dispatch = useSetAtom(composeAtom);
   const title = intl.formatMessage({ defaultMessage: 'Toggle In Game' });
   return (
-    <InComposeButton pressed={inGame} onClick={() => dispatch({ type: 'toggleInGame', payload: {} })} title={title}>
+    <InComposeButton
+      pressed={inGame}
+      onClick={() =>
+        dispatch({
+          type: 'toggleInGame',
+          payload: {
+            defaultInGame: channel?.type === 'IN_GAME',
+          },
+        })
+      }
+      title={title}
+    >
       <Mask className={inGame ? '' : 'text-text-lighter'} />
     </InComposeButton>
   );
diff --git a/apps/spa/components/compose/useSendPreview.tsx b/apps/spa/components/compose/useSendPreview.tsx
index 13dfa9707..c947919b8 100644
--- a/apps/spa/components/compose/useSendPreview.tsx
+++ b/apps/spa/components/compose/useSendPreview.tsx
@@ -21,13 +21,14 @@ const sendPreview = (
   parsed: ParseResult,
   connection: WebSocket,
   sendTimeoutRef: MutableRefObject<number | undefined>,
+  defaultInGame: boolean,
 ): void => {
   window.clearTimeout(sendTimeoutRef.current);
 
   sendTimeoutRef.current = window.setTimeout(() => {
-    const { defaultInGame: composeInGame, previewId, inputedName, editFor } = compose;
+    const { previewId, inputedName, editFor } = compose;
     const { isAction, broadcast, whisperToUsernames, inGame: parsedInGame } = parsed;
-    const inGame = parsedInGame ?? composeInGame;
+    const inGame = parsedInGame ?? defaultInGame;
     const inGameName = inputedName || characterName;
     if (!previewId) {
       return;
@@ -59,6 +60,7 @@ export const useSendPreview = (
   characterName: string,
   composeAtom: ComposeAtom,
   parsedAtom: Atom<ParseResult>,
+  defaultInGame: boolean,
 ) => {
   const store = useStore();
   const sendTimoutRef = useRef<number | undefined>(undefined);
@@ -74,7 +76,16 @@ export const useSendPreview = (
       }
       const composeState = store.get(composeAtom);
       const parsed = store.get(parsedAtom);
-      sendPreview(channelId, nickname, characterName, composeState, parsed, connectionState.connection, sendTimoutRef);
+      sendPreview(
+        channelId,
+        nickname,
+        characterName,
+        composeState,
+        parsed,
+        connectionState.connection,
+        sendTimoutRef,
+        defaultInGame,
+      );
     });
-  }, [channelId, characterName, composeAtom, connectionState, isFocused, nickname, parsedAtom, store]);
+  }, [channelId, characterName, composeAtom, connectionState, defaultInGame, isFocused, nickname, parsedAtom, store]);
 };
diff --git a/apps/spa/components/pane-channel/ChannelPane.tsx b/apps/spa/components/pane-channel/ChannelPane.tsx
index fac984ca5..92182dae0 100644
--- a/apps/spa/components/pane-channel/ChannelPane.tsx
+++ b/apps/spa/components/pane-channel/ChannelPane.tsx
@@ -39,15 +39,17 @@ const SecretChannelInfo: FC<{ className?: string }> = ({ className }) => {
 export const ChatPaneChannel: FC<Props> = memo(({ channelId }) => {
   const { data: currentUser } = useQueryUser();
   const member = useMyChannelMember(channelId);
-  const atoms: ChannelAtoms = useMakeChannelAtoms(channelId, member.isOk ? member.some.channel : null);
   const nickname = currentUser?.nickname ?? undefined;
   const { data: channel, isLoading, error } = useQueryChannel(channelId);
+  const defaultInGame = channel?.type === 'IN_GAME';
+  const atoms: ChannelAtoms = useMakeChannelAtoms(channelId, member.isOk ? member.some.channel : null, defaultInGame);
   useSendPreview(
     channelId,
     nickname,
     member.isOk ? member.some.channel.characterName : '',
     atoms.composeAtom,
     atoms.parsedAtom,
+    defaultInGame,
   );
   const memberListState = useAtomValue(atoms.memberListStateAtom);
   let errorNode = null;
diff --git a/apps/spa/components/pane-channel/NameEditContent.tsx b/apps/spa/components/pane-channel/NameEditContent.tsx
index a72388a04..76f0e7247 100644
--- a/apps/spa/components/pane-channel/NameEditContent.tsx
+++ b/apps/spa/components/pane-channel/NameEditContent.tsx
@@ -85,10 +85,10 @@ export const NameEditContent: FC<Props> = ({ member }) => {
     inGame: baseId + 'in-game',
   };
   const switchToInGame = () => {
-    dispatch({ type: 'toggleInGame', payload: { inGame: true } });
+    dispatch({ type: 'setInGame', payload: { inGame: true } });
   };
   const switchToOutOfGame = () => {
-    dispatch({ type: 'toggleInGame', payload: { inGame: false } });
+    dispatch({ type: 'setInGame', payload: { inGame: false } });
   };
   return (
     <div className="grid w-52 grid-cols-[auto_auto] gap-x-1 gap-y-2">
diff --git a/apps/spa/components/pane-channel/SelfPreview.tsx b/apps/spa/components/pane-channel/SelfPreview.tsx
index 8183577cc..0f449e5cb 100644
--- a/apps/spa/components/pane-channel/SelfPreview.tsx
+++ b/apps/spa/components/pane-channel/SelfPreview.tsx
@@ -24,7 +24,7 @@ type ComposeDrived = Pick<ComposeState, 'media'> & {
 const isEqual = (a: ComposeDrived, b: ComposeDrived) =>
   a.editMode === b.editMode && a.name === b.name && a.media === b.media;
 
-const selector = ({ defaultInGame: inGame, inputedName, source, editFor, media }: ComposeState): ComposeDrived => {
+const selector = ({ inputedName, source, editFor, media }: ComposeState): ComposeDrived => {
   const editMode = editFor !== null;
   return { name: inputedName.trim(), editMode, media };
 };
diff --git a/apps/spa/components/pane-channel/useSend.tsx b/apps/spa/components/pane-channel/useSend.tsx
index d0a3be87c..ec65b4d7b 100644
--- a/apps/spa/components/pane-channel/useSend.tsx
+++ b/apps/spa/components/pane-channel/useSend.tsx
@@ -12,9 +12,12 @@ import { useQueryChannelMembers } from '../../hooks/useQueryChannelMembers';
 import { parse } from '../../interpreter/parser';
 import { upload } from '../../media';
 import { ComposeActionUnion } from '../../state/compose.actions';
+import { useQueryChannel } from '../../hooks/useQueryChannel';
 
 export const useSend = (me: User) => {
   const channelId = useChannelId();
+  const { data: channel } = useQueryChannel(channelId);
+  const defaultInGame = channel?.type === 'IN_GAME';
   const { composeAtom, parsedAtom, checkComposeAtom } = useChannelAtoms();
   const store = useStore();
   const setBanner = useSetBanner();
@@ -56,7 +59,7 @@ export const useSend = (me: User) => {
     const { text, entities, whisperToUsernames } = parse(compose.source);
     let result: Result<Message, ApiError>;
     let name = nickname;
-    const inGame = parsed.inGame ?? compose.defaultInGame;
+    const inGame = parsed.inGame ?? defaultInGame;
     if (inGame) {
       const inputedName = compose.inputedName.trim();
       if (inputedName === '') {
@@ -131,6 +134,7 @@ export const useSend = (me: User) => {
     channelId,
     checkComposeAtom,
     composeAtom,
+    defaultInGame,
     myMember,
     nickname,
     parsedAtom,
diff --git a/apps/spa/hooks/useChannelAtoms.tsx b/apps/spa/hooks/useChannelAtoms.tsx
index 78d9f5f9c..3c4f9d79a 100644
--- a/apps/spa/hooks/useChannelAtoms.tsx
+++ b/apps/spa/hooks/useChannelAtoms.tsx
@@ -34,11 +34,15 @@ export interface ChannelAtoms {
 
 export const ChannelAtomsContext = createContext<ChannelAtoms | null>(null);
 
-export const useMakeChannelAtoms = (channelId: string, member: ChannelMember | null): ChannelAtoms => {
+export const useMakeChannelAtoms = (
+  channelId: string,
+  member: ChannelMember | null,
+  defaultInGame: boolean,
+): ChannelAtoms => {
   const composeAtom = useMemo(() => atomWithReducer(makeInitialComposeState(), composeReducer), []);
   const checkComposeAtom: Atom<ComposeError | null> = useMemo(
-    () => selectAtom(composeAtom, checkCompose(member?.characterName ?? '')),
-    [composeAtom, member?.characterName],
+    () => selectAtom(composeAtom, checkCompose(member?.characterName ?? '', defaultInGame)),
+    [composeAtom, defaultInGame, member?.characterName],
   );
   return useMemo(() => {
     const loadableParsedAtom = loadable(
@@ -62,7 +66,6 @@ export const useMakeChannelAtoms = (channelId: string, member: ChannelMember | n
     const isWhisperAtom = selectAtom(parsedAtom, ({ whisperToUsernames }) => whisperToUsernames !== null);
     const inGameAtom = atom((read) => {
       const { inGame } = read(parsedAtom);
-      const { defaultInGame } = read(composeAtom);
       if (inGame == null) {
         return defaultInGame;
       } else {
@@ -83,7 +86,7 @@ export const useMakeChannelAtoms = (channelId: string, member: ChannelMember | n
       showArchivedAtom: atomWithStorage(`${channelId}:show-archived`, false),
       memberListStateAtom: atom<ChannelMemberListState>('CLOSED'),
     };
-  }, [channelId, checkComposeAtom, composeAtom]);
+  }, [channelId, checkComposeAtom, composeAtom, defaultInGame]);
 };
 
 export const useChannelAtoms = (): ChannelAtoms => {
diff --git a/apps/spa/hooks/useChatList.tsx b/apps/spa/hooks/useChatList.tsx
index 342149d81..1c9ca181f 100644
--- a/apps/spa/hooks/useChatList.tsx
+++ b/apps/spa/hooks/useChatList.tsx
@@ -27,11 +27,10 @@ export const START_INDEX = 100000000;
 
 type ComposeSlice = Pick<ComposeState, 'previewId' | 'editFor'> & {
   prevPreviewId: string | null;
-  inGame: boolean;
 };
 
 const selectComposeSlice = (
-  { previewId, editFor, defaultInGame }: ComposeState,
+  { previewId, editFor }: ComposeState,
   prevSlice: ComposeSlice | null | undefined,
 ): ComposeSlice => {
   let prevPreviewId: string | null = null;
@@ -43,11 +42,10 @@ const selectComposeSlice = (
     }
   }
 
-  return { previewId, editFor, prevPreviewId, inGame: defaultInGame };
+  return { previewId, editFor, prevPreviewId };
 };
 
-const isComposeSliceEq = (a: ComposeSlice, b: ComposeSlice) =>
-  a.previewId === b.previewId && a.inGame === b.inGame && a.editFor === b.editFor;
+const isComposeSliceEq = (a: ComposeSlice, b: ComposeSlice) => a.previewId === b.previewId && a.editFor === b.editFor;
 
 const filter = (type: ChannelFilter, item: ChatItem) => {
   if (type === 'OOC' && item.inGame) return false;
@@ -174,7 +172,7 @@ export const useChatList = (channelId: string, myId?: string): UseChatListReturn
           composeSlice.previewId,
           myId,
           channelId,
-          composeSlice.inGame,
+          true,
           composeSlice.editFor,
           pos,
           posP,
@@ -228,7 +226,6 @@ export const useChatList = (channelId: string, myId?: string): UseChatListReturn
   }, [
     channelId,
     composeSlice.editFor,
-    composeSlice.inGame,
     composeSlice.prevPreviewId,
     composeSlice.previewId,
     filterType,
diff --git a/apps/spa/state/compose.actions.ts b/apps/spa/state/compose.actions.ts
index ba1ac3549..96e5ccabe 100644
--- a/apps/spa/state/compose.actions.ts
+++ b/apps/spa/state/compose.actions.ts
@@ -6,7 +6,8 @@ import type { ComposeState } from './compose.reducer';
 export type ComposeActionMap = {
   setSource: { channelId: string; source: string };
   setInputedName: { inputedName: string; setInGame?: boolean };
-  toggleInGame: { inGame?: boolean };
+  toggleInGame: { defaultInGame: boolean };
+  setInGame: { inGame: boolean };
   toggleBroadcast: Empty;
   addDice: Empty;
   link: { text: string; href: string };
diff --git a/apps/spa/state/compose.reducer.ts b/apps/spa/state/compose.reducer.ts
index 5607649de..e53fa8336 100644
--- a/apps/spa/state/compose.reducer.ts
+++ b/apps/spa/state/compose.reducer.ts
@@ -12,7 +12,6 @@ export interface ComposeState {
   editFor: string | null;
   inputedName: string;
   previewId: string;
-  defaultInGame: boolean;
   source: string;
   media: File | string | null;
   whisperTo: // Represents whisper to the Game Master
@@ -30,7 +29,6 @@ export const makeInitialComposeState = (): ComposeState => ({
   editFor: null,
   inputedName: '',
   previewId: makeId(),
-  defaultInGame: true,
   source: DEFAULT_COMPOSE_SOURCE,
   media: null,
   range: [DEFAULT_COMPOSE_SOURCE.length, DEFAULT_COMPOSE_SOURCE.length],
@@ -45,42 +43,46 @@ const QUICK_CHECK_REGEX = /[.。](in|out)\b/i;
 
 const handleSetComposeSource = (state: ComposeState, action: ComposeAction<'setSource'>): ComposeState => {
   const { source } = action.payload;
-  let { previewId, defaultInGame } = state;
-  if (QUICK_CHECK_REGEX.exec(source)) {
-    const modifiersResult = parseModifiers(source);
-    if (modifiersResult.inGame) {
-      // Flip the default in-game state if the source has a explicit in-game modifier
-      // So that users can flip the state by deleting the modifier
-      defaultInGame = !modifiersResult.inGame.inGame;
-    }
-  }
+  let { previewId } = state;
   if ((source === '' || state.source === '') && state.editFor === null) {
     previewId = makeId();
   }
-  return { ...state, source: action.payload.source, previewId, defaultInGame };
+  return { ...state, source: action.payload.source, previewId };
 };
 
-const handleToggleInGame = (state: ComposeState, action: ComposeAction<'toggleInGame'>): ComposeState => {
-  const { inGame: modifier } = parseModifiers(state.source);
-  if (action.payload.inGame != null) {
-    // Do nothing if the payload is the same as the current state
-    if (!modifier && state.defaultInGame === action.payload.inGame) {
-      return state;
-    }
-    if (modifier !== false && modifier.inGame === action.payload.inGame) {
-      return state;
-    }
+const handleToggleInGame = (state: ComposeState, { payload }: ComposeAction<'toggleInGame'>): ComposeState => {
+  const { source } = state;
+  const { inGame: modifier } = parseModifiers(source);
+  let nextSource;
+  if (!modifier) {
+    const startsWithSpace = source.startsWith(' ');
+    const command = payload.defaultInGame ? '.out' : '.in';
+    nextSource = (startsWithSpace ? command : `${command} `) + source;
+  } else {
+    const before = source.substring(0, modifier.start);
+    const after = source.substring(modifier.start + modifier.len);
+    const command = modifier.inGame ? '.out ' : '.in ';
+    nextSource = command + (before + after).trimStart();
   }
+  return { ...state, source: nextSource, range: [nextSource.length, nextSource.length] };
+};
+
+const handleSetInGame = (state: ComposeState, { payload }: ComposeAction<'setInGame'>): ComposeState => {
   const { source } = state;
-  let nextSource = source;
+  const { inGame: modifier } = parseModifiers(source);
+  if (modifier !== false && modifier.inGame === payload.inGame) {
+    return state;
+  }
+  let nextSource;
   if (!modifier) {
     const startsWithSpace = source.startsWith(' ');
-    const command = state.defaultInGame ? '.out' : '.in';
+    const command = payload.inGame ? '.in' : '.out';
     nextSource = (startsWithSpace ? command : `${command} `) + source;
   } else {
     const before = source.substring(0, modifier.start);
     const after = source.substring(modifier.start + modifier.len);
-    nextSource = (modifier.inGame ? '.out ' : '.in ') + (before + after).trimStart();
+    const command = payload.inGame ? '.in ' : '.out ';
+    nextSource = command + (before + after).trimStart();
   }
   return { ...state, source: nextSource, range: [nextSource.length, nextSource.length] };
 };
@@ -143,7 +145,7 @@ const handleSetInputedName = (state: ComposeState, { payload }: ComposeAction<'s
   const inputedName = payload.inputedName.trim().slice(0, 32);
   const nextState = { ...state, inputedName };
   if (payload.setInGame) {
-    return handleToggleInGame(nextState, { type: 'toggleInGame', payload: { inGame: true } });
+    return handleSetInGame(nextState, { type: 'setInGame', payload: { inGame: true } });
   } else {
     return nextState;
   }
@@ -182,7 +184,6 @@ const handleEditMessage = (
     editFor,
     media: mediaId,
     source,
-    defaultInGame: inGame,
     inputedName,
     range,
     backup: clearBackup(state),
@@ -242,8 +243,7 @@ const handleSent = (state: ComposeState, { payload: { edit = false } }: ComposeA
     return state.backup;
   }
   const modifiersParseResult = parseModifiers(state.source);
-  const nextDefaultInGame = !(modifiersParseResult.inGame ? modifiersParseResult.inGame.inGame : state.defaultInGame);
-  let source = nextDefaultInGame ? '.out ' : '.in ';
+  let source = '';
   if (modifiersParseResult.mute) {
     source = '.mute ';
   }
@@ -252,7 +252,6 @@ const handleSent = (state: ComposeState, { payload: { edit = false } }: ComposeA
   }
   return {
     ...state,
-    defaultInGame: nextDefaultInGame,
     previewId: makeId(),
     editFor: null,
     range: [source.length, source.length],
@@ -327,6 +326,8 @@ export const composeReducer = (state: ComposeState, action: ComposeActionUnion):
       return handleSetInputedName(state, action);
     case 'toggleInGame':
       return handleToggleInGame(state, action);
+    case 'setInGame':
+      return handleSetInGame(state, action);
     case 'recoverState':
       return handleRecoverState(state, action);
     case 'addDice':
@@ -363,13 +364,8 @@ export const composeReducer = (state: ComposeState, action: ComposeActionUnion):
 };
 
 export const checkCompose =
-  (characterName: string) =>
-  ({
-    source,
-    inputedName,
-    defaultInGame,
-    media,
-  }: Pick<ComposeState, 'source' | 'inputedName' | 'defaultInGame' | 'media'>): ComposeError | null => {
+  (characterName: string, defaultInGame: boolean) =>
+  ({ source, inputedName, media }: Pick<ComposeState, 'source' | 'inputedName' | 'media'>): ComposeError | null => {
     const { inGame, rest } = parseModifiers(source);
     if (inGame ? inGame.inGame : defaultInGame) {
       if (inputedName.trim() === '' && characterName === '') {