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,
}
}