From 35ad8f7deda440b35d4e3e5468aeee77eb0e1ccd Mon Sep 17 00:00:00 2001 From: dioo1461 Date: Fri, 6 Feb 2026 19:13:44 +0900 Subject: [PATCH 1/6] =?UTF-8?q?refactor:=20SSE=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=EC=8B=9C=EC=A0=90=EC=9D=84=20"=EC=95=B1=20=EC=8B=9C=EC=9E=91"?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 17 +++--- src/SSEProvider.tsx | 56 +++++++++++++++++++ .../groupDetail/GroupDetailScreen/index.tsx | 17 ++---- 3 files changed, 70 insertions(+), 20 deletions(-) create mode 100644 src/SSEProvider.tsx 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..0c9e4397 --- /dev/null +++ b/src/SSEProvider.tsx @@ -0,0 +1,56 @@ +import { + createContext, + type PropsWithChildren, + useCallback, + useEffect, + useMemo, + useRef, +} from 'react'; + +import { useChatRoomListSse, useSafeContext } from './hooks'; +import type { ChatRoomListSseOptions } from './hooks/useChatRoomListSse'; + +type SseContextValue = { + addHandlers: (handlers: ChatRoomListSseOptions) => () => void; +}; +const SseContext = createContext(null); + +export const SSEProvider = ({ children }: PropsWithChildren) => { + const handlersRef = useRef(new Set()); + + const { connect, disconnect } = useChatRoomListSse({ + onInitMessage: (payload) => { + handlersRef.current.forEach(h => { + h.onInitMessage?.(payload); + }); + }, + onNewMessage: (payload) => { + handlersRef.current.forEach(h => { + h.onNewMessage?.(payload); + }); + }, + }); + + useEffect(() => { + connect(); + return () => disconnect(); + }, [connect, disconnect]); + + const addHandlers = useCallback((handlers: ChatRoomListSseOptions) => { + handlersRef.current.add(handlers); + return () => { + handlersRef.current.delete(handlers); + }; + }, []); + + const value = useMemo(() => ({ addHandlers }), [addHandlers]); + + return {children}; +}; + +export const useSseSubscription = () => { + const ctx = useSafeContext(SseContext); + return ctx; +}; + +export default SSEProvider; \ No newline at end of file diff --git a/src/screens/group/groupDetail/GroupDetailScreen/index.tsx b/src/screens/group/groupDetail/GroupDetailScreen/index.tsx index 8ccac740..fdcb4e81 100644 --- a/src/screens/group/groupDetail/GroupDetailScreen/index.tsx +++ b/src/screens/group/groupDetail/GroupDetailScreen/index.tsx @@ -1,6 +1,5 @@ -import { useFocusEffect } from '@react-navigation/native'; import type { Day } from 'date-fns'; -import { useCallback, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { Panel } from '@/components'; import { GROUP_COLORS } from '@/constants/groupColors'; @@ -8,8 +7,9 @@ import { useCategoriesQuery } from '@/features/category/api/queries'; import { isDayInDayBelong } from '@/features/category/model/dayBelongHelper'; 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 { useSseSubscription } from '@/SSEProvider'; import { studyDataUtils } from '@/store/studySlice'; import GroupInfoPanel from '../components/GroupInfoPanel'; @@ -26,7 +26,7 @@ const GroupDetailScreen = () => { const targetChatRoomId = useMemo(() => String(params.chatRoomId), [params.chatRoomId]); - const { connect, disconnect } = useChatRoomListSse({ + useSseSubscription().addHandlers({ onInitMessage: (payload) => { const room = payload.rooms?.find(r => String(r.roomId) === targetChatRoomId); if (room?.unread != null) { @@ -43,15 +43,6 @@ const GroupDetailScreen = () => { }, }); - const manageSseConnection = useCallback(() => { - connect(); - return () => { - disconnect(); - }; - }, [connect, disconnect]); - - useFocusEffect(manageSseConnection); - // TODO: Fallback UI if (isPending || !data) return null; if (!groupColor) return null; From 4f9e24ba8a14b89d67b6bc57015fcb1aa01ec6e4 Mon Sep 17 00:00:00 2001 From: dioo1461 Date: Fri, 6 Feb 2026 19:44:18 +0900 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20=EA=B7=B8=EB=A3=B9=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=8B=9C,=20=EA=B3=B5=EA=B0=9C=20=EC=97=AC?= =?UTF-8?q?=EB=B6=80=EA=B0=80=20=EC=8B=A4=EC=A0=9C=EC=99=80=20=EB=B0=98?= =?UTF-8?q?=EB=8C=80=EB=A1=9C=20=EC=A0=81=EC=9A=A9=EB=90=98=EA=B3=A0=20?= =?UTF-8?q?=EC=9E=88=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/locales/ko/createGroup.json | 2 +- .../GroupsScreen/components/JoinedGroupPanel/index.tsx | 2 +- .../CreateGroupScreen/components/GroupPublicityInput.tsx | 6 +++--- src/screens/group/CreateGroupScreen/index.tsx | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) 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..0cdea5b5 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', }); @@ -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) => ( From baf1e3aa0a5a958e852ac8f8b8580233b50aadc9 Mon Sep 17 00:00:00 2001 From: dioo1461 Date: Fri, 6 Feb 2026 19:57:17 +0900 Subject: [PATCH 3/6] =?UTF-8?q?refactor:=20SSEProvider=EC=97=90=EC=84=9C?= =?UTF-8?q?=20chatCount=EB=A5=BC=20=EA=B4=80=EB=A6=AC=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EA=B5=AC=EC=A1=B0=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/SSEProvider.tsx | 73 ++++++++++--------- src/features/chat/sse/useChatCount.ts | 13 ++++ .../groupDetail/GroupDetailScreen/index.tsx | 25 +------ 3 files changed, 55 insertions(+), 56 deletions(-) create mode 100644 src/features/chat/sse/useChatCount.ts diff --git a/src/SSEProvider.tsx b/src/SSEProvider.tsx index 0c9e4397..39790f95 100644 --- a/src/SSEProvider.tsx +++ b/src/SSEProvider.tsx @@ -1,33 +1,52 @@ -import { - createContext, - type PropsWithChildren, - useCallback, - useEffect, - useMemo, - useRef, -} from 'react'; +import type { PropsWithChildren } from 'react'; +import { createContext, useCallback, useEffect, useMemo, useRef } from 'react'; import { useChatRoomListSse, useSafeContext } from './hooks'; -import type { ChatRoomListSseOptions } from './hooks/useChatRoomListSse'; -type SseContextValue = { - addHandlers: (handlers: ChatRoomListSseOptions) => () => void; +type SseStore = { + getCount: (roomId: string) => number; + setCount: (roomId: string, count: number) => void; + inc: (roomId: string) => void; + subscribe: (listener: () => void) => () => void; }; -const SseContext = createContext(null); -export const SSEProvider = ({ children }: PropsWithChildren) => { - const handlersRef = useRef(new Set()); +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]); const { connect, disconnect } = useChatRoomListSse({ onInitMessage: (payload) => { - handlersRef.current.forEach(h => { - h.onInitMessage?.(payload); + payload.rooms?.forEach(r => { + const count = Math.max(0, r.unread ?? 0); + store.setCount(r.roomId, count); }); }, onNewMessage: (payload) => { - handlersRef.current.forEach(h => { - h.onNewMessage?.(payload); - }); + store.inc(payload.roomId); }, }); @@ -36,21 +55,9 @@ export const SSEProvider = ({ children }: PropsWithChildren) => { return () => disconnect(); }, [connect, disconnect]); - const addHandlers = useCallback((handlers: ChatRoomListSseOptions) => { - handlersRef.current.add(handlers); - return () => { - handlersRef.current.delete(handlers); - }; - }, []); - - const value = useMemo(() => ({ addHandlers }), [addHandlers]); - - return {children}; + return {children}; }; -export const useSseSubscription = () => { - const ctx = useSafeContext(SseContext); - return ctx; -}; +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/screens/group/groupDetail/GroupDetailScreen/index.tsx b/src/screens/group/groupDetail/GroupDetailScreen/index.tsx index fdcb4e81..b14e1ce4 100644 --- a/src/screens/group/groupDetail/GroupDetailScreen/index.tsx +++ b/src/screens/group/groupDetail/GroupDetailScreen/index.tsx @@ -1,15 +1,14 @@ import type { Day } from 'date-fns'; -import { 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 { useStackRoute } from '@/hooks'; import { StackableScreenLayout } from '@/layout'; -import { useSseSubscription } from '@/SSEProvider'; import { studyDataUtils } from '@/store/studySlice'; import GroupInfoPanel from '../components/GroupInfoPanel'; @@ -21,27 +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]); - - useSseSubscription().addHandlers({ - 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 chatCount = useChatCount(params.chatRoomId); // TODO: Fallback UI if (isPending || !data) return null; From a2d293ab21da88e834b6d42634de88518753ab59 Mon Sep 17 00:00:00 2001 From: dioo1461 Date: Fri, 6 Feb 2026 20:00:10 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix(Windows):=20react-native-svg=20?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=EB=9F=AC=EB=A6=AC=EB=A5=BC=20?= =?UTF-8?q?=EC=97=85=EA=B7=B8=EB=A0=88=EC=9D=B4=EB=93=9C=ED=95=98=EC=97=AC?= =?UTF-8?q?=20Windows=EC=97=90=EC=84=9C=EC=9D=98=20=EB=B2=84=EB=B2=85?= =?UTF-8?q?=EC=9E=84=20=EB=AC=B8=EC=A0=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 8 ++++---- package.json | 2 +- src/hooks/useChatRoomListSse/index.ts | 1 - 3 files changed, 5 insertions(+), 6 deletions(-) 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/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); From 1631f097b60708b60d411addac1690c1164968ff Mon Sep 17 00:00:00 2001 From: dioo1461 Date: Fri, 6 Feb 2026 20:18:40 +0900 Subject: [PATCH 5/6] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/SSEProvider.tsx | 9 ++------- .../CreateGroupScreen/components/GroupPublicityInput.tsx | 4 ++-- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/SSEProvider.tsx b/src/SSEProvider.tsx index 39790f95..3ae7334b 100644 --- a/src/SSEProvider.tsx +++ b/src/SSEProvider.tsx @@ -38,7 +38,7 @@ const SSEProvider = ({ children }: PropsWithChildren) => { }, }), [emit]); - const { connect, disconnect } = useChatRoomListSse({ + useChatRoomListSse({ onInitMessage: (payload) => { payload.rooms?.forEach(r => { const count = Math.max(0, r.unread ?? 0); @@ -50,14 +50,9 @@ const SSEProvider = ({ children }: PropsWithChildren) => { }, }); - useEffect(() => { - connect(); - return () => disconnect(); - }, [connect, disconnect]); - return {children}; }; -export const useSSEContext = () =>useSafeContext(SseContext); +export const useSSEContext = () => useSafeContext(SseContext); export default SSEProvider; \ No newline at end of file diff --git a/src/screens/group/CreateGroupScreen/components/GroupPublicityInput.tsx b/src/screens/group/CreateGroupScreen/components/GroupPublicityInput.tsx index 0cdea5b5..59922ce1 100644 --- a/src/screens/group/CreateGroupScreen/components/GroupPublicityInput.tsx +++ b/src/screens/group/CreateGroupScreen/components/GroupPublicityInput.tsx @@ -18,7 +18,7 @@ const GroupPublicityInput = ({ formName, defaultValue }: GroupPublicityInputProp }); const { control } = useFormContext(); - const isPublic = useWatch({ + const isPrivate = useWatch({ control, name: formName, defaultValue, @@ -28,7 +28,7 @@ const GroupPublicityInput = ({ formName, defaultValue }: GroupPublicityInputProp ); From 2dd8ab56f25b56f7870d5759899416be38828083 Mon Sep 17 00:00:00 2001 From: dioo1461 Date: Fri, 6 Feb 2026 20:19:24 +0900 Subject: [PATCH 6/6] =?UTF-8?q?chore:=20tsc=20incremental=20=EC=98=B5?= =?UTF-8?q?=EC=85=98=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tsconfig.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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, } }