diff --git a/etc/servers/work-server/src/routes/chat.ts b/etc/servers/work-server/src/routes/chat.ts index efede81..08a5ddb 100644 --- a/etc/servers/work-server/src/routes/chat.ts +++ b/etc/servers/work-server/src/routes/chat.ts @@ -1279,6 +1279,8 @@ async function buildChatShareSnapshot( : await listChatConversationActivityLogsByRequestIds(normalizedSessionId, scopeRequestIds); const promptTarget = tokenPayload.kind === 'prompt' ? resolvePromptTarget(scopedMessages, tokenPayload) : null; const roomRequestCounts = useInitialManagedShareRoomView ? undefined : buildRoomRequestCounts(requests, messages); + const oldestLoadedMessageId = detailPage?.oldestLoadedMessageId ?? null; + const hasOlderMessages = detailPage?.hasOlderMessages ?? false; if (tokenPayload.kind === 'prompt' && !promptTarget) { return null; @@ -1293,6 +1295,8 @@ async function buildChatShareSnapshot( messages: scopedMessages, activityLogs, roomRequestCounts, + oldestLoadedMessageId, + hasOlderMessages, promptTarget, detailLevel, } satisfies { @@ -1307,6 +1311,8 @@ async function buildChatShareSnapshot( processingCount: number; unansweredCount: number; } | undefined; + oldestLoadedMessageId: number | null; + hasOlderMessages: boolean; promptTarget: | { sourceMessageId: number; @@ -2580,6 +2586,8 @@ export async function registerChatRoutes(app: FastifyInstance) { messages: shareSnapshot.messages, activityLogs: shareSnapshot.activityLogs, roomRequestCounts: shareSnapshot.roomRequestCounts, + oldestLoadedMessageId: shareSnapshot.oldestLoadedMessageId, + hasOlderMessages: shareSnapshot.hasOlderMessages, rooms: resolvedRoomContext.rooms, activeSessionId: activeRoom.sessionId, promptTarget: shareSnapshot.promptTarget, @@ -2588,6 +2596,72 @@ export async function registerChatRoutes(app: FastifyInstance) { }; }); + app.get(`${CHAT_SHARE_ROUTE_PREFIX}/:token/conversations/:sessionId`, async (request, reply) => { + const params = z.object({ + token: z.string().trim().min(1).max(16000), + sessionId: z.string().trim().min(1).max(120), + }).parse(request.params ?? {}); + const query = z.object({ + limit: z.coerce.number().int().min(1).max(200).optional(), + beforeMessageId: z.coerce.number().int().positive().optional(), + }).parse(request.query ?? {}); + const managedContext = await resolveManagedChatShareContext(params.token); + const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token); + + if (!tokenPayload) { + return reply.code(404).send({ + message: '공유 링크가 유효하지 않습니다.', + }); + } + + const unavailableMessage = resolveManagedShareUnavailableMessage(managedContext.managedResource); + + if (unavailableMessage) { + return reply.code(403).send({ + message: unavailableMessage, + }); + } + + if (!(await ensureManagedShareAccessPin(request, reply, managedContext.sharePath))) { + return; + } + + const resolvedRoomContext = await resolveActiveManagedShareRoom({ + managedResource: managedContext.managedResource, + tokenPayload, + requestedSessionId: params.sessionId, + }); + + if (!resolvedRoomContext.requestedRoomMatched || !resolvedRoomContext.activeRoom) { + return reply.code(403).send({ + message: '이 공유 링크에서 접근할 수 없는 채팅방입니다.', + }); + } + + const item = await getChatConversation(resolvedRoomContext.activeRoom.sessionId, null); + + if (!item) { + return reply.code(404).send({ + message: '채팅방을 찾을 수 없습니다.', + }); + } + + const detailPage = await listChatConversationDetailPage(resolvedRoomContext.activeRoom.sessionId, { + limit: query.limit ?? 40, + beforeMessageId: query.beforeMessageId ?? null, + }); + + return { + ok: true, + item, + messages: detailPage.messages, + requests: detailPage.requests, + activityLogs: detailPage.activityLogs, + oldestLoadedMessageId: detailPage.oldestLoadedMessageId, + hasOlderMessages: detailPage.hasOlderMessages, + }; + }); + app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/clear`, async (request, reply) => { const params = z.object({ token: z.string().trim().min(1).max(16000), diff --git a/src/app/main/SharedAppSettingsPage.css b/src/app/main/SharedAppSettingsPage.css index 9166686..f0ead54 100644 --- a/src/app/main/SharedAppSettingsPage.css +++ b/src/app/main/SharedAppSettingsPage.css @@ -1,10 +1,17 @@ .shared-app-settings-page { display: flex; + flex: 1 1 auto; flex-direction: column; gap: 16px; - padding: 16px; - min-height: 100%; - background: #f7f8fb; + width: 100%; + height: 100%; + min-height: 0; + padding: 16px 18px 20px; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + background: #f8fafc; } .shared-app-settings-page--loading { @@ -12,18 +19,128 @@ justify-content: center; } -.shared-app-settings-page__grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 16px; -} - -.shared-app-settings-page .ant-card { - border-radius: 16px; -} - -.shared-app-settings-page .ant-card-body { +.shared-app-settings-page__header { display: flex; - flex-direction: column; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.shared-app-settings-page__header .ant-typography { + margin-bottom: 0; +} + +.shared-app-settings-page__header-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.shared-app-settings-page__tabs .ant-tabs-nav, +.shared-app-settings-page__subtabs .ant-tabs-nav { + margin-bottom: 12px; +} + +.shared-app-settings-page__panel { + display: grid; + gap: 14px; +} + +.shared-app-settings-page__section { + display: grid; + gap: 12px; + padding: 14px 16px; + border: 1px solid rgba(226, 232, 240, 0.92); + border-radius: 16px; + background: #fff; +} + +.shared-app-settings-page__section-head { + display: grid; gap: 4px; } + +.shared-app-settings-page__field-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 8px 12px; +} + +.shared-app-settings-page__field-grid .ant-form-item, +.shared-app-settings-page__check-grid .ant-form-item { + margin-bottom: 0; +} + +.shared-app-settings-page__check-grid { + display: grid; + gap: 10px; +} + +.shared-app-settings-page__status-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 10px; +} + +.shared-app-settings-page__status-item { + display: grid; + gap: 4px; + padding: 12px 14px; + border-radius: 12px; + background: #f8fafc; + border: 1px solid rgba(226, 232, 240, 0.92); +} + +.shared-app-settings-page__status-item span { + font-size: 12px; + color: #64748b; +} + +.shared-app-settings-page__status-item strong { + font-size: 14px; + color: #0f172a; +} + +.shared-app-settings-page__inline-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.shared-app-settings-page__storage-list { + display: grid; + gap: 12px; +} + +.shared-app-settings-page__storage-item { + display: grid; + gap: 10px; + padding: 12px 14px; + border-radius: 14px; + border: 1px solid rgba(226, 232, 240, 0.92); + background: #f8fafc; +} + +.shared-app-settings-page__storage-head { + display: flex; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; +} + +.shared-app-settings-page__storage-head > div { + display: grid; + gap: 4px; +} + +@media (max-width: 767px) { + .shared-app-settings-page { + padding: 14px; + } + + .shared-app-settings-page__section, + .shared-app-settings-page__storage-item { + padding: 12px; + } +} diff --git a/src/app/main/SharedAppSettingsPage.tsx b/src/app/main/SharedAppSettingsPage.tsx index 0dd7dd4..394a88b 100644 --- a/src/app/main/SharedAppSettingsPage.tsx +++ b/src/app/main/SharedAppSettingsPage.tsx @@ -1,28 +1,323 @@ import { ReloadOutlined, SaveOutlined } from '@ant-design/icons'; -import { Alert, App, Button, Card, Checkbox, Flex, Form, Input, InputNumber, Select, Space, Spin, Typography } from 'antd'; -import { useCallback, useEffect, useState } from 'react'; +import { Alert, App, Button, Checkbox, Form, Input, InputNumber, Spin, Tabs, Typography } from 'antd'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { fetchWebPushConfig } from './notificationApi'; +import { NOTIFICATION_DEVICE_ID_STORAGE_KEY, getSavedNotificationDeviceId } from './notificationIdentity'; import { DEFAULT_APP_CONFIG, - getWeeklyScheduleOptions, + APP_CONFIG_STORAGE_KEY, saveAppConfigToServer, type AppConfig, - type PlanCostTimeUnit, } from './appConfig'; +import { + TOKEN_ACCESS_STORAGE_KEY, +} from './tokenAccess'; +import { + clearWebPushSubscriptionRegistration, + ensureWebPushSubscriptionRegistered, + syncExistingWebPushSubscriptionRegistration, +} from './webPushRegistration'; +import { isPreviewRuntime } from './previewRuntime'; import './SharedAppSettingsPage.css'; const { Paragraph, Text, Title } = Typography; -const PLAN_COST_TIME_UNIT_OPTIONS: Array<{ value: PlanCostTimeUnit; label: string }> = [ - { value: 'hour', label: '시간' }, - { value: 'minute', label: '분' }, - { value: 'second', label: '초' }, -]; +const WEB_PUSH_METADATA_STORAGE_KEY = 'work-server.web-push.registration-meta.v1'; +const SHARE_IMMEDIATE_SEND_PINNED_STORAGE_KEY = 'codex-live-share-immediate-send-pinned-by-token'; +const SHARE_LAST_ROOM_STORAGE_KEY = 'codex-live-share-last-room-by-token'; +const SHARE_APP_LAUNCH_USAGE_STORAGE_KEY = 'chat-share-page:app-launch-usage:v1'; +const CHAT_SHARE_ACCESS_PIN_STORAGE_KEY = 'main-chat-panel:share-access-pins'; +const CHAT_CONTEXT_CONFIRM_SUPPRESSION_STORAGE_KEY = 'codex-live-context-confirm-suppressed-by-session'; +const PREVIEW_RUNTIME_TOKEN_STORAGE_KEY = 'work-app.preview-runtime.registered-token'; type SharedAppSettingsPageProps = { shareToken: string; }; type SharedAppSettingsFormValue = AppConfig; +type ClientNotificationPermissionState = 'granted' | 'denied' | 'default' | 'unsupported'; +type StorageScope = 'local' | 'session'; + +type StorageEntry = { + id: string; + label: string; + description: string; + scope: StorageScope; + storageKey: string; +}; + +type StorageGroup = { + key: string; + label: string; + description: string; + entries: StorageEntry[]; +}; + +const STORAGE_GROUPS: StorageGroup[] = [ + { + key: 'share-chat', + label: '공유채팅', + description: '공유채팅 화면에서 쓰는 클라이언트 저장값입니다.', + entries: [ + { + id: 'share-last-room', + label: '마지막 채팅방', + description: '토큰별 마지막으로 열었던 채팅방 세션을 기억합니다.', + scope: 'local', + storageKey: SHARE_LAST_ROOM_STORAGE_KEY, + }, + { + id: 'share-immediate-send', + label: '즉시전송 고정', + description: '토큰별 즉시전송 고정 상태를 저장합니다.', + scope: 'local', + storageKey: SHARE_IMMEDIATE_SEND_PINNED_STORAGE_KEY, + }, + { + id: 'share-app-usage', + label: '앱 실행 우선순위', + description: '공유채팅 안에서 자주 실행한 앱 사용량을 기록합니다.', + scope: 'local', + storageKey: SHARE_APP_LAUNCH_USAGE_STORAGE_KEY, + }, + { + id: 'share-access-pin', + label: '공유 비밀번호 캐시', + description: '방별 접근 비밀번호 입력 이력을 저장합니다.', + scope: 'local', + storageKey: CHAT_SHARE_ACCESS_PIN_STORAGE_KEY, + }, + { + id: 'context-confirm', + label: '문맥 확인 숨김', + description: '세션별 문맥 확인 안내를 다시 보지 않도록 저장합니다.', + scope: 'local', + storageKey: CHAT_CONTEXT_CONFIRM_SUPPRESSION_STORAGE_KEY, + }, + ], + }, + { + key: 'notifications', + label: '알림 / PWA', + description: '웹 푸시와 기기 등록에 쓰는 저장값입니다.', + entries: [ + { + id: 'notification-device-id', + label: '알림 기기 ID', + description: '현재 브라우저를 식별하는 웹 푸시 기기 ID입니다.', + scope: 'local', + storageKey: NOTIFICATION_DEVICE_ID_STORAGE_KEY, + }, + { + id: 'web-push-meta', + label: '웹 푸시 등록 메타', + description: '최근 웹 푸시 등록 상태와 메타데이터를 저장합니다.', + scope: 'local', + storageKey: WEB_PUSH_METADATA_STORAGE_KEY, + }, + ], + }, + { + key: 'access', + label: '접근 토큰', + description: '앱 접근과 등록 토큰에 쓰는 저장값입니다.', + entries: [ + { + id: 'registered-token', + label: '등록 토큰', + description: '앱 접근 허용 토큰을 로컬에 저장합니다.', + scope: 'local', + storageKey: TOKEN_ACCESS_STORAGE_KEY, + }, + { + id: 'preview-token', + label: '미리보기 토큰', + description: 'preview 런타임 등록 토큰을 저장합니다.', + scope: 'local', + storageKey: PREVIEW_RUNTIME_TOKEN_STORAGE_KEY, + }, + { + id: 'app-config', + label: '앱 설정 캐시', + description: '서버에서 받은 앱 설정의 마지막 캐시입니다.', + scope: 'local', + storageKey: APP_CONFIG_STORAGE_KEY, + }, + ], + }, +]; + +function getClientNotificationPermission(): ClientNotificationPermissionState { + if ( + typeof window === 'undefined' + || typeof Notification === 'undefined' + || typeof navigator === 'undefined' + || !('serviceWorker' in navigator) + || !('PushManager' in window) + ) { + return 'unsupported'; + } + + if (Notification.permission === 'granted') { + return 'granted'; + } + + if (Notification.permission === 'denied') { + return 'denied'; + } + + return 'default'; +} + +function getClientNotificationPermissionLabel(permission: ClientNotificationPermissionState) { + switch (permission) { + case 'granted': + return '허용'; + case 'denied': + return '차단'; + case 'unsupported': + return '미지원'; + default: + return '확인 필요'; + } +} + +function hasSecureOrigin() { + if (typeof window === 'undefined') { + return false; + } + + return window.isSecureContext || window.location.hostname === 'localhost'; +} + +function isStandaloneDisplayMode() { + if (typeof window === 'undefined') { + return false; + } + + return ( + window.matchMedia?.('(display-mode: standalone)').matches === true || + (window.navigator as Navigator & { standalone?: boolean }).standalone === true + ); +} + +function isAppleMobileDevice() { + if (typeof navigator === 'undefined') { + return false; + } + + return /iPhone|iPad|iPod/i.test(navigator.userAgent); +} + +async function getPushServiceWorkerRegistration() { + if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) { + return null; + } + + const resolveUsableRegistration = async (registration: ServiceWorkerRegistration | null | undefined) => { + if (!registration) { + return null; + } + + if (registration.active || registration.waiting) { + return registration; + } + + const installingWorker = registration.installing; + + if (!installingWorker) { + return registration; + } + + await new Promise((resolve) => { + let completed = false; + + const finish = () => { + if (completed) { + return; + } + + completed = true; + window.clearTimeout(timeoutId); + installingWorker.removeEventListener('statechange', onStateChange); + resolve(); + }; + + const onStateChange = () => { + if ( + installingWorker.state === 'activated' + || installingWorker.state === 'installed' + || Boolean(registration.active) + || Boolean(registration.waiting) + ) { + finish(); + } + }; + + const timeoutId = window.setTimeout(finish, 5000); + installingWorker.addEventListener('statechange', onStateChange); + onStateChange(); + }); + + return registration; + }; + + const existing = await navigator.serviceWorker.getRegistration(); + if (existing) { + return (await resolveUsableRegistration(existing)) ?? existing; + } + + const scoped = await navigator.serviceWorker.getRegistration('/'); + if (scoped) { + return (await resolveUsableRegistration(scoped)) ?? scoped; + } + + const serviceWorkerUrl = import.meta.env.DEV ? '/dev-sw.js?dev-sw' : '/sw.js'; + const resolvedServiceWorkerUrl = + typeof window !== 'undefined' ? new URL(serviceWorkerUrl, window.location.href).toString() : serviceWorkerUrl; + + try { + const registration = import.meta.env.DEV + ? await navigator.serviceWorker.register(resolvedServiceWorkerUrl, { type: 'module', scope: '/', updateViaCache: 'none' }) + .catch(() => navigator.serviceWorker.register(resolvedServiceWorkerUrl, { scope: '/', updateViaCache: 'none' })) + : await navigator.serviceWorker.register(resolvedServiceWorkerUrl); + return (await resolveUsableRegistration(registration)) ?? registration; + } catch { + return null; + } +} + +function readStorageValue(scope: StorageScope, key: string) { + if (typeof window === 'undefined') { + return ''; + } + + try { + const storage = scope === 'local' ? window.localStorage : window.sessionStorage; + return storage.getItem(key) ?? ''; + } catch { + return ''; + } +} + +function writeStorageValue(scope: StorageScope, key: string, value: string) { + if (typeof window === 'undefined') { + return; + } + + const storage = scope === 'local' ? window.localStorage : window.sessionStorage; + + if (!value.trim()) { + storage.removeItem(key); + return; + } + + storage.setItem(key, value); +} + +function buildStorageItemKey(entry: StorageEntry) { + return `${entry.scope}:${entry.storageKey}`; +} export function SharedAppSettingsPage({ shareToken }: SharedAppSettingsPageProps) { const { message } = App.useApp(); @@ -31,6 +326,64 @@ export function SharedAppSettingsPage({ shareToken }: SharedAppSettingsPageProps const [isSaving, setIsSaving] = useState(false); const [errorMessage, setErrorMessage] = useState(''); const [savedConfig, setSavedConfig] = useState(DEFAULT_APP_CONFIG); + const [activeTabKey, setActiveTabKey] = useState('general'); + const [storageGroupKey, setStorageGroupKey] = useState(STORAGE_GROUPS[0]?.key ?? 'share-chat'); + const [storageDrafts, setStorageDrafts] = useState>({}); + const [storageFeedback, setStorageFeedback] = useState(''); + const [webPushConfigured, setWebPushConfigured] = useState(false); + const [webPushRegistered, setWebPushRegistered] = useState(false); + const [webPushLoading, setWebPushLoading] = useState(false); + const [clientPermission, setClientPermission] = useState(() => getClientNotificationPermission()); + + const selectedStorageGroup = useMemo( + () => STORAGE_GROUPS.find((group) => group.key === storageGroupKey) ?? STORAGE_GROUPS[0], + [storageGroupKey], + ); + + const loadStorageDrafts = useCallback(() => { + const nextDrafts = STORAGE_GROUPS.flatMap((group) => group.entries).reduce>((accumulator, entry) => { + accumulator[buildStorageItemKey(entry)] = readStorageValue(entry.scope, entry.storageKey); + return accumulator; + }, {}); + setStorageDrafts(nextDrafts); + }, []); + + const syncWebPushStatus = useCallback(async () => { + setClientPermission(getClientNotificationPermission()); + + try { + const config = await fetchWebPushConfig(); + const isConfigured = Boolean(config.enabled && config.publicKey); + setWebPushConfigured(isConfigured); + + if (!isConfigured) { + setWebPushRegistered(false); + return; + } + + const registration = await getPushServiceWorkerRegistration(); + + if (!registration || typeof Notification === 'undefined' || Notification.permission !== 'granted') { + setWebPushRegistered(false); + return; + } + + const subscription = await registration.pushManager.getSubscription(); + + if (!subscription) { + setWebPushRegistered(false); + return; + } + + await syncExistingWebPushSubscriptionRegistration(registration, { + deviceId: getSavedNotificationDeviceId(), + }); + setWebPushRegistered(true); + } catch { + setWebPushConfigured(false); + setWebPushRegistered(false); + } + }, []); const loadConfig = useCallback(async () => { setIsLoading(true); @@ -53,12 +406,14 @@ export function SharedAppSettingsPage({ shareToken }: SharedAppSettingsPageProps const nextConfig = payload.config ?? DEFAULT_APP_CONFIG; setSavedConfig(nextConfig); form.setFieldsValue(nextConfig); + await syncWebPushStatus(); + loadStorageDrafts(); } catch (error) { setErrorMessage(error instanceof Error ? error.message : '앱 설정을 불러오지 못했습니다.'); } finally { setIsLoading(false); } - }, [form, shareToken]); + }, [form, loadStorageDrafts, shareToken, syncWebPushStatus]); useEffect(() => { void loadConfig(); @@ -76,7 +431,7 @@ export function SharedAppSettingsPage({ shareToken }: SharedAppSettingsPageProps }); setSavedConfig(saved); form.setFieldsValue(saved); - message.success('앱 설정을 저장했습니다.'); + message.success('개인설정을 저장했습니다.'); } catch (error) { setErrorMessage(error instanceof Error ? error.message : '앱 설정 저장에 실패했습니다.'); } finally { @@ -86,6 +441,145 @@ export function SharedAppSettingsPage({ shareToken }: SharedAppSettingsPageProps [form, message, shareToken], ); + const handleRequestNotificationPermission = useCallback(async () => { + if (typeof Notification === 'undefined') { + message.warning('현재 브라우저에서는 알림 권한 요청을 지원하지 않습니다.'); + return; + } + + if (!hasSecureOrigin()) { + message.warning('알림은 HTTPS 또는 localhost 환경에서만 권한 요청이 가능합니다.'); + return; + } + + if (isAppleMobileDevice() && !isStandaloneDisplayMode()) { + message.warning('아이폰에서는 홈 화면에 추가한 PWA에서만 웹 푸시를 허용할 수 있습니다.'); + return; + } + + try { + await Notification.requestPermission(); + await syncWebPushStatus(); + message.success('브라우저 알림 권한 상태를 다시 확인했습니다.'); + } catch { + message.error('알림 권한 요청 중 오류가 발생했습니다.'); + } + }, [message, syncWebPushStatus]); + + const handleRegisterWebPush = useCallback(async () => { + if (isPreviewRuntime()) { + message.warning('미리보기 런타임에서는 웹 푸시 등록을 지원하지 않습니다.'); + return; + } + + if (!hasSecureOrigin()) { + message.warning('웹 푸시는 HTTPS 또는 localhost 환경에서만 등록할 수 있습니다.'); + return; + } + + if (isAppleMobileDevice() && !isStandaloneDisplayMode()) { + message.warning('아이폰에서는 홈 화면에 추가한 PWA로 연 뒤 등록해 주세요.'); + return; + } + + setWebPushLoading(true); + + try { + let permission = getClientNotificationPermission(); + + if (permission !== 'granted' && typeof Notification !== 'undefined') { + await Notification.requestPermission(); + permission = getClientNotificationPermission(); + } + + if (permission !== 'granted') { + throw new Error('브라우저 알림 권한을 허용한 뒤 다시 시도해 주세요.'); + } + + const registration = await getPushServiceWorkerRegistration(); + + if (!registration) { + throw new Error('서비스워커 준비가 아직 끝나지 않았습니다. 잠시 후 다시 시도해 주세요.'); + } + + await ensureWebPushSubscriptionRegistered(registration, { + deviceId: getSavedNotificationDeviceId(), + }); + await syncWebPushStatus(); + loadStorageDrafts(); + message.success('이 기기의 웹 푸시 등록을 완료했습니다.'); + } catch (error) { + message.error(error instanceof Error ? error.message : '웹 푸시 등록에 실패했습니다.'); + } finally { + setWebPushLoading(false); + } + }, [loadStorageDrafts, message, syncWebPushStatus]); + + const handleUnregisterWebPush = useCallback(async () => { + setWebPushLoading(true); + + try { + const registration = await getPushServiceWorkerRegistration(); + + if (!registration) { + throw new Error('해제할 서비스워커 등록을 찾지 못했습니다.'); + } + + await clearWebPushSubscriptionRegistration(registration); + await syncWebPushStatus(); + loadStorageDrafts(); + message.success('이 기기의 웹 푸시 등록을 해제했습니다.'); + } catch (error) { + message.error(error instanceof Error ? error.message : '웹 푸시 해제에 실패했습니다.'); + } finally { + setWebPushLoading(false); + } + }, [loadStorageDrafts, message, syncWebPushStatus]); + + const handleStorageDraftChange = useCallback((entry: StorageEntry, value: string) => { + setStorageDrafts((current) => ({ + ...current, + [buildStorageItemKey(entry)]: value, + })); + }, []); + + const handleReloadStorageEntry = useCallback((entry: StorageEntry) => { + setStorageFeedback(''); + setStorageDrafts((current) => ({ + ...current, + [buildStorageItemKey(entry)]: readStorageValue(entry.scope, entry.storageKey), + })); + }, []); + + const handleSaveStorageEntry = useCallback((entry: StorageEntry) => { + try { + const draftValue = storageDrafts[buildStorageItemKey(entry)] ?? ''; + writeStorageValue(entry.scope, entry.storageKey, draftValue); + setStorageFeedback(`${entry.label} 저장값을 반영했습니다.`); + message.success(`${entry.label} 저장값을 반영했습니다.`); + } catch (error) { + const nextMessage = error instanceof Error ? error.message : `${entry.label} 저장값을 반영하지 못했습니다.`; + setStorageFeedback(nextMessage); + message.error(nextMessage); + } + }, [message, storageDrafts]); + + const handleClearStorageEntry = useCallback((entry: StorageEntry) => { + try { + writeStorageValue(entry.scope, entry.storageKey, ''); + setStorageDrafts((current) => ({ + ...current, + [buildStorageItemKey(entry)]: '', + })); + setStorageFeedback(`${entry.label} 저장값을 비웠습니다.`); + message.success(`${entry.label} 저장값을 비웠습니다.`); + } catch (error) { + const nextMessage = error instanceof Error ? error.message : `${entry.label} 저장값을 비우지 못했습니다.`; + setStorageFeedback(nextMessage); + message.error(nextMessage); + } + }, [message]); + if (isLoading) { return (
@@ -96,22 +590,22 @@ export function SharedAppSettingsPage({ shareToken }: SharedAppSettingsPageProps return (
- +
- 앱 설정 + 개인설정 - 공유 링크에서 허용된 핵심 앱 설정만 바로 수정합니다. + 공유채팅 안에서 개인 단위 앱 설정, 웹 푸시 등록, 클라이언트 저장값을 한 번에 관리합니다.
- - - - +
+
{errorMessage ? : null} @@ -121,128 +615,194 @@ export function SharedAppSettingsPage({ shareToken }: SharedAppSettingsPageProps initialValues={savedConfig} onFinish={(values) => void handleSave(values)} > -
- - - - - - - - - - - - - - - - - - 채팅방 알림 수신 - - + +
+
+ 채팅 기본값 + 공유채팅과 Codex Live 동작에 직접 연결되는 최소 설정만 다룹니다. +
+
+ + + + + + + + + + + + + + + + + + +
+
- - - 자동 새로고침 사용 - - - - - - - - - - - +
+
+ 알림 유도 문구 + 앱 전체 웹 푸시 등록 흐름에서 PWA, 권한, 등록 안내를 어떻게 유도할지 정합니다. +
+
+ + 앱 전체 채팅방 알림 수신 사용 + + + PWA 설치 유도 사용 + + + 브라우저 권한 유도 사용 + + + 웹 푸시 등록 유도 사용 + +
+
+
+ ), + }, + { + key: 'web-push', + label: '웹 푸시', + children: ( +
+
+
+ 현재 기기 등록 상태 + 권한, PWA 실행 여부, 서버 설정, 현재 기기 등록 여부를 바로 확인합니다. +
+
+
+ 권한 + {getClientNotificationPermissionLabel(clientPermission)} +
+
+ PWA + {isStandaloneDisplayMode() ? '실행 중' : '브라우저 탭'} +
+
+ 보안 환경 + {hasSecureOrigin() ? 'HTTPS 준비' : '등록 불가'} +
+
+ 서버 설정 + {webPushConfigured ? '활성' : '비활성'} +
+
+ 기기 등록 + {webPushRegistered ? '완료' : '미등록'} +
+
+ 기기 ID + {getSavedNotificationDeviceId() || '-'} +
+
+
+ + + + +
+
- - - 장싱 처리 필수 - - - main 자동 반영 - - - 생성 후 에디터 열기 - - + {isAppleMobileDevice() && !isStandaloneDisplayMode() ? ( + + ) : null} +
+ ), + }, + { + key: 'storage', + label: '저장값', + children: ( +
+ ({ + key: group.key, + label: group.label, + children: ( +
+
+ {group.label} + {group.description} +
+ {storageFeedback ? : null} +
+ {group.entries.map((entry) => { + const storageItemKey = buildStorageItemKey(entry); - - - 일일 업무일지 자동 생성 - - - - - - 스크린샷 포함 - - - 변경 파일 포함 - - - 명령 로그 포함 - - - - - - - - - - - - - - - - - -
+ return ( +
+
+
+ {entry.label} + {entry.description} +
+ {entry.scope === 'local' ? 'localStorage' : 'sessionStorage'} +
+ {entry.storageKey} + { + handleStorageDraftChange(entry, event.target.value); + }} + /> +
+ + + +
+
+ ); + })} +
+
+ ), + }))} + /> +
+ ), + }, + ]} + /> - - - 알림 토큰 등록과 업데이트 확인처럼 현재 기기 상태가 필요한 항목은 공유 링크에서 제외했습니다. - ); } diff --git a/src/app/main/mainChatPanel/chatUtils.ts b/src/app/main/mainChatPanel/chatUtils.ts index b21b992..8cad1e2 100644 --- a/src/app/main/mainChatPanel/chatUtils.ts +++ b/src/app/main/mainChatPanel/chatUtils.ts @@ -1891,6 +1891,78 @@ export async function fetchChatConversationDetail( }; } +export async function fetchChatShareConversationDetail( + token: string, + options: { + sessionId: string; + limit?: number; + beforeMessageId?: number | null; + sharePin?: string | null; + }, +) { + const query = new URLSearchParams(); + + if (options.limit != null) { + query.set('limit', String(options.limit)); + } + + if (Number.isFinite(options.beforeMessageId) && (options.beforeMessageId ?? 0) > 0) { + query.set('beforeMessageId', String(options.beforeMessageId)); + } + + const response = await requestChatApi( + `/shares/${encodeURIComponent(token)}/conversations/${encodeURIComponent(options.sessionId)}${query.toString() ? `?${query.toString()}` : ''}`, + undefined, + { + allowUnauthenticated: true, + sharePin: options.sharePin, + timeoutMs: 20000, + }, + ); + const normalizedMessages = Array.isArray(response.messages) + ? response.messages.map((message, index) => normalizeChatMessage(message, index)) + : []; + const normalizedRequests = enrichFailedRequestsWithActivityLogs( + response.requests.map((item) => normalizeChatConversationRequest(item)), + response.activityLogs, + ); + const visibleRequestIds = new Set( + normalizedMessages + .map((message) => message.clientRequestId?.trim() ?? '') + .filter(Boolean), + ); + const hydratedMessages = hydrateActivityLogMessages( + replaceGenericFailureMessages(normalizedMessages, normalizedRequests, response.activityLogs), + response.activityLogs.filter((item) => visibleRequestIds.has(item.requestId?.trim() ?? '')), + ).filter( + (message) => + message.author !== 'system' || + isActivityLogMessage(message) || + isMissingRequestMessage(message) || + isExecutionFailureMessage(message), + ); + const recoveredMessages = buildRecoveredMessagesFromConversationDetail(normalizedRequests, response.activityLogs); + + return { + ...response, + messages: mergeRecoveredChatMessages(recoveredMessages, hydratedMessages), + item: { + ...response.item, + lastMessagePreview: resolveConversationFailurePreview( + response.item.lastMessagePreview, + normalizedRequests, + response.activityLogs, + ), + notifyOffline: resolveSyncedChatOfflineNotificationSetting( + response.item.sessionId, + response.item.notifyOffline, + getOrCreateClientId(), + ), + }, + requests: normalizedRequests, + }; +} + export async function fetchChatRuntimeSnapshot() { const response = await requestChatApi<{ ok: boolean; item: ChatRuntimeSnapshot }>('/runtime'); return response.item; @@ -2576,6 +2648,8 @@ export type ChatShareSnapshot = { processingCount: number; unansweredCount: number; }; + oldestLoadedMessageId?: number | null; + hasOlderMessages?: boolean; promptTarget?: { sourceMessageId: number; promptIndex: number; @@ -2799,6 +2873,8 @@ export async function fetchChatShareSnapshot( rooms?: ChatShareRoomSummary[]; activeSessionId?: string | null; roomRequestCounts?: ChatShareSnapshot['roomRequestCounts']; + oldestLoadedMessageId?: number | null; + hasOlderMessages?: boolean; promptTarget?: ChatShareSnapshot['promptTarget']; refreshedAt: string; }>( @@ -2906,6 +2982,11 @@ export async function fetchChatShareSnapshot( unansweredCount: Number.isFinite(response.roomRequestCounts.unansweredCount) ? response.roomRequestCounts.unansweredCount : 0, } : undefined, + oldestLoadedMessageId: + Number.isFinite(response.oldestLoadedMessageId) && Number(response.oldestLoadedMessageId) > 0 + ? Number(response.oldestLoadedMessageId) + : null, + hasOlderMessages: response.hasOlderMessages === true, promptTarget: response.promptTarget ?? null, refreshedAt: response.refreshedAt, } satisfies ChatShareSnapshot; diff --git a/src/app/main/pages/ChatSharePage.css b/src/app/main/pages/ChatSharePage.css index 26c98ef..9824107 100644 --- a/src/app/main/pages/ChatSharePage.css +++ b/src/app/main/pages/ChatSharePage.css @@ -33,8 +33,10 @@ overflow-x: hidden; overflow-y: auto; -webkit-overflow-scrolling: touch; + overscroll-behavior-x: none; overscroll-behavior-y: contain; scrollbar-gutter: stable; + touch-action: pan-y; scroll-padding-bottom: calc(var(--chat-share-page-bottom-padding) + var(--chat-share-page-active-safe-bottom)); padding: calc(var(--chat-share-page-top-padding) + var(--chat-share-page-safe-top)) @@ -912,6 +914,18 @@ flex: 0 0 auto; } +.chat-share-page__initial-load-alert.ant-alert { + margin-top: 10px; + border-radius: 14px; + border: 1px solid rgba(125, 211, 252, 0.45); + background: linear-gradient(180deg, rgba(240, 249, 255, 0.96) 0%, rgba(224, 242, 254, 0.94) 100%); +} + +.chat-share-page__initial-load-alert .ant-alert-description { + font-size: 12px; + line-height: 1.5; +} + .chat-share-page__live-dot { width: 10px; height: 10px; @@ -1412,6 +1426,48 @@ background: linear-gradient(180deg, #f8fbff 0%, #eef4fb 100%); } +.chat-share-page__app-settings-drawer-shell .ant-drawer-content, +.chat-share-page__app-settings-drawer-shell .ant-drawer-wrapper-body { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + background: + linear-gradient(180deg, #f7fafc 0%, #eef3f9 100%), + radial-gradient(circle at top left, rgba(37, 99, 235, 0.08), transparent 26%); +} + +.chat-share-page__app-settings-drawer-shell .ant-drawer-header, +.chat-share-page__app-settings-drawer-shell .ant-drawer-header-title, +.chat-share-page__app-settings-drawer-shell .ant-drawer-extra { + min-width: 0; +} + +.chat-share-page__app-settings-drawer-shell .ant-drawer-header { + border-bottom: 1px solid rgba(196, 210, 226, 0.96); + padding: 14px 18px; + background: rgba(248, 250, 252, 0.94); +} + +.chat-share-page__app-settings-drawer-shell .ant-drawer-body { + display: flex; + flex: 1 1 auto; + min-width: 0; + min-height: 0; + padding: 0; + overflow: hidden; +} + +.chat-share-page__app-settings-drawer-body, +.chat-share-page__app-settings-drawer-body .shared-app-settings-page { + display: flex; + flex: 1 1 auto; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; +} + .chat-share-page__room-settings-actions { display: flex; align-items: center; @@ -1541,9 +1597,10 @@ .chat-share-page__process-inspector--fullscreen { inset: 0; - width: 100vw; - max-height: 100dvh; - height: 100dvh; + width: auto; + height: auto; + max-width: none; + max-height: none; border-radius: 0; border-width: 0; } @@ -1672,6 +1729,49 @@ overflow: auto; } +.chat-share-page__process-inspector-sections--fullscreen { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr)); + align-items: stretch; + grid-auto-flow: dense; + padding: + 14px + max(18px, env(safe-area-inset-right, 0px)) + 18px + max(18px, env(safe-area-inset-left, 0px)); +} + +.chat-share-page__process-inspector-sections--fullscreen.chat-share-page__process-inspector-sections--log-collapsed, +.chat-share-page__process-inspector-sections--fullscreen.chat-share-page__process-inspector-sections--checklist-collapsed.chat-share-page__process-inspector-sections--narratives-collapsed { + grid-template-columns: minmax(0, 1fr); +} + +.chat-share-page__process-inspector-sections--fullscreen .chat-share-page__process-inspector-section--checklist, +.chat-share-page__process-inspector-sections--fullscreen .chat-share-page__process-inspector-section--narratives, +.chat-share-page__process-inspector-sections--fullscreen .chat-share-page__process-inspector-section--log { + grid-column: auto; + grid-row: auto; +} + +.chat-share-page__process-inspector-sections--fullscreen.chat-share-page__process-inspector-sections--log-collapsed + .chat-share-page__process-inspector-section--checklist, +.chat-share-page__process-inspector-sections--fullscreen.chat-share-page__process-inspector-sections--log-collapsed + .chat-share-page__process-inspector-section--narratives, +.chat-share-page__process-inspector-sections--fullscreen.chat-share-page__process-inspector-sections--log-collapsed + .chat-share-page__process-inspector-section--log, +.chat-share-page__process-inspector-sections--fullscreen.chat-share-page__process-inspector-sections--checklist-collapsed.chat-share-page__process-inspector-sections--narratives-collapsed + .chat-share-page__process-inspector-section--log { + grid-column: 1; + grid-row: auto; +} + +.chat-share-page__process-inspector-sections--fullscreen.chat-share-page__process-inspector-sections--checklist-collapsed:not(.chat-share-page__process-inspector-sections--narratives-collapsed) + .chat-share-page__process-inspector-section--narratives, +.chat-share-page__process-inspector-sections--fullscreen.chat-share-page__process-inspector-sections--narratives-collapsed:not(.chat-share-page__process-inspector-sections--checklist-collapsed) + .chat-share-page__process-inspector-section--checklist { + grid-column: 1 / -1; + grid-row: auto; +} + .chat-share-page__process-inspector-section { display: grid; gap: 8px; @@ -2385,6 +2485,10 @@ padding-top: calc(14px + env(safe-area-inset-top, 0px)); } + .chat-share-page__app-settings-drawer-shell .ant-drawer-header { + padding-top: calc(14px + env(safe-area-inset-top, 0px)); + } + .chat-share-page__program-app-shell--system-chat-room { padding: 0; } diff --git a/src/app/main/pages/ChatSharePage.tsx b/src/app/main/pages/ChatSharePage.tsx index 0cb47bf..b43a612 100644 --- a/src/app/main/pages/ChatSharePage.tsx +++ b/src/app/main/pages/ChatSharePage.tsx @@ -1,7 +1,7 @@ import { AppstoreOutlined, CheckOutlined, CloseOutlined, CommentOutlined, CopyOutlined, DeleteOutlined, DownOutlined, EditOutlined, EyeInvisibleOutlined, EyeOutlined, FileTextOutlined, FilterOutlined, FullscreenOutlined, LeftOutlined, MinusOutlined, PlusOutlined, ReloadOutlined, RightOutlined, SearchOutlined, SendOutlined, SettingOutlined, ThunderboltOutlined, UpOutlined } from '@ant-design/icons'; import { Alert, App, Button, Checkbox, Drawer, Dropdown, Input, Modal, Select, Spin, Tabs, Tag, Typography, type MenuProps } from 'antd'; import type { TextAreaRef } from 'antd/es/input/TextArea'; -import { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ClipboardEvent, type CSSProperties, type FocusEvent, type KeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'; +import { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ClipboardEvent, type CSSProperties, type FocusEvent, type KeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; import { useParams } from 'react-router-dom'; import { FullscreenPreviewModal } from '../../../components/previewer'; @@ -43,6 +43,7 @@ import { completeChatShareManualBadge, createChatShareRoom, fetchChatConversationDetail, + fetchChatShareConversationDetail, fetchChatConversations, deleteChatShareRoom, fetchChatShareRuntimeSnapshot, @@ -82,6 +83,7 @@ import type { import { isPromptResolved } from '../mainChatPanel/promptState'; import { sendClientNotification, shouldFallbackToLocalNotification, showLocalClientNotification } from '../notificationApi'; import { copyTextToClipboard } from '../../../utils/clipboard'; +import { isTypingTarget } from '../mainView/utils'; import { applyViewportCssVars, scheduleViewportRecoverySync } from '../viewportCssVars'; import { isPreviewRuntime } from '../previewRuntime'; import { createInstallManifestObjectUrl, swapInstallDocumentMetadata } from '../pwa/installManifest'; @@ -109,6 +111,12 @@ const SHARE_ROOM_SNAPSHOT_SESSION_INDEX_STORAGE_KEY = 'codex-live-share-room-sna const SHARE_ROOM_SNAPSHOT_SESSION_STORAGE_KEY_PREFIX = 'codex-live-share-room-snapshot:v1'; const SHARE_ROOM_SNAPSHOT_SESSION_CACHE_LIMIT = 6; const SHARE_IMMEDIATE_SEND_TOGGLE_HOLD_MS = 1000; +const SHARE_EDGE_NAVIGATION_HOTZONE_PX = 28; +const SHARE_APPS_EDGE_MIDDLE_BAND_RATIO = 0.2; +const SHARE_EDGE_GESTURE_MIN_HORIZONTAL_PX = 16; +const SHARE_EDGE_GESTURE_OPEN_APPS_PX = 96; +const SHARE_EDGE_GESTURE_MAX_VERTICAL_DRIFT_PX = 64; +const SHARE_HISTORY_PAGE_SIZE = 40; const SHARE_ACCESS_PIN_PROMPT_TTL_OPTIONS = [ { value: 'always', label: '매번 묻기', minutes: 0 }, { value: '5', label: '5분 유지', minutes: 5 }, @@ -177,6 +185,18 @@ type ShareSearchResult = { type ShareSearchIndexedResult = ShareSearchResult & { searchText: string; }; +type ShareEdgeGestureTracking = + | { + startX: number; + startY: number; + direction: 'back'; + } + | { + startX: number; + startY: number; + direction: 'apps'; + opened: boolean; + }; type ShareSearchPanelMode = 'all' | 'apps'; type ShareWorkServerVersionStatus = 'latest' | 'unknown' | 'update-available' | 'build-required'; type ClientNotificationPermissionState = 'unsupported' | 'default' | 'granted' | 'denied'; @@ -1883,6 +1903,31 @@ function isMobileShareInputTarget(target: EventTarget | null): target is HTMLEle return Boolean(inputTarget) && isSnapshotDeferrableFocusTarget(inputTarget); } +function isMobileTouchViewport() { + if (typeof window === 'undefined') { + return false; + } + + return window.innerWidth <= 768 + && (window.matchMedia('(pointer: coarse)').matches || window.matchMedia('(hover: none)').matches); +} + +function isShareEdgeGestureIgnoredTarget(target: EventTarget | null) { + if (isTypingTarget(target)) { + return true; + } + + if (!(target instanceof HTMLElement)) { + return false; + } + + return Boolean( + target.closest( + 'button, a, summary, [role="button"], [data-share-edge-gesture-ignore], .ant-modal-root, .ant-drawer, .ant-dropdown, .ant-select-dropdown, .chat-share-page__program-shell, .chat-share-page__program-minimized', + ), + ); +} + function getDefaultProgramMinimizedPosition() { if (typeof window === 'undefined') { return { x: PROGRAM_MINIMIZED_VIEWPORT_PADDING, y: PROGRAM_MINIMIZED_VIEWPORT_PADDING }; @@ -2071,6 +2116,38 @@ function replaceChatShareSnapshotRequest(snapshot: ChatShareSnapshot, nextReques }; } +function mergeChatShareSnapshotHistory( + snapshot: ChatShareSnapshot, + detailPage: { + requests: ChatConversationRequest[]; + messages: ChatMessage[]; + activityLogs: ChatShareSnapshot['activityLogs']; + oldestLoadedMessageId: number | null; + hasOlderMessages: boolean; + }, +): ChatShareSnapshot { + const mergedRequests = [...detailPage.requests, ...snapshot.requests]; + const mergedMessages = [...detailPage.messages, ...snapshot.messages]; + const mergedActivityLogs = [...detailPage.activityLogs, ...snapshot.activityLogs]; + + return { + ...snapshot, + detailLevel: detailPage.hasOlderMessages ? 'initial' : 'full', + requests: mergedRequests.filter( + (request, index, array) => array.findIndex((item) => item.requestId === request.requestId) === index, + ), + messages: mergedMessages.filter( + (message, index, array) => array.findIndex((item) => item.id === message.id) === index, + ), + activityLogs: mergedActivityLogs.filter( + (activityLog, index, array) => array.findIndex((item) => item.requestId === activityLog.requestId) === index, + ), + oldestLoadedMessageId: detailPage.oldestLoadedMessageId, + hasOlderMessages: detailPage.hasOlderMessages, + refreshedAt: new Date().toISOString(), + }; +} + function buildShareVisibleText(text: string) { return stripHiddenPreviewTags(extractAttachmentPreviewUrls(text).strippedText).trim(); } @@ -4014,8 +4091,11 @@ export function ChatSharePage() { const [snapshot, setSnapshot] = useState(initialCachedSnapshot); const [requestedRoomSessionId, setRequestedRoomSessionId] = useState(initialRequestedRoomSessionId); const requestedRoomSessionIdRef = useRef(requestedRoomSessionId); + const skipNextRequestedRoomRefreshRef = useRef(false); const [isLoading, setIsLoading] = useState(() => initialCachedSnapshot == null); const [, setIsRefreshing] = useState(false); + const [isLoadingFullSnapshot, setIsLoadingFullSnapshot] = useState(false); + const [isLoadingOlderShareHistory, setIsLoadingOlderShareHistory] = useState(false); const [isLiveConnected, setIsLiveConnected] = useState(false); const [errorMessage, setErrorMessage] = useState(''); const [requiresAccessPin, setRequiresAccessPin] = useState(false); @@ -4848,12 +4928,6 @@ export function ChatSharePage() { setRequiresAccessPin(false); setAccessPinSubmitError(''); - if (initialLoad && nextSnapshot.detailLevel === 'initial') { - window.setTimeout(() => { - void refreshSnapshot({ silent: true }); - }, 0); - } - return true; } catch (error) { if (error instanceof ChatApiError && error.status === 401 && (error.code === 'share_pin_required' || error.code === 'share_pin_invalid')) { @@ -4892,6 +4966,42 @@ export function ChatSharePage() { snapshotRefreshPromiseRef.current = refreshTask; return refreshTask; }, [normalizedToken]); + const handleLoadOlderShareHistory = useCallback(async () => { + if (!normalizedToken || !snapshot?.conversation.sessionId || !snapshot.hasOlderMessages || !snapshot.oldestLoadedMessageId) { + return; + } + + setIsLoadingFullSnapshot(true); + setIsLoadingOlderShareHistory(true); + + try { + const scrollContainer = scrollContainerRef.current; + const previousScrollHeight = scrollContainer?.scrollHeight ?? 0; + const previousScrollTop = scrollContainer?.scrollTop ?? 0; + const detailPage = await fetchChatShareConversationDetail(normalizedToken, { + sessionId: snapshot.conversation.sessionId, + beforeMessageId: snapshot.oldestLoadedMessageId, + limit: SHARE_HISTORY_PAGE_SIZE, + sharePin: getStoredChatShareAccessPin(normalizedToken) || undefined, + }); + + setSnapshot((current) => (current ? mergeChatShareSnapshotHistory(current, detailPage) : current)); + + window.requestAnimationFrame(() => { + if (!scrollContainer) { + return; + } + + const nextScrollHeight = scrollContainer.scrollHeight; + scrollContainer.scrollTop = Math.max(0, previousScrollTop + (nextScrollHeight - previousScrollHeight)); + }); + } catch (error) { + message.error(error instanceof Error ? error.message : '이전 대화를 불러오지 못했습니다.'); + } finally { + setIsLoadingFullSnapshot(false); + setIsLoadingOlderShareHistory(false); + } + }, [message, normalizedToken, snapshot]); const handleDeleteShareRoom = useCallback(async (room: ChatShareRoomSummary) => { if (!normalizedToken || !room.sessionId || isDeletingRoom) { return; @@ -5619,6 +5729,39 @@ export function ChatSharePage() { captureTarget.setPointerCapture(event.pointerId); }, []); + const clearProgramMinimizedDragState = useCallback(() => { + const dragState = programMinimizedDragStateRef.current; + + if (!dragState) { + programMinimizedMovedRef.current = false; + return; + } + + if (dragState.captureTarget.hasPointerCapture(dragState.pointerId)) { + dragState.captureTarget.releasePointerCapture(dragState.pointerId); + } + + programMinimizedDragStateRef.current = null; + programMinimizedMovedRef.current = false; + }, []); + + const handleProgramMinimizedActionPointerDown = useCallback((event: ReactPointerEvent) => { + event.preventDefault(); + event.stopPropagation(); + clearProgramMinimizedDragState(); + }, [clearProgramMinimizedDragState]); + + const runProgramMinimizedActionAfterPointerCycle = useCallback((action: () => void) => { + if (typeof window === 'undefined') { + action(); + return; + } + + window.requestAnimationFrame(() => { + action(); + }); + }, []); + const handleRestoreProgram = useCallback((targetKey: string) => { let restoredTarget: ShareProgramTarget | null = null; @@ -5693,8 +5836,13 @@ export function ChatSharePage() { size="small" icon={} className="chat-share-page__program-minimized-button" - onClick={() => { - handleRestoreProgram(item.target.key); + onPointerDown={handleProgramMinimizedActionPointerDown} + onClick={(event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + runProgramMinimizedActionAfterPointerCycle(() => { + handleRestoreProgram(item.target.key); + }); }} > 열기 @@ -5705,8 +5853,13 @@ export function ChatSharePage() { className="chat-share-page__program-minimized-icon chat-share-page__program-minimized-close" icon={} aria-label="프로그램 닫기" - onClick={() => { - handleCloseMinimizedProgram(item.target.key); + onPointerDown={handleProgramMinimizedActionPointerDown} + onClick={(event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + runProgramMinimizedActionAfterPointerCycle(() => { + handleCloseMinimizedProgram(item.target.key); + }); }} /> @@ -5979,6 +6132,11 @@ export function ChatSharePage() { return; } + if (skipNextRequestedRoomRefreshRef.current) { + skipNextRequestedRoomRefreshRef.current = false; + return; + } + void refreshSnapshot({ silent: true }); }, [normalizedToken, refreshSnapshot, requestedRoomSessionId]); useEffect(() => { @@ -6001,10 +6159,11 @@ export function ChatSharePage() { } requestedRoomSessionIdRef.current = stabilizedRoomSessionId; + skipNextRequestedRoomRefreshRef.current = snapshot?.detailLevel === 'initial'; writeStoredShareLastRoomSessionId(normalizedToken, stabilizedRoomSessionId); writeShareRoomSessionIdToLocation(stabilizedRoomSessionId, 'replace'); setRequestedRoomSessionId(stabilizedRoomSessionId); - }, [activeShareRoomSessionId, normalizedToken, shareRooms]); + }, [activeShareRoomSessionId, normalizedToken, shareRooms, snapshot?.detailLevel]); useEffect(() => { if (!requestedRoomSessionId) { @@ -6534,7 +6693,7 @@ export function ChatSharePage() { if (cachedSnapshot) { setSnapshot(cachedSnapshot); } - setIsRoomSwitching(true); + setIsRoomSwitching(!cachedSnapshot); requestedRoomSessionIdRef.current = normalizedSessionId; writeStoredShareLastRoomSessionId(normalizedToken, normalizedSessionId); writeShareRoomSessionIdToLocation(normalizedSessionId, 'push'); @@ -7740,7 +7899,15 @@ export function ChatSharePage() { )} -
+
계획 체크리스트 @@ -7916,8 +8083,10 @@ export function ChatSharePage() { try { const roomSnapshot = await fetchChatShareSnapshot(normalizedToken, { sessionId: normalizedSessionId, + view: 'initial', sharePin, }); + writeStoredShareRoomSnapshot(normalizedToken, roomSnapshot); setShareRoomPendingCountsBySessionId((current) => { const nextCounts = resolveShareRoomPendingCounts(roomSnapshot); @@ -8394,6 +8563,7 @@ export function ChatSharePage() { ? renderEmbeddedSharePlayApp(programTarget.appId, closeProgramTarget, normalizedToken) : null; const isServerCommandDrawerOpen = programTarget?.appId === 'server-command'; + const isAppSettingsDrawerOpen = programTarget?.appId === 'app-settings'; const indexedContentSearchResults = useMemo(() => { const results: ShareSearchIndexedResult[] = []; @@ -8832,19 +9002,19 @@ export function ChatSharePage() { }, ] : []), - ...(canCreateSharedRooms + ...(shareAllowedAppIdSet.has('app-settings') ? [ { - key: 'conversation-room-create', + key: 'conversation-personal-settings', label: ( - 채팅방 추가 + 개인설정 - 같은 공유 토큰 안에 새 채팅방을 만들고 바로 전환합니다. + 앱 전체 웹 푸시, 유도 문구, 클라이언트 저장값을 탭으로 관리합니다. ), - icon: , + icon: , }, ] : []), @@ -8898,11 +9068,12 @@ export function ChatSharePage() { allowedPlayAppEntries.length, canSendMessage, canOpenSharedRoomSettings, - canCreateSharedRooms, hasWorkServerCommandApp, isClearingConversation, normalizedToken, selectedTokenUsageSetting, + shareAllowedAppIdSet, + sharePermissionSet, shareWorkServerCommand, snapshot?.conversation.title, selectedShareRoomSessionId, @@ -8911,6 +9082,12 @@ export function ChatSharePage() { tokenUsageOverview.fiveHourCountdownLabel, ], ); + const openShareAppsPanel = useCallback(() => { + setSearchPanelMode('apps'); + setSearchKeyword(''); + setIsShareRoomListVisible(false); + setIsSearchOpen(true); + }, []); const handleShareHeaderSettingsClick = useCallback>( ({ key }) => { if (key === 'conversation-search') { @@ -8926,9 +9103,7 @@ export function ChatSharePage() { } if (key === 'conversation-apps') { - setSearchPanelMode('apps'); - setSearchKeyword(''); - setIsSearchOpen(true); + openShareAppsPanel(); return; } @@ -8942,8 +9117,8 @@ export function ChatSharePage() { return; } - if (key === 'conversation-room-create') { - openCreateRoomDialog(); + if (key === 'conversation-personal-settings') { + openProgramTarget(buildShareManagementProgramTarget('app-settings', '개인설정')); return; } @@ -8956,8 +9131,127 @@ export function ChatSharePage() { void handleClearConversation(); } }, - [handleClearConversation, handleReloadPage, openCreateRoomDialog, openProgramTarget, openSharedRoomSettings], + [handleClearConversation, handleReloadPage, openProgramTarget, openShareAppsPanel, openSharedRoomSettings], ); + + useEffect(() => { + if (typeof window === 'undefined') { + return undefined; + } + + const scrollContainer = scrollContainerRef.current; + + if (!scrollContainer || !isMobileTouchViewport()) { + return undefined; + } + + const isBlockedByOverlay = + isSearchOpen + || isTokenUsageOpen + || isRoomSettingsOpen + || isCreateRoomOpen + || isOriginReplyModalOpen + || Boolean(sourceGroupDetail) + || Boolean(programTarget) + || Boolean(activeProcessInspectorRequestId); + + let tracking: ShareEdgeGestureTracking | null = null; + + const resetTracking = () => { + tracking = null; + }; + + const handleTouchStart = (event: TouchEvent) => { + const touch = event.touches[0]; + + if (!touch || event.touches.length !== 1 || isBlockedByOverlay || isShareEdgeGestureIgnoredTarget(event.target)) { + tracking = null; + return; + } + + const centerBandStart = window.innerHeight * SHARE_APPS_EDGE_MIDDLE_BAND_RATIO; + const centerBandEnd = window.innerHeight * (1 - SHARE_APPS_EDGE_MIDDLE_BAND_RATIO); + + if ( + touch.clientX >= window.innerWidth - SHARE_EDGE_NAVIGATION_HOTZONE_PX + && touch.clientY >= centerBandStart + && touch.clientY <= centerBandEnd + ) { + tracking = { + startX: touch.clientX, + startY: touch.clientY, + direction: 'apps', + opened: false, + }; + return; + } + + if (touch.clientX <= SHARE_EDGE_NAVIGATION_HOTZONE_PX) { + tracking = { + startX: touch.clientX, + startY: touch.clientY, + direction: 'back', + }; + return; + } + + tracking = null; + }; + + const handleTouchMove = (event: TouchEvent) => { + const touch = event.touches[0]; + + if (!tracking || !touch) { + return; + } + + const deltaX = touch.clientX - tracking.startX; + const deltaY = touch.clientY - tracking.startY; + + if (Math.abs(deltaY) > SHARE_EDGE_GESTURE_MAX_VERTICAL_DRIFT_PX) { + tracking = null; + return; + } + + if (tracking.direction === 'back') { + if (deltaX >= SHARE_EDGE_GESTURE_MIN_HORIZONTAL_PX) { + event.preventDefault(); + } + return; + } + + if (deltaX <= SHARE_EDGE_GESTURE_MIN_HORIZONTAL_PX * -1) { + event.preventDefault(); + } + + if (!tracking.opened && deltaX <= SHARE_EDGE_GESTURE_OPEN_APPS_PX * -1) { + tracking.opened = true; + openShareAppsPanel(); + } + }; + + scrollContainer.addEventListener('touchstart', handleTouchStart, { passive: true, capture: true }); + scrollContainer.addEventListener('touchmove', handleTouchMove, { passive: false, capture: true }); + scrollContainer.addEventListener('touchend', resetTracking, { passive: true, capture: true }); + scrollContainer.addEventListener('touchcancel', resetTracking, { passive: true, capture: true }); + + return () => { + scrollContainer.removeEventListener('touchstart', handleTouchStart, true); + scrollContainer.removeEventListener('touchmove', handleTouchMove, true); + scrollContainer.removeEventListener('touchend', resetTracking, true); + scrollContainer.removeEventListener('touchcancel', resetTracking, true); + }; + }, [ + activeProcessInspectorRequestId, + isCreateRoomOpen, + isOriginReplyModalOpen, + isRoomSettingsOpen, + isSearchOpen, + isTokenUsageOpen, + openShareAppsPanel, + programTarget, + sourceGroupDetail, + ]); const shareExpandModeMenuItems = useMemo( () => [ { @@ -9363,6 +9657,28 @@ export function ChatSharePage() { {headerSummaryLabel} + {snapshot.hasOlderMessages ? ( + } + loading={isLoadingFullSnapshot || isLoadingOlderShareHistory} + onClick={() => { + void handleLoadOlderShareHistory(); + }} + > + 이전 대화 더 불러오기 + + } + /> + ) : null}
{canToggleShareRoomList ? ( @@ -10724,8 +11040,36 @@ export function ChatSharePage() {
) : null} + } + onClick={handleReloadProgram} + /> + )} + onClose={closeProgramTarget} + > + {isAppSettingsDrawerOpen ? ( +
+ +
+ ) : null} +
- ) : programTarget.appId === 'app-settings' ? ( -
- -
) : (