diff --git a/api/notices.ts b/api/notices.ts new file mode 100644 index 0000000..ac1599a --- /dev/null +++ b/api/notices.ts @@ -0,0 +1,134 @@ +import { + ApiError, + authenticatedApiRequest, + type ApiRequestOptions, + type ClerkTokenGetter, +} from '@/api/api-client'; + +const NOTICE_MAX_ATTEMPTS = 3; +const NOTICE_RETRY_DELAY_MS = 500; + +export interface NoticeListItemResponse { + id: number; + title: string; + isPinned: boolean; + createdAt: string; +} + +export interface NoticeCursorPageResponse { + items: NoticeListItemResponse[]; + nextCursor: string | null; + hasNext: boolean; +} + +export interface NoticeDetailResponse { + id: number; + title: string; + content: string; + isPinned: boolean; + createdAt: string; + updatedAt: string; +} + +interface FetchNoticesOptions extends Pick { + cursor?: string | null; + size?: number; +} + +type NoticeRequestOptions = Pick; + +export function fetchNotices( + getToken: ClerkTokenGetter, + { cursor, size = 20, signal }: FetchNoticesOptions = {}, +) { + const params = new URLSearchParams({ size: String(size) }); + const abortSignal = signal ?? undefined; + + if (cursor) { + params.set('cursor', cursor); + } + + return requestWithRetry(() => + authenticatedApiRequest( + getToken, + `/api/v1/notices?${params.toString()}`, + { method: 'GET', signal: abortSignal }, + ), + abortSignal, + ); +} + +export function fetchNoticeDetail( + getToken: ClerkTokenGetter, + noticeId: number, + options: NoticeRequestOptions = {}, +) { + const signal = options.signal ?? undefined; + + return requestWithRetry(() => + authenticatedApiRequest( + getToken, + `/api/v1/notices/${encodeURIComponent(String(noticeId))}`, + { method: 'GET', ...options, signal }, + ), + signal, + ); +} + +async function requestWithRetry( + request: () => Promise, + signal?: AbortSignal, +): Promise { + let lastError: unknown; + + for (let attempt = 1; attempt <= NOTICE_MAX_ATTEMPTS; attempt += 1) { + try { + return await request(); + } catch (error) { + lastError = error; + + if (signal?.aborted || !shouldRetryNoticeRequest(error) || attempt === NOTICE_MAX_ATTEMPTS) { + throw error; + } + + await delay(NOTICE_RETRY_DELAY_MS * attempt, signal); + } + } + + throw lastError; +} + +function shouldRetryNoticeRequest(error: unknown) { + if (isAbortError(error)) { + return false; + } + + if (error instanceof ApiError) { + return error.status === 408 || error.status === 429 || error.status >= 500; + } + + return error instanceof TypeError; +} + +function delay(ms: number, signal?: AbortSignal) { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(resolve, ms); + + if (!signal) { + return; + } + + signal.addEventListener( + 'abort', + () => { + clearTimeout(timeoutId); + reject(new DOMException('Aborted', 'AbortError')); + }, + { once: true }, + ); + }); +} + +function isAbortError(error: unknown) { + return typeof error === 'object' && error !== null && 'name' in error && error.name === 'AbortError'; +} diff --git a/app/(tabs)/(home)/notice-detail.tsx b/app/(tabs)/(home)/notice-detail.tsx new file mode 100644 index 0000000..0d59970 --- /dev/null +++ b/app/(tabs)/(home)/notice-detail.tsx @@ -0,0 +1,268 @@ +import { useAuth } from '@clerk/expo'; +import { router, useLocalSearchParams } from 'expo-router'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ActivityIndicator, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +import { fetchNoticeDetail, type NoticeDetailResponse } from '@/api/notices'; +import { AppIcon } from '@/components/ui/app-icon'; +import { Button } from '@/components/ui/button'; +import { Colors, Typography } from '@/constants/theme'; + +type NoticeDetailState = + | { status: 'loading'; data: null; message: null } + | { status: 'success'; data: NoticeDetailResponse; message: null } + | { status: 'error'; data: null; message: string }; + +export default function NoticeDetailScreen() { + const { id } = useLocalSearchParams<{ id?: string | string[] }>(); + const { getToken, isLoaded, isSignedIn } = useAuth(); + const noticeId = useMemo(() => parseNoticeId(id), [id]); + const [state, setState] = useState({ + status: 'loading', + data: null, + message: null, + }); + const getTokenRef = useRef(getToken); + const retryControllerRef = useRef(null); + + useEffect(() => { + getTokenRef.current = getToken; + }, [getToken]); + + const loadNotice = useCallback( + async (signal?: AbortSignal) => { + if (!isLoaded) { + return; + } + + if (!isSignedIn) { + setState({ + status: 'error', + data: null, + message: '공지사항을 보려면 로그인이 필요합니다.', + }); + return; + } + + if (noticeId == null) { + setState({ + status: 'error', + data: null, + message: '공지사항 정보를 확인할 수 없습니다.', + }); + return; + } + + setState({ status: 'loading', data: null, message: null }); + + try { + const data = await fetchNoticeDetail(() => getTokenRef.current(), noticeId, { signal }); + setState({ status: 'success', data, message: null }); + } catch (error) { + if (isAbortError(error)) { + return; + } + + setState({ + status: 'error', + data: null, + message: error instanceof Error ? error.message : '공지사항을 불러오지 못했습니다.', + }); + } + }, + [isLoaded, isSignedIn, noticeId], + ); + + useEffect(() => { + const controller = new AbortController(); + + void loadNotice(controller.signal); + + return () => { + controller.abort(); + retryControllerRef.current?.abort(); + retryControllerRef.current = null; + }; + }, [loadNotice]); + + const handleRetry = useCallback(() => { + retryControllerRef.current?.abort(); + + const controller = new AbortController(); + retryControllerRef.current = controller; + + void loadNotice(controller.signal).finally(() => { + if (retryControllerRef.current === controller) { + retryControllerRef.current = null; + } + }); + }, [loadNotice]); + + const title = state.data?.title ?? '공지사항'; + + return ( + + + router.back()} /> + + {title} + + + + + {state.status === 'loading' && ( + + + 공지사항을 불러오는 중입니다. + + )} + + {state.status === 'error' && ( + + 공지사항을 불러올 수 없습니다. + {state.message} +