diff --git a/package-lock.json b/package-lock.json index de21ff19..01e9a100 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,7 +45,7 @@ "react-native-sqlite-storage": "^6.0.1", "react-native-sqlite-table": "^0.2.2", "react-native-sse": "^1.2.1", - "react-native-svg": "^15.11.2", + "react-native-svg": "^15.15.2", "react-native-uuid": "^2.0.3", "react-native-windows": "0.78.4", "rnw-secure-storage": "^0.1.9", @@ -14482,9 +14482,9 @@ "integrity": "sha512-zejanlScF+IB9tYnbdry0MT34qjBXbiV/E72qGz33W/tX1bx8MXsbB4lxiuPETc9v/008vYZ60yjIstW22VlVg==" }, "node_modules/react-native-svg": { - "version": "15.11.2", - "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.11.2.tgz", - "integrity": "sha512-+YfF72IbWQUKzCIydlijV1fLuBsQNGMT6Da2kFlo1sh+LE3BIm/2Q7AR1zAAR6L0BFLi1WaQPLfFUC9bNZpOmw==", + "version": "15.15.2", + "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.2.tgz", + "integrity": "sha512-lpaSwA2i+eLvcEdDZyGgMEInQW99K06zjJqfMFblE0yxI0SCN5E4x6in46f0IYi6i3w2t2aaq3oOnyYBe+bo4w==", "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", diff --git a/package.json b/package.json index 4aa11a44..57333ba2 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "react-native-sqlite-storage": "^6.0.1", "react-native-sqlite-table": "^0.2.2", "react-native-sse": "^1.2.1", - "react-native-svg": "^15.11.2", + "react-native-svg": "^15.15.2", "react-native-uuid": "^2.0.3", "react-native-windows": "0.78.4", "rnw-secure-storage": "^0.1.9", diff --git a/src/App.tsx b/src/App.tsx index 6f72947b..e75b1d45 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import AuthGate from '@/AuthGate'; import AppProviders from './AppProviders'; import DogFootUnderlay from './DogFootUnderlay'; import SplashRenderer from './SplashRenderer'; +import SSEProvider from './SSEProvider'; import StudyDataHydrator from './StudyDataHydrator'; import SystemUIProvider from './SystemUIProvider'; @@ -15,13 +16,15 @@ const App = () => ( - - - - - - - + + + + + + + + + diff --git a/src/SSEProvider.tsx b/src/SSEProvider.tsx new file mode 100644 index 00000000..3ae7334b --- /dev/null +++ b/src/SSEProvider.tsx @@ -0,0 +1,58 @@ +import type { PropsWithChildren } from 'react'; +import { createContext, useCallback, useEffect, useMemo, useRef } from 'react'; + +import { useChatRoomListSse, useSafeContext } from './hooks'; + +type SseStore = { + getCount: (roomId: string) => number; + setCount: (roomId: string, count: number) => void; + inc: (roomId: string) => void; + subscribe: (listener: () => void) => () => void; +}; + +const SseContext = createContext(null); + +const SSEProvider = ({ children }: PropsWithChildren) => { + const mapRef = useRef(new Map()); + const listenersRef = useRef(new Set<() => void>()); + + const emit = useCallback(() => { + listenersRef.current.forEach(cb => { + cb(); + }); + }, []); + + const store = useMemo(() => ({ + getCount: (roomId) => mapRef.current.get(roomId) ?? 0, + setCount: (roomId, count) => { + mapRef.current.set(roomId, count); + emit(); + }, + inc: (roomId) => { + mapRef.current.set(roomId, (mapRef.current.get(roomId) ?? 0) + 1); + emit(); + }, + subscribe: (listener) => { + listenersRef.current.add(listener); + return () => listenersRef.current.delete(listener); + }, + }), [emit]); + + useChatRoomListSse({ + onInitMessage: (payload) => { + payload.rooms?.forEach(r => { + const count = Math.max(0, r.unread ?? 0); + store.setCount(r.roomId, count); + }); + }, + onNewMessage: (payload) => { + store.inc(payload.roomId); + }, + }); + + return {children}; +}; + +export const useSSEContext = () => useSafeContext(SseContext); + +export default SSEProvider; \ No newline at end of file diff --git a/src/features/chat/sse/useChatCount.ts b/src/features/chat/sse/useChatCount.ts new file mode 100644 index 00000000..f79018d6 --- /dev/null +++ b/src/features/chat/sse/useChatCount.ts @@ -0,0 +1,13 @@ +import { useSyncExternalStore } from 'react'; + +import { useSSEContext } from '@/SSEProvider'; + +export const useChatCount = (roomId: string) => { + const store = useSSEContext(); + + return useSyncExternalStore( + store.subscribe, + () => store.getCount(roomId), + () => store.getCount(roomId), + ); +}; diff --git a/src/hooks/useChatRoomListSse/index.ts b/src/hooks/useChatRoomListSse/index.ts index 4307ccad..648d324b 100644 --- a/src/hooks/useChatRoomListSse/index.ts +++ b/src/hooks/useChatRoomListSse/index.ts @@ -22,7 +22,6 @@ export interface ChatRoomListSseOptions { onReconnectScheduled?: (delayMs: number) => void; } -// TODO: SSE 커넥션 연결에 지연 시간이 생각보다 길어, UX 개선 필요. 앱 포그라운드 상태에만 하나의 커넥션을 여는 것을 목표로 구현하기 export const useChatRoomListSse = (options: ChatRoomListSseOptions = {}) => { const [status, setStatus] = useState('idle'); const [connectionError, setConnectionError] = useState(null); diff --git a/src/locales/ko/createGroup.json b/src/locales/ko/createGroup.json index 892b26e0..3d0d364f 100644 --- a/src/locales/ko/createGroup.json +++ b/src/locales/ko/createGroup.json @@ -7,7 +7,7 @@ "max-member-label": "최대 멤버 수", "max-member-description": "그룹에 참여할 수 있는 최대 멤버 수를 설정하세요(최대 99명).", "max-member-exceed-error": "최대 멤버 수는 99명을 초과할 수 없습니다.", - "publicity-label": "그룹 공개하기", + "make-publicity-private": "비공개 그룹으로 만들기", "publicity-description-public": "공개 그룹으로 설정하면, 누구나 이 그룹을 검색하여 참여할 수 있습니다.", "publicity-description-private": "비공개 그룹으로 설정하면, 초대 코드를 통해서만 이 그룹에 참여할 수 있습니다.", "group-image-label": "그룹 이미지", diff --git a/src/screens/bottomTab/GroupsScreen/components/JoinedGroupPanel/index.tsx b/src/screens/bottomTab/GroupsScreen/components/JoinedGroupPanel/index.tsx index e51f0d3b..75b43c35 100644 --- a/src/screens/bottomTab/GroupsScreen/components/JoinedGroupPanel/index.tsx +++ b/src/screens/bottomTab/GroupsScreen/components/JoinedGroupPanel/index.tsx @@ -55,7 +55,7 @@ const GroupListItem = ({ group }: { group: GetGroupsResponse['data'][number] }) groupColorKey={groupColorKey} groupId={group.groupId} groupName={group.groupName} - isPublic={group.isOpen} + isPublic={!group.isApprovalRequired} participantImageUrls={[leaderImageUrl, ...otherImageUrls]} totalDailyGoalCount={10} /> diff --git a/src/screens/group/CreateGroupScreen/components/GroupPublicityInput.tsx b/src/screens/group/CreateGroupScreen/components/GroupPublicityInput.tsx index 836720ce..59922ce1 100644 --- a/src/screens/group/CreateGroupScreen/components/GroupPublicityInput.tsx +++ b/src/screens/group/CreateGroupScreen/components/GroupPublicityInput.tsx @@ -9,7 +9,7 @@ interface GroupPublicityInputProps { } const GroupPublicityInput = ({ formName, defaultValue }: GroupPublicityInputProps) => { - const tPublicityLabel = useTranslatedText({ tKey: 'createGroup.publicity-label' }); + const tMakePublicityPrivate = useTranslatedText({ tKey: 'createGroup.make-publicity-private' }); const tPublicityDescriptionPublic = useTranslatedText({ tKey: 'createGroup.publicity-description-public', }); @@ -18,7 +18,7 @@ const GroupPublicityInput = ({ formName, defaultValue }: GroupPublicityInputProp }); const { control } = useFormContext(); - const isPublic = useWatch({ + const isPrivate = useWatch({ control, name: formName, defaultValue, @@ -28,8 +28,8 @@ const GroupPublicityInput = ({ formName, defaultValue }: GroupPublicityInputProp ); }; diff --git a/src/screens/group/CreateGroupScreen/index.tsx b/src/screens/group/CreateGroupScreen/index.tsx index 892858de..143b2fff 100644 --- a/src/screens/group/CreateGroupScreen/index.tsx +++ b/src/screens/group/CreateGroupScreen/index.tsx @@ -38,7 +38,7 @@ const CreateGroupScreen = () => { - + {(submit, disabled) => ( diff --git a/src/screens/group/groupDetail/GroupDetailScreen/index.tsx b/src/screens/group/groupDetail/GroupDetailScreen/index.tsx index 8ccac740..b14e1ce4 100644 --- a/src/screens/group/groupDetail/GroupDetailScreen/index.tsx +++ b/src/screens/group/groupDetail/GroupDetailScreen/index.tsx @@ -1,14 +1,13 @@ -import { useFocusEffect } from '@react-navigation/native'; import type { Day } from 'date-fns'; -import { useCallback, useMemo, useState } from 'react'; import { Panel } from '@/components'; import { GROUP_COLORS } from '@/constants/groupColors'; import { useCategoriesQuery } from '@/features/category/api/queries'; import { isDayInDayBelong } from '@/features/category/model/dayBelongHelper'; +import { useChatCount } from '@/features/chat/sse/useChatCount'; import { useGroupDetailQuery } from '@/features/groups/api/queries'; import { useGroupColorKey } from '@/features/groups/hooks/useGroupColorKey'; -import { useChatRoomListSse, useStackRoute } from '@/hooks'; +import { useStackRoute } from '@/hooks'; import { StackableScreenLayout } from '@/layout'; import { studyDataUtils } from '@/store/studySlice'; @@ -21,36 +20,7 @@ const GroupDetailScreen = () => { const { colorKey } = useGroupColorKey(params.groupId); const groupColor = colorKey ? GROUP_COLORS[colorKey] : null; const { data, isPending } = useGroupDetailQuery(params.groupId); - - const [chatCount, setChatCount] = useState(null); - - const targetChatRoomId = useMemo(() => String(params.chatRoomId), [params.chatRoomId]); - - const { connect, disconnect } = useChatRoomListSse({ - onInitMessage: (payload) => { - const room = payload.rooms?.find(r => String(r.roomId) === targetChatRoomId); - if (room?.unread != null) { - const normalizedUnreadCount = room.unread < 0 ? 0 : room.unread; - setChatCount(normalizedUnreadCount); - } - }, - onNewMessage: (payload) => { - if (String(payload.roomId) !== targetChatRoomId) return; - setChatCount(prev => { - if (prev === null) return null; - return prev + 1; - }); - }, - }); - - const manageSseConnection = useCallback(() => { - connect(); - return () => { - disconnect(); - }; - }, [connect, disconnect]); - - useFocusEffect(manageSseConnection); + const chatCount = useChatCount(params.chatRoomId); // TODO: Fallback UI if (isPending || !data) return null; diff --git a/tsconfig.json b/tsconfig.json index f2006c1c..9a91ff9f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,6 +16,7 @@ "@store/*": ["src/store/*"], }, "resolveJsonModule": true, - "esModuleInterop": true + "esModuleInterop": true, + "incremental": true, } }