Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 10 additions & 7 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -15,13 +16,15 @@ const App = () => (
<NavigationContainer>
<AppProviders>
<SystemUIProvider>
<SplashRenderer>
<StudyDataHydrator>
<DogFootUnderlay>
<AuthGate />
</DogFootUnderlay>
</StudyDataHydrator>
</SplashRenderer>
<SSEProvider>
<SplashRenderer>
<StudyDataHydrator>
<DogFootUnderlay>
<AuthGate />
</DogFootUnderlay>
</StudyDataHydrator>
</SplashRenderer>
</SSEProvider>
</SystemUIProvider>
</AppProviders>
</NavigationContainer>
Expand Down
58 changes: 58 additions & 0 deletions src/SSEProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<SseStore | null>(null);

const SSEProvider = ({ children }: PropsWithChildren) => {
const mapRef = useRef(new Map<string, number>());
const listenersRef = useRef(new Set<() => void>());

const emit = useCallback(() => {
listenersRef.current.forEach(cb => {
cb();
});
}, []);

const store = useMemo<SseStore>(() => ({
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 <SseContext.Provider value={store}>{children}</SseContext.Provider>;
};

export const useSSEContext = () => useSafeContext(SseContext);

export default SSEProvider;
13 changes: 13 additions & 0 deletions src/features/chat/sse/useChatCount.ts
Original file line number Diff line number Diff line change
@@ -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),
);
};
1 change: 0 additions & 1 deletion src/hooks/useChatRoomListSse/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ export interface ChatRoomListSseOptions {
onReconnectScheduled?: (delayMs: number) => void;
}

// TODO: SSE 커넥션 연결에 지연 시간이 생각보다 길어, UX 개선 필요. 앱 포그라운드 상태에만 하나의 커넥션을 여는 것을 목표로 구현하기
export const useChatRoomListSse = (options: ChatRoomListSseOptions = {}) => {
const [status, setStatus] = useState<ConnectionStatus>('idle');
const [connectionError, setConnectionError] = useState<string | null>(null);
Expand Down
2 changes: 1 addition & 1 deletion src/locales/ko/createGroup.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "그룹 이미지",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]}
Comment on lines 56 to 59

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use isOpen instead of inverting isApprovalRequired

GetGroupsResponse exposes both isOpen and isApprovalRequired as independent fields (see src/features/groups/model/groups.ts), so treating isPublic as !isApprovalRequired can be wrong when a group is publicly searchable but still requires approval. This also makes the list view disagree with the detail view, which still uses params.isOpen. In those cases, groups will be labeled private in the list even though they are open. Prefer using group.isOpen (or whatever the API contract defines) for public/private status.

Useful? React with 👍 / 👎.

totalDailyGoalCount={10}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
Expand All @@ -18,7 +18,7 @@ const GroupPublicityInput = ({ formName, defaultValue }: GroupPublicityInputProp
});
const { control } = useFormContext();

const isPublic = useWatch({
const isPrivate = useWatch({
control,
name: formName,
defaultValue,
Expand All @@ -28,8 +28,8 @@ const GroupPublicityInput = ({ formName, defaultValue }: GroupPublicityInputProp
<Form.CheckBox
defaultValue={defaultValue}
formName={formName}
hint={isPublic ? tPublicityDescriptionPublic : tPublicityDescriptionPrivate}
label={tPublicityLabel}
hint={!isPrivate ? tPublicityDescriptionPublic : tPublicityDescriptionPrivate}
label={tMakePublicityPrivate}
/>
);
};
Expand Down
2 changes: 1 addition & 1 deletion src/screens/group/CreateGroupScreen/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const CreateGroupScreen = () => {
<GroupNameInput defaultValue='' formName='name' />
<GroupTagInput defaultValue='' formName='tag' />
<GroupMaxMemberInput defaultValue={20} formName='maxMember' />
<GroupPublicityInput defaultValue={true} formName='isApprovalRequired' />
<GroupPublicityInput defaultValue={false} formName='isApprovalRequired' />
<Form.SubmitButton onSubmit={onSubmit}>
{(submit, disabled) => (
<Button.Basic disabled={disabled} onPress={submit}>
Expand Down
36 changes: 3 additions & 33 deletions src/screens/group/groupDetail/GroupDetailScreen/index.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<number | null>(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;
Expand Down
3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"@store/*": ["src/store/*"],
},
"resolveJsonModule": true,
"esModuleInterop": true
"esModuleInterop": true,
"incremental": true,
}
}