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 === '') {