chore: test deploy snapshot
This commit is contained in:
@@ -1279,6 +1279,8 @@ async function buildChatShareSnapshot(
|
|||||||
: await listChatConversationActivityLogsByRequestIds(normalizedSessionId, scopeRequestIds);
|
: await listChatConversationActivityLogsByRequestIds(normalizedSessionId, scopeRequestIds);
|
||||||
const promptTarget = tokenPayload.kind === 'prompt' ? resolvePromptTarget(scopedMessages, tokenPayload) : null;
|
const promptTarget = tokenPayload.kind === 'prompt' ? resolvePromptTarget(scopedMessages, tokenPayload) : null;
|
||||||
const roomRequestCounts = useInitialManagedShareRoomView ? undefined : buildRoomRequestCounts(requests, messages);
|
const roomRequestCounts = useInitialManagedShareRoomView ? undefined : buildRoomRequestCounts(requests, messages);
|
||||||
|
const oldestLoadedMessageId = detailPage?.oldestLoadedMessageId ?? null;
|
||||||
|
const hasOlderMessages = detailPage?.hasOlderMessages ?? false;
|
||||||
|
|
||||||
if (tokenPayload.kind === 'prompt' && !promptTarget) {
|
if (tokenPayload.kind === 'prompt' && !promptTarget) {
|
||||||
return null;
|
return null;
|
||||||
@@ -1293,6 +1295,8 @@ async function buildChatShareSnapshot(
|
|||||||
messages: scopedMessages,
|
messages: scopedMessages,
|
||||||
activityLogs,
|
activityLogs,
|
||||||
roomRequestCounts,
|
roomRequestCounts,
|
||||||
|
oldestLoadedMessageId,
|
||||||
|
hasOlderMessages,
|
||||||
promptTarget,
|
promptTarget,
|
||||||
detailLevel,
|
detailLevel,
|
||||||
} satisfies {
|
} satisfies {
|
||||||
@@ -1307,6 +1311,8 @@ async function buildChatShareSnapshot(
|
|||||||
processingCount: number;
|
processingCount: number;
|
||||||
unansweredCount: number;
|
unansweredCount: number;
|
||||||
} | undefined;
|
} | undefined;
|
||||||
|
oldestLoadedMessageId: number | null;
|
||||||
|
hasOlderMessages: boolean;
|
||||||
promptTarget:
|
promptTarget:
|
||||||
| {
|
| {
|
||||||
sourceMessageId: number;
|
sourceMessageId: number;
|
||||||
@@ -2580,6 +2586,8 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
|||||||
messages: shareSnapshot.messages,
|
messages: shareSnapshot.messages,
|
||||||
activityLogs: shareSnapshot.activityLogs,
|
activityLogs: shareSnapshot.activityLogs,
|
||||||
roomRequestCounts: shareSnapshot.roomRequestCounts,
|
roomRequestCounts: shareSnapshot.roomRequestCounts,
|
||||||
|
oldestLoadedMessageId: shareSnapshot.oldestLoadedMessageId,
|
||||||
|
hasOlderMessages: shareSnapshot.hasOlderMessages,
|
||||||
rooms: resolvedRoomContext.rooms,
|
rooms: resolvedRoomContext.rooms,
|
||||||
activeSessionId: activeRoom.sessionId,
|
activeSessionId: activeRoom.sessionId,
|
||||||
promptTarget: shareSnapshot.promptTarget,
|
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) => {
|
app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/clear`, async (request, reply) => {
|
||||||
const params = z.object({
|
const params = z.object({
|
||||||
token: z.string().trim().min(1).max(16000),
|
token: z.string().trim().min(1).max(16000),
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
.shared-app-settings-page {
|
.shared-app-settings-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 16px;
|
width: 100%;
|
||||||
min-height: 100%;
|
height: 100%;
|
||||||
background: #f7f8fb;
|
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 {
|
.shared-app-settings-page--loading {
|
||||||
@@ -12,18 +19,128 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shared-app-settings-page__grid {
|
.shared-app-settings-page__header {
|
||||||
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 {
|
|
||||||
display: flex;
|
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;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,28 +1,323 @@
|
|||||||
import { ReloadOutlined, SaveOutlined } from '@ant-design/icons';
|
import { ReloadOutlined, SaveOutlined } from '@ant-design/icons';
|
||||||
import { Alert, App, Button, Card, Checkbox, Flex, Form, Input, InputNumber, Select, Space, Spin, Typography } from 'antd';
|
import { Alert, App, Button, Checkbox, Form, Input, InputNumber, Spin, Tabs, Typography } from 'antd';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { fetchWebPushConfig } from './notificationApi';
|
||||||
|
import { NOTIFICATION_DEVICE_ID_STORAGE_KEY, getSavedNotificationDeviceId } from './notificationIdentity';
|
||||||
import {
|
import {
|
||||||
DEFAULT_APP_CONFIG,
|
DEFAULT_APP_CONFIG,
|
||||||
getWeeklyScheduleOptions,
|
APP_CONFIG_STORAGE_KEY,
|
||||||
saveAppConfigToServer,
|
saveAppConfigToServer,
|
||||||
type AppConfig,
|
type AppConfig,
|
||||||
type PlanCostTimeUnit,
|
|
||||||
} from './appConfig';
|
} from './appConfig';
|
||||||
|
import {
|
||||||
|
TOKEN_ACCESS_STORAGE_KEY,
|
||||||
|
} from './tokenAccess';
|
||||||
|
import {
|
||||||
|
clearWebPushSubscriptionRegistration,
|
||||||
|
ensureWebPushSubscriptionRegistered,
|
||||||
|
syncExistingWebPushSubscriptionRegistration,
|
||||||
|
} from './webPushRegistration';
|
||||||
|
import { isPreviewRuntime } from './previewRuntime';
|
||||||
import './SharedAppSettingsPage.css';
|
import './SharedAppSettingsPage.css';
|
||||||
|
|
||||||
const { Paragraph, Text, Title } = Typography;
|
const { Paragraph, Text, Title } = Typography;
|
||||||
|
|
||||||
const PLAN_COST_TIME_UNIT_OPTIONS: Array<{ value: PlanCostTimeUnit; label: string }> = [
|
const WEB_PUSH_METADATA_STORAGE_KEY = 'work-server.web-push.registration-meta.v1';
|
||||||
{ value: 'hour', label: '시간' },
|
const SHARE_IMMEDIATE_SEND_PINNED_STORAGE_KEY = 'codex-live-share-immediate-send-pinned-by-token';
|
||||||
{ value: 'minute', label: '분' },
|
const SHARE_LAST_ROOM_STORAGE_KEY = 'codex-live-share-last-room-by-token';
|
||||||
{ value: 'second', label: '초' },
|
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 = {
|
type SharedAppSettingsPageProps = {
|
||||||
shareToken: string;
|
shareToken: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SharedAppSettingsFormValue = AppConfig;
|
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<void>((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) {
|
export function SharedAppSettingsPage({ shareToken }: SharedAppSettingsPageProps) {
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
@@ -31,6 +326,64 @@ export function SharedAppSettingsPage({ shareToken }: SharedAppSettingsPageProps
|
|||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
const [savedConfig, setSavedConfig] = useState<AppConfig>(DEFAULT_APP_CONFIG);
|
const [savedConfig, setSavedConfig] = useState<AppConfig>(DEFAULT_APP_CONFIG);
|
||||||
|
const [activeTabKey, setActiveTabKey] = useState('general');
|
||||||
|
const [storageGroupKey, setStorageGroupKey] = useState(STORAGE_GROUPS[0]?.key ?? 'share-chat');
|
||||||
|
const [storageDrafts, setStorageDrafts] = useState<Record<string, string>>({});
|
||||||
|
const [storageFeedback, setStorageFeedback] = useState('');
|
||||||
|
const [webPushConfigured, setWebPushConfigured] = useState(false);
|
||||||
|
const [webPushRegistered, setWebPushRegistered] = useState(false);
|
||||||
|
const [webPushLoading, setWebPushLoading] = useState(false);
|
||||||
|
const [clientPermission, setClientPermission] = useState<ClientNotificationPermissionState>(() => 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<Record<string, string>>((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 () => {
|
const loadConfig = useCallback(async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -53,12 +406,14 @@ export function SharedAppSettingsPage({ shareToken }: SharedAppSettingsPageProps
|
|||||||
const nextConfig = payload.config ?? DEFAULT_APP_CONFIG;
|
const nextConfig = payload.config ?? DEFAULT_APP_CONFIG;
|
||||||
setSavedConfig(nextConfig);
|
setSavedConfig(nextConfig);
|
||||||
form.setFieldsValue(nextConfig);
|
form.setFieldsValue(nextConfig);
|
||||||
|
await syncWebPushStatus();
|
||||||
|
loadStorageDrafts();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setErrorMessage(error instanceof Error ? error.message : '앱 설정을 불러오지 못했습니다.');
|
setErrorMessage(error instanceof Error ? error.message : '앱 설정을 불러오지 못했습니다.');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [form, shareToken]);
|
}, [form, loadStorageDrafts, shareToken, syncWebPushStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadConfig();
|
void loadConfig();
|
||||||
@@ -76,7 +431,7 @@ export function SharedAppSettingsPage({ shareToken }: SharedAppSettingsPageProps
|
|||||||
});
|
});
|
||||||
setSavedConfig(saved);
|
setSavedConfig(saved);
|
||||||
form.setFieldsValue(saved);
|
form.setFieldsValue(saved);
|
||||||
message.success('앱 설정을 저장했습니다.');
|
message.success('개인설정을 저장했습니다.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setErrorMessage(error instanceof Error ? error.message : '앱 설정 저장에 실패했습니다.');
|
setErrorMessage(error instanceof Error ? error.message : '앱 설정 저장에 실패했습니다.');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -86,6 +441,145 @@ export function SharedAppSettingsPage({ shareToken }: SharedAppSettingsPageProps
|
|||||||
[form, message, shareToken],
|
[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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="shared-app-settings-page shared-app-settings-page--loading">
|
<div className="shared-app-settings-page shared-app-settings-page--loading">
|
||||||
@@ -96,22 +590,22 @@ export function SharedAppSettingsPage({ shareToken }: SharedAppSettingsPageProps
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shared-app-settings-page">
|
<div className="shared-app-settings-page">
|
||||||
<Flex align="center" justify="space-between" gap={12} wrap>
|
<div className="shared-app-settings-page__header">
|
||||||
<div>
|
<div>
|
||||||
<Title level={4}>앱 설정</Title>
|
<Title level={4}>개인설정</Title>
|
||||||
<Paragraph type="secondary">
|
<Paragraph type="secondary">
|
||||||
공유 링크에서 허용된 핵심 앱 설정만 바로 수정합니다.
|
공유채팅 안에서 개인 단위 앱 설정, 웹 푸시 등록, 클라이언트 저장값을 한 번에 관리합니다.
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
</div>
|
</div>
|
||||||
<Space>
|
<div className="shared-app-settings-page__header-actions">
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => void loadConfig()} disabled={isSaving}>
|
<Button icon={<ReloadOutlined />} onClick={() => void loadConfig()} disabled={isSaving || webPushLoading}>
|
||||||
새로고침
|
새로고침
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="primary" icon={<SaveOutlined />} onClick={() => void form.submit()} loading={isSaving}>
|
<Button type="primary" icon={<SaveOutlined />} onClick={() => void form.submit()} loading={isSaving}>
|
||||||
저장
|
저장
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</div>
|
||||||
</Flex>
|
</div>
|
||||||
|
|
||||||
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
|
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
|
||||||
|
|
||||||
@@ -121,128 +615,194 @@ export function SharedAppSettingsPage({ shareToken }: SharedAppSettingsPageProps
|
|||||||
initialValues={savedConfig}
|
initialValues={savedConfig}
|
||||||
onFinish={(values) => void handleSave(values)}
|
onFinish={(values) => void handleSave(values)}
|
||||||
>
|
>
|
||||||
<div className="shared-app-settings-page__grid">
|
<Tabs
|
||||||
<Card size="small" title="채팅 문맥 설정">
|
activeKey={activeTabKey}
|
||||||
<Form.Item label="최근 메시지 수" name={['chat', 'maxContextMessages']}>
|
onChange={setActiveTabKey}
|
||||||
<InputNumber min={1} max={50} style={{ width: '100%' }} />
|
className="shared-app-settings-page__tabs"
|
||||||
</Form.Item>
|
items={[
|
||||||
<Form.Item label="최대 문자 수" name={['chat', 'maxContextChars']}>
|
{
|
||||||
<InputNumber min={500} max={20000} step={100} style={{ width: '100%' }} />
|
key: 'general',
|
||||||
</Form.Item>
|
label: '기본',
|
||||||
<Form.Item label="Codex Live 최대 실행 시간(초)" name={['chat', 'codexLiveMaxExecutionSeconds']}>
|
children: (
|
||||||
<InputNumber min={60} max={7200} step={30} style={{ width: '100%' }} />
|
<div className="shared-app-settings-page__panel">
|
||||||
</Form.Item>
|
<div className="shared-app-settings-page__section">
|
||||||
<Form.Item label="무출력 실패 시간(초)" name={['chat', 'codexLiveIdleTimeoutSeconds']}>
|
<div className="shared-app-settings-page__section-head">
|
||||||
<InputNumber min={30} max={3600} step={10} style={{ width: '100%' }} />
|
<Text strong>채팅 기본값</Text>
|
||||||
</Form.Item>
|
<Text type="secondary">공유채팅과 Codex Live 동작에 직접 연결되는 최소 설정만 다룹니다.</Text>
|
||||||
<Form.Item label="재기동 완료 자동 실행 대기(초)" name={['chat', 'restartReservationCompletionDelaySeconds']}>
|
</div>
|
||||||
<InputNumber min={1} max={300} style={{ width: '100%' }} />
|
<div className="shared-app-settings-page__field-grid">
|
||||||
</Form.Item>
|
<Form.Item label="최근 메시지 수" name={['chat', 'maxContextMessages']}>
|
||||||
<Form.Item name={['chat', 'receiveRoomNotifications']} valuePropName="checked">
|
<InputNumber min={1} max={50} style={{ width: '100%' }} />
|
||||||
<Checkbox>채팅방 알림 수신</Checkbox>
|
</Form.Item>
|
||||||
</Form.Item>
|
<Form.Item label="최대 문자 수" name={['chat', 'maxContextChars']}>
|
||||||
</Card>
|
<InputNumber min={500} max={20000} step={100} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="최대 실행 시간(초)" name={['chat', 'codexLiveMaxExecutionSeconds']}>
|
||||||
|
<InputNumber min={60} max={7200} step={30} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="무출력 실패 시간(초)" name={['chat', 'codexLiveIdleTimeoutSeconds']}>
|
||||||
|
<InputNumber min={30} max={3600} step={10} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="재기동 완료 자동 실행 대기(초)" name={['chat', 'restartReservationCompletionDelaySeconds']}>
|
||||||
|
<InputNumber min={1} max={300} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="통합 검색 단축키" name={['gestureShortcuts', 'openSearch']}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card size="small" title="자동접수 / 주기">
|
<div className="shared-app-settings-page__section">
|
||||||
<Form.Item name={['automation', 'autoRefreshEnabled']} valuePropName="checked">
|
<div className="shared-app-settings-page__section-head">
|
||||||
<Checkbox>자동 새로고침 사용</Checkbox>
|
<Text strong>알림 유도 문구</Text>
|
||||||
</Form.Item>
|
<Text type="secondary">앱 전체 웹 푸시 등록 흐름에서 PWA, 권한, 등록 안내를 어떻게 유도할지 정합니다.</Text>
|
||||||
<Form.Item label="자동 새로고침 간격(초)" name={['automation', 'autoRefreshIntervalSeconds']}>
|
</div>
|
||||||
<InputNumber min={1} max={3600} style={{ width: '100%' }} />
|
<div className="shared-app-settings-page__check-grid">
|
||||||
</Form.Item>
|
<Form.Item name={['chat', 'receiveRoomNotifications']} valuePropName="checked">
|
||||||
<Form.Item label="자동접수 방식" name={['automation', 'autoReceiveScheduleType']}>
|
<Checkbox>앱 전체 채팅방 알림 수신 사용</Checkbox>
|
||||||
<Select
|
</Form.Item>
|
||||||
options={[
|
<Form.Item name={['chat', 'guidePwaInstallForNotifications']} valuePropName="checked">
|
||||||
{ value: 'interval', label: '간격' },
|
<Checkbox>PWA 설치 유도 사용</Checkbox>
|
||||||
{ value: 'daily', label: '매일' },
|
</Form.Item>
|
||||||
{ value: 'weekly', label: '매주' },
|
<Form.Item name={['chat', 'guideWebPushPermission']} valuePropName="checked">
|
||||||
]}
|
<Checkbox>브라우저 권한 유도 사용</Checkbox>
|
||||||
/>
|
</Form.Item>
|
||||||
</Form.Item>
|
<Form.Item name={['chat', 'guideWebPushRegistration']} valuePropName="checked">
|
||||||
<Form.Item label="간격(초)" name={['automation', 'autoReceiveIntervalSeconds']}>
|
<Checkbox>웹 푸시 등록 유도 사용</Checkbox>
|
||||||
<InputNumber min={1} max={3600} style={{ width: '100%' }} />
|
</Form.Item>
|
||||||
</Form.Item>
|
</div>
|
||||||
<Form.Item label="매일 시각" name={['automation', 'autoReceiveDailyTime']}>
|
</div>
|
||||||
<Input placeholder="09:00" />
|
</div>
|
||||||
</Form.Item>
|
),
|
||||||
<Form.Item label="매주 요일" name={['automation', 'autoReceiveWeeklyDay']}>
|
},
|
||||||
<Select options={getWeeklyScheduleOptions()} />
|
{
|
||||||
</Form.Item>
|
key: 'web-push',
|
||||||
<Form.Item label="매주 시각" name={['automation', 'autoReceiveWeeklyTime']}>
|
label: '웹 푸시',
|
||||||
<Input placeholder="09:00" />
|
children: (
|
||||||
</Form.Item>
|
<div className="shared-app-settings-page__panel">
|
||||||
</Card>
|
<div className="shared-app-settings-page__section">
|
||||||
|
<div className="shared-app-settings-page__section-head">
|
||||||
|
<Text strong>현재 기기 등록 상태</Text>
|
||||||
|
<Text type="secondary">권한, PWA 실행 여부, 서버 설정, 현재 기기 등록 여부를 바로 확인합니다.</Text>
|
||||||
|
</div>
|
||||||
|
<div className="shared-app-settings-page__status-grid">
|
||||||
|
<div className="shared-app-settings-page__status-item">
|
||||||
|
<span>권한</span>
|
||||||
|
<strong>{getClientNotificationPermissionLabel(clientPermission)}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="shared-app-settings-page__status-item">
|
||||||
|
<span>PWA</span>
|
||||||
|
<strong>{isStandaloneDisplayMode() ? '실행 중' : '브라우저 탭'}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="shared-app-settings-page__status-item">
|
||||||
|
<span>보안 환경</span>
|
||||||
|
<strong>{hasSecureOrigin() ? 'HTTPS 준비' : '등록 불가'}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="shared-app-settings-page__status-item">
|
||||||
|
<span>서버 설정</span>
|
||||||
|
<strong>{webPushConfigured ? '활성' : '비활성'}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="shared-app-settings-page__status-item">
|
||||||
|
<span>기기 등록</span>
|
||||||
|
<strong>{webPushRegistered ? '완료' : '미등록'}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="shared-app-settings-page__status-item">
|
||||||
|
<span>기기 ID</span>
|
||||||
|
<strong>{getSavedNotificationDeviceId() || '-'}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="shared-app-settings-page__inline-actions">
|
||||||
|
<Button onClick={() => void syncWebPushStatus()} disabled={webPushLoading}>
|
||||||
|
상태 확인
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => void handleRequestNotificationPermission()} disabled={webPushLoading}>
|
||||||
|
권한 요청
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" onClick={() => void handleRegisterWebPush()} loading={webPushLoading}>
|
||||||
|
등록 실행
|
||||||
|
</Button>
|
||||||
|
<Button danger onClick={() => void handleUnregisterWebPush()} disabled={webPushLoading || !webPushRegistered}>
|
||||||
|
등록 해제
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card size="small" title="자동화 기본값">
|
{isAppleMobileDevice() && !isStandaloneDisplayMode() ? (
|
||||||
<Form.Item name={['planDefaults', 'jangsingProcessingRequired']} valuePropName="checked">
|
<Alert
|
||||||
<Checkbox>장싱 처리 필수</Checkbox>
|
showIcon
|
||||||
</Form.Item>
|
type="info"
|
||||||
<Form.Item name={['planDefaults', 'autoDeployToMain']} valuePropName="checked">
|
message="아이폰/아이패드는 홈 화면에 추가한 PWA에서만 웹 푸시를 등록할 수 있습니다."
|
||||||
<Checkbox>main 자동 반영</Checkbox>
|
/>
|
||||||
</Form.Item>
|
) : null}
|
||||||
<Form.Item name={['planDefaults', 'openEditorAfterCreate']} valuePropName="checked">
|
</div>
|
||||||
<Checkbox>생성 후 에디터 열기</Checkbox>
|
),
|
||||||
</Form.Item>
|
},
|
||||||
</Card>
|
{
|
||||||
|
key: 'storage',
|
||||||
|
label: '저장값',
|
||||||
|
children: (
|
||||||
|
<div className="shared-app-settings-page__panel">
|
||||||
|
<Tabs
|
||||||
|
activeKey={selectedStorageGroup?.key}
|
||||||
|
onChange={setStorageGroupKey}
|
||||||
|
className="shared-app-settings-page__subtabs"
|
||||||
|
items={STORAGE_GROUPS.map((group) => ({
|
||||||
|
key: group.key,
|
||||||
|
label: group.label,
|
||||||
|
children: (
|
||||||
|
<div className="shared-app-settings-page__section">
|
||||||
|
<div className="shared-app-settings-page__section-head">
|
||||||
|
<Text strong>{group.label}</Text>
|
||||||
|
<Text type="secondary">{group.description}</Text>
|
||||||
|
</div>
|
||||||
|
{storageFeedback ? <Alert showIcon type="info" message={storageFeedback} /> : null}
|
||||||
|
<div className="shared-app-settings-page__storage-list">
|
||||||
|
{group.entries.map((entry) => {
|
||||||
|
const storageItemKey = buildStorageItemKey(entry);
|
||||||
|
|
||||||
<Card size="small" title="업무일지 자동화">
|
return (
|
||||||
<Form.Item name={['worklogAutomation', 'autoCreateDailyWorklog']} valuePropName="checked">
|
<div key={storageItemKey} className="shared-app-settings-page__storage-item">
|
||||||
<Checkbox>일일 업무일지 자동 생성</Checkbox>
|
<div className="shared-app-settings-page__storage-head">
|
||||||
</Form.Item>
|
<div>
|
||||||
<Form.Item label="생성 시각" name={['worklogAutomation', 'dailyCreateTime']}>
|
<Text strong>{entry.label}</Text>
|
||||||
<Input placeholder="18:00" />
|
<Text type="secondary">{entry.description}</Text>
|
||||||
</Form.Item>
|
</div>
|
||||||
<Form.Item name={['worklogAutomation', 'includeScreenshots']} valuePropName="checked">
|
<Text type="secondary">{entry.scope === 'local' ? 'localStorage' : 'sessionStorage'}</Text>
|
||||||
<Checkbox>스크린샷 포함</Checkbox>
|
</div>
|
||||||
</Form.Item>
|
<Text code>{entry.storageKey}</Text>
|
||||||
<Form.Item name={['worklogAutomation', 'includeChangedFiles']} valuePropName="checked">
|
<Input.TextArea
|
||||||
<Checkbox>변경 파일 포함</Checkbox>
|
rows={4}
|
||||||
</Form.Item>
|
value={storageDrafts[storageItemKey] ?? ''}
|
||||||
<Form.Item name={['worklogAutomation', 'includeCommandLogs']} valuePropName="checked">
|
placeholder="값이 없으면 비워 둡니다."
|
||||||
<Checkbox>명령 로그 포함</Checkbox>
|
onChange={(event) => {
|
||||||
</Form.Item>
|
handleStorageDraftChange(entry, event.target.value);
|
||||||
<Form.Item label="템플릿" name={['worklogAutomation', 'template']}>
|
}}
|
||||||
<Select
|
/>
|
||||||
options={[
|
<div className="shared-app-settings-page__inline-actions">
|
||||||
{ value: 'simple', label: '간단' },
|
<Button size="small" onClick={() => handleReloadStorageEntry(entry)}>
|
||||||
{ value: 'detailed', label: '상세' },
|
다시 읽기
|
||||||
]}
|
</Button>
|
||||||
/>
|
<Button size="small" type="primary" onClick={() => handleSaveStorageEntry(entry)}>
|
||||||
</Form.Item>
|
저장
|
||||||
</Card>
|
</Button>
|
||||||
|
<Button size="small" danger onClick={() => handleClearStorageEntry(entry)}>
|
||||||
<Card size="small" title="비용 표시 / 단축키">
|
비우기
|
||||||
<Form.Item label="백만 토큰당 기본 비용" name={['planCost', 'baseCostPerMillionTokens']}>
|
</Button>
|
||||||
<InputNumber min={100} max={1000000} style={{ width: '100%' }} />
|
</div>
|
||||||
</Form.Item>
|
</div>
|
||||||
<Form.Item label="재시도 비용 배수(%)" name={['planCost', 'retryCostMultiplierPercent']}>
|
);
|
||||||
<InputNumber min={0} max={500} style={{ width: '100%' }} />
|
})}
|
||||||
</Form.Item>
|
</div>
|
||||||
<Form.Item label="시간 비용 배수(%)" name={['planCost', 'hourlyCostMultiplierPercent']}>
|
</div>
|
||||||
<InputNumber min={0} max={500} style={{ width: '100%' }} />
|
),
|
||||||
</Form.Item>
|
}))}
|
||||||
<Form.Item label="시간 단위" name={['planCost', 'timeCostUnit']}>
|
/>
|
||||||
<Select options={PLAN_COST_TIME_UNIT_OPTIONS} />
|
</div>
|
||||||
</Form.Item>
|
),
|
||||||
<Form.Item label="주의 배수" name={['planCost', 'attentionCostThresholdMultiplier']}>
|
},
|
||||||
<InputNumber min={0.1} max={100} step={0.1} style={{ width: '100%' }} />
|
]}
|
||||||
</Form.Item>
|
/>
|
||||||
<Form.Item label="경고 배수" name={['planCost', 'warningCostThresholdMultiplier']}>
|
|
||||||
<InputNumber min={0.1} max={100} step={0.1} style={{ width: '100%' }} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label="고비용 배수" name={['planCost', 'highCostThresholdMultiplier']}>
|
|
||||||
<InputNumber min={0.1} max={100} step={0.1} style={{ width: '100%' }} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label="검색 단축키" name={['gestureShortcuts', 'openSearch']}>
|
|
||||||
<Input />
|
|
||||||
</Form.Item>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<Text type="secondary">
|
|
||||||
알림 토큰 등록과 업데이트 확인처럼 현재 기기 상태가 필요한 항목은 공유 링크에서 제외했습니다.
|
|
||||||
</Text>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<ChatConversationDetailResponse>(
|
||||||
|
`/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() {
|
export async function fetchChatRuntimeSnapshot() {
|
||||||
const response = await requestChatApi<{ ok: boolean; item: ChatRuntimeSnapshot }>('/runtime');
|
const response = await requestChatApi<{ ok: boolean; item: ChatRuntimeSnapshot }>('/runtime');
|
||||||
return response.item;
|
return response.item;
|
||||||
@@ -2576,6 +2648,8 @@ export type ChatShareSnapshot = {
|
|||||||
processingCount: number;
|
processingCount: number;
|
||||||
unansweredCount: number;
|
unansweredCount: number;
|
||||||
};
|
};
|
||||||
|
oldestLoadedMessageId?: number | null;
|
||||||
|
hasOlderMessages?: boolean;
|
||||||
promptTarget?: {
|
promptTarget?: {
|
||||||
sourceMessageId: number;
|
sourceMessageId: number;
|
||||||
promptIndex: number;
|
promptIndex: number;
|
||||||
@@ -2799,6 +2873,8 @@ export async function fetchChatShareSnapshot(
|
|||||||
rooms?: ChatShareRoomSummary[];
|
rooms?: ChatShareRoomSummary[];
|
||||||
activeSessionId?: string | null;
|
activeSessionId?: string | null;
|
||||||
roomRequestCounts?: ChatShareSnapshot['roomRequestCounts'];
|
roomRequestCounts?: ChatShareSnapshot['roomRequestCounts'];
|
||||||
|
oldestLoadedMessageId?: number | null;
|
||||||
|
hasOlderMessages?: boolean;
|
||||||
promptTarget?: ChatShareSnapshot['promptTarget'];
|
promptTarget?: ChatShareSnapshot['promptTarget'];
|
||||||
refreshedAt: string;
|
refreshedAt: string;
|
||||||
}>(
|
}>(
|
||||||
@@ -2906,6 +2982,11 @@ export async function fetchChatShareSnapshot(
|
|||||||
unansweredCount: Number.isFinite(response.roomRequestCounts.unansweredCount) ? response.roomRequestCounts.unansweredCount : 0,
|
unansweredCount: Number.isFinite(response.roomRequestCounts.unansweredCount) ? response.roomRequestCounts.unansweredCount : 0,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
oldestLoadedMessageId:
|
||||||
|
Number.isFinite(response.oldestLoadedMessageId) && Number(response.oldestLoadedMessageId) > 0
|
||||||
|
? Number(response.oldestLoadedMessageId)
|
||||||
|
: null,
|
||||||
|
hasOlderMessages: response.hasOlderMessages === true,
|
||||||
promptTarget: response.promptTarget ?? null,
|
promptTarget: response.promptTarget ?? null,
|
||||||
refreshedAt: response.refreshedAt,
|
refreshedAt: response.refreshedAt,
|
||||||
} satisfies ChatShareSnapshot;
|
} satisfies ChatShareSnapshot;
|
||||||
|
|||||||
@@ -33,8 +33,10 @@
|
|||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
|
overscroll-behavior-x: none;
|
||||||
overscroll-behavior-y: contain;
|
overscroll-behavior-y: contain;
|
||||||
scrollbar-gutter: stable;
|
scrollbar-gutter: stable;
|
||||||
|
touch-action: pan-y;
|
||||||
scroll-padding-bottom: calc(var(--chat-share-page-bottom-padding) + var(--chat-share-page-active-safe-bottom));
|
scroll-padding-bottom: calc(var(--chat-share-page-bottom-padding) + var(--chat-share-page-active-safe-bottom));
|
||||||
padding:
|
padding:
|
||||||
calc(var(--chat-share-page-top-padding) + var(--chat-share-page-safe-top))
|
calc(var(--chat-share-page-top-padding) + var(--chat-share-page-safe-top))
|
||||||
@@ -912,6 +914,18 @@
|
|||||||
flex: 0 0 auto;
|
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 {
|
.chat-share-page__live-dot {
|
||||||
width: 10px;
|
width: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
@@ -1412,6 +1426,48 @@
|
|||||||
background: linear-gradient(180deg, #f8fbff 0%, #eef4fb 100%);
|
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 {
|
.chat-share-page__room-settings-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1541,9 +1597,10 @@
|
|||||||
|
|
||||||
.chat-share-page__process-inspector--fullscreen {
|
.chat-share-page__process-inspector--fullscreen {
|
||||||
inset: 0;
|
inset: 0;
|
||||||
width: 100vw;
|
width: auto;
|
||||||
max-height: 100dvh;
|
height: auto;
|
||||||
height: 100dvh;
|
max-width: none;
|
||||||
|
max-height: none;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
border-width: 0;
|
border-width: 0;
|
||||||
}
|
}
|
||||||
@@ -1672,6 +1729,49 @@
|
|||||||
overflow: auto;
|
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 {
|
.chat-share-page__process-inspector-section {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -2385,6 +2485,10 @@
|
|||||||
padding-top: calc(14px + env(safe-area-inset-top, 0px));
|
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 {
|
.chat-share-page__program-app-shell--system-chat-room {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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 { 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 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 { createPortal } from 'react-dom';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { FullscreenPreviewModal } from '../../../components/previewer';
|
import { FullscreenPreviewModal } from '../../../components/previewer';
|
||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
completeChatShareManualBadge,
|
completeChatShareManualBadge,
|
||||||
createChatShareRoom,
|
createChatShareRoom,
|
||||||
fetchChatConversationDetail,
|
fetchChatConversationDetail,
|
||||||
|
fetchChatShareConversationDetail,
|
||||||
fetchChatConversations,
|
fetchChatConversations,
|
||||||
deleteChatShareRoom,
|
deleteChatShareRoom,
|
||||||
fetchChatShareRuntimeSnapshot,
|
fetchChatShareRuntimeSnapshot,
|
||||||
@@ -82,6 +83,7 @@ import type {
|
|||||||
import { isPromptResolved } from '../mainChatPanel/promptState';
|
import { isPromptResolved } from '../mainChatPanel/promptState';
|
||||||
import { sendClientNotification, shouldFallbackToLocalNotification, showLocalClientNotification } from '../notificationApi';
|
import { sendClientNotification, shouldFallbackToLocalNotification, showLocalClientNotification } from '../notificationApi';
|
||||||
import { copyTextToClipboard } from '../../../utils/clipboard';
|
import { copyTextToClipboard } from '../../../utils/clipboard';
|
||||||
|
import { isTypingTarget } from '../mainView/utils';
|
||||||
import { applyViewportCssVars, scheduleViewportRecoverySync } from '../viewportCssVars';
|
import { applyViewportCssVars, scheduleViewportRecoverySync } from '../viewportCssVars';
|
||||||
import { isPreviewRuntime } from '../previewRuntime';
|
import { isPreviewRuntime } from '../previewRuntime';
|
||||||
import { createInstallManifestObjectUrl, swapInstallDocumentMetadata } from '../pwa/installManifest';
|
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_STORAGE_KEY_PREFIX = 'codex-live-share-room-snapshot:v1';
|
||||||
const SHARE_ROOM_SNAPSHOT_SESSION_CACHE_LIMIT = 6;
|
const SHARE_ROOM_SNAPSHOT_SESSION_CACHE_LIMIT = 6;
|
||||||
const SHARE_IMMEDIATE_SEND_TOGGLE_HOLD_MS = 1000;
|
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 = [
|
const SHARE_ACCESS_PIN_PROMPT_TTL_OPTIONS = [
|
||||||
{ value: 'always', label: '매번 묻기', minutes: 0 },
|
{ value: 'always', label: '매번 묻기', minutes: 0 },
|
||||||
{ value: '5', label: '5분 유지', minutes: 5 },
|
{ value: '5', label: '5분 유지', minutes: 5 },
|
||||||
@@ -177,6 +185,18 @@ type ShareSearchResult = {
|
|||||||
type ShareSearchIndexedResult = ShareSearchResult & {
|
type ShareSearchIndexedResult = ShareSearchResult & {
|
||||||
searchText: string;
|
searchText: string;
|
||||||
};
|
};
|
||||||
|
type ShareEdgeGestureTracking =
|
||||||
|
| {
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
direction: 'back';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
direction: 'apps';
|
||||||
|
opened: boolean;
|
||||||
|
};
|
||||||
type ShareSearchPanelMode = 'all' | 'apps';
|
type ShareSearchPanelMode = 'all' | 'apps';
|
||||||
type ShareWorkServerVersionStatus = 'latest' | 'unknown' | 'update-available' | 'build-required';
|
type ShareWorkServerVersionStatus = 'latest' | 'unknown' | 'update-available' | 'build-required';
|
||||||
type ClientNotificationPermissionState = 'unsupported' | 'default' | 'granted' | 'denied';
|
type ClientNotificationPermissionState = 'unsupported' | 'default' | 'granted' | 'denied';
|
||||||
@@ -1883,6 +1903,31 @@ function isMobileShareInputTarget(target: EventTarget | null): target is HTMLEle
|
|||||||
return Boolean(inputTarget) && isSnapshotDeferrableFocusTarget(inputTarget);
|
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() {
|
function getDefaultProgramMinimizedPosition() {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return { x: PROGRAM_MINIMIZED_VIEWPORT_PADDING, y: PROGRAM_MINIMIZED_VIEWPORT_PADDING };
|
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) {
|
function buildShareVisibleText(text: string) {
|
||||||
return stripHiddenPreviewTags(extractAttachmentPreviewUrls(text).strippedText).trim();
|
return stripHiddenPreviewTags(extractAttachmentPreviewUrls(text).strippedText).trim();
|
||||||
}
|
}
|
||||||
@@ -4014,8 +4091,11 @@ export function ChatSharePage() {
|
|||||||
const [snapshot, setSnapshot] = useState<ChatShareSnapshot | null>(initialCachedSnapshot);
|
const [snapshot, setSnapshot] = useState<ChatShareSnapshot | null>(initialCachedSnapshot);
|
||||||
const [requestedRoomSessionId, setRequestedRoomSessionId] = useState<string>(initialRequestedRoomSessionId);
|
const [requestedRoomSessionId, setRequestedRoomSessionId] = useState<string>(initialRequestedRoomSessionId);
|
||||||
const requestedRoomSessionIdRef = useRef(requestedRoomSessionId);
|
const requestedRoomSessionIdRef = useRef(requestedRoomSessionId);
|
||||||
|
const skipNextRequestedRoomRefreshRef = useRef(false);
|
||||||
const [isLoading, setIsLoading] = useState(() => initialCachedSnapshot == null);
|
const [isLoading, setIsLoading] = useState(() => initialCachedSnapshot == null);
|
||||||
const [, setIsRefreshing] = useState(false);
|
const [, setIsRefreshing] = useState(false);
|
||||||
|
const [isLoadingFullSnapshot, setIsLoadingFullSnapshot] = useState(false);
|
||||||
|
const [isLoadingOlderShareHistory, setIsLoadingOlderShareHistory] = useState(false);
|
||||||
const [isLiveConnected, setIsLiveConnected] = useState(false);
|
const [isLiveConnected, setIsLiveConnected] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
const [requiresAccessPin, setRequiresAccessPin] = useState(false);
|
const [requiresAccessPin, setRequiresAccessPin] = useState(false);
|
||||||
@@ -4848,12 +4928,6 @@ export function ChatSharePage() {
|
|||||||
setRequiresAccessPin(false);
|
setRequiresAccessPin(false);
|
||||||
setAccessPinSubmitError('');
|
setAccessPinSubmitError('');
|
||||||
|
|
||||||
if (initialLoad && nextSnapshot.detailLevel === 'initial') {
|
|
||||||
window.setTimeout(() => {
|
|
||||||
void refreshSnapshot({ silent: true });
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ChatApiError && error.status === 401 && (error.code === 'share_pin_required' || error.code === 'share_pin_invalid')) {
|
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;
|
snapshotRefreshPromiseRef.current = refreshTask;
|
||||||
return refreshTask;
|
return refreshTask;
|
||||||
}, [normalizedToken]);
|
}, [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) => {
|
const handleDeleteShareRoom = useCallback(async (room: ChatShareRoomSummary) => {
|
||||||
if (!normalizedToken || !room.sessionId || isDeletingRoom) {
|
if (!normalizedToken || !room.sessionId || isDeletingRoom) {
|
||||||
return;
|
return;
|
||||||
@@ -5619,6 +5729,39 @@ export function ChatSharePage() {
|
|||||||
captureTarget.setPointerCapture(event.pointerId);
|
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<HTMLElement>) => {
|
||||||
|
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) => {
|
const handleRestoreProgram = useCallback((targetKey: string) => {
|
||||||
let restoredTarget: ShareProgramTarget | null = null;
|
let restoredTarget: ShareProgramTarget | null = null;
|
||||||
|
|
||||||
@@ -5693,8 +5836,13 @@ export function ChatSharePage() {
|
|||||||
size="small"
|
size="small"
|
||||||
icon={<AppstoreOutlined />}
|
icon={<AppstoreOutlined />}
|
||||||
className="chat-share-page__program-minimized-button"
|
className="chat-share-page__program-minimized-button"
|
||||||
onClick={() => {
|
onPointerDown={handleProgramMinimizedActionPointerDown}
|
||||||
handleRestoreProgram(item.target.key);
|
onClick={(event: ReactMouseEvent<HTMLElement>) => {
|
||||||
|
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"
|
className="chat-share-page__program-minimized-icon chat-share-page__program-minimized-close"
|
||||||
icon={<CloseOutlined />}
|
icon={<CloseOutlined />}
|
||||||
aria-label="프로그램 닫기"
|
aria-label="프로그램 닫기"
|
||||||
onClick={() => {
|
onPointerDown={handleProgramMinimizedActionPointerDown}
|
||||||
handleCloseMinimizedProgram(item.target.key);
|
onClick={(event: ReactMouseEvent<HTMLElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
runProgramMinimizedActionAfterPointerCycle(() => {
|
||||||
|
handleCloseMinimizedProgram(item.target.key);
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -5979,6 +6132,11 @@ export function ChatSharePage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (skipNextRequestedRoomRefreshRef.current) {
|
||||||
|
skipNextRequestedRoomRefreshRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
void refreshSnapshot({ silent: true });
|
void refreshSnapshot({ silent: true });
|
||||||
}, [normalizedToken, refreshSnapshot, requestedRoomSessionId]);
|
}, [normalizedToken, refreshSnapshot, requestedRoomSessionId]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -6001,10 +6159,11 @@ export function ChatSharePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
requestedRoomSessionIdRef.current = stabilizedRoomSessionId;
|
requestedRoomSessionIdRef.current = stabilizedRoomSessionId;
|
||||||
|
skipNextRequestedRoomRefreshRef.current = snapshot?.detailLevel === 'initial';
|
||||||
writeStoredShareLastRoomSessionId(normalizedToken, stabilizedRoomSessionId);
|
writeStoredShareLastRoomSessionId(normalizedToken, stabilizedRoomSessionId);
|
||||||
writeShareRoomSessionIdToLocation(stabilizedRoomSessionId, 'replace');
|
writeShareRoomSessionIdToLocation(stabilizedRoomSessionId, 'replace');
|
||||||
setRequestedRoomSessionId(stabilizedRoomSessionId);
|
setRequestedRoomSessionId(stabilizedRoomSessionId);
|
||||||
}, [activeShareRoomSessionId, normalizedToken, shareRooms]);
|
}, [activeShareRoomSessionId, normalizedToken, shareRooms, snapshot?.detailLevel]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!requestedRoomSessionId) {
|
if (!requestedRoomSessionId) {
|
||||||
@@ -6534,7 +6693,7 @@ export function ChatSharePage() {
|
|||||||
if (cachedSnapshot) {
|
if (cachedSnapshot) {
|
||||||
setSnapshot(cachedSnapshot);
|
setSnapshot(cachedSnapshot);
|
||||||
}
|
}
|
||||||
setIsRoomSwitching(true);
|
setIsRoomSwitching(!cachedSnapshot);
|
||||||
requestedRoomSessionIdRef.current = normalizedSessionId;
|
requestedRoomSessionIdRef.current = normalizedSessionId;
|
||||||
writeStoredShareLastRoomSessionId(normalizedToken, normalizedSessionId);
|
writeStoredShareLastRoomSessionId(normalizedToken, normalizedSessionId);
|
||||||
writeShareRoomSessionIdToLocation(normalizedSessionId, 'push');
|
writeShareRoomSessionIdToLocation(normalizedSessionId, 'push');
|
||||||
@@ -7740,7 +7899,15 @@ export function ChatSharePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="chat-share-page__process-inspector-sections">
|
<div
|
||||||
|
className={[
|
||||||
|
'chat-share-page__process-inspector-sections',
|
||||||
|
processInspectorMode === 'fullscreen' ? 'chat-share-page__process-inspector-sections--fullscreen' : '',
|
||||||
|
isProcessInspectorChecklistCollapsed ? 'chat-share-page__process-inspector-sections--checklist-collapsed' : '',
|
||||||
|
isProcessInspectorNarrativesCollapsed ? 'chat-share-page__process-inspector-sections--narratives-collapsed' : '',
|
||||||
|
isProcessInspectorLogCollapsed ? 'chat-share-page__process-inspector-sections--log-collapsed' : '',
|
||||||
|
].filter(Boolean).join(' ')}
|
||||||
|
>
|
||||||
<section className="chat-share-page__process-inspector-section chat-share-page__process-inspector-section--checklist">
|
<section className="chat-share-page__process-inspector-section chat-share-page__process-inspector-section--checklist">
|
||||||
<div className="chat-share-page__process-inspector-section-head">
|
<div className="chat-share-page__process-inspector-section-head">
|
||||||
<Text strong>계획 체크리스트</Text>
|
<Text strong>계획 체크리스트</Text>
|
||||||
@@ -7916,8 +8083,10 @@ export function ChatSharePage() {
|
|||||||
try {
|
try {
|
||||||
const roomSnapshot = await fetchChatShareSnapshot(normalizedToken, {
|
const roomSnapshot = await fetchChatShareSnapshot(normalizedToken, {
|
||||||
sessionId: normalizedSessionId,
|
sessionId: normalizedSessionId,
|
||||||
|
view: 'initial',
|
||||||
sharePin,
|
sharePin,
|
||||||
});
|
});
|
||||||
|
writeStoredShareRoomSnapshot(normalizedToken, roomSnapshot);
|
||||||
|
|
||||||
setShareRoomPendingCountsBySessionId((current) => {
|
setShareRoomPendingCountsBySessionId((current) => {
|
||||||
const nextCounts = resolveShareRoomPendingCounts(roomSnapshot);
|
const nextCounts = resolveShareRoomPendingCounts(roomSnapshot);
|
||||||
@@ -8394,6 +8563,7 @@ export function ChatSharePage() {
|
|||||||
? renderEmbeddedSharePlayApp(programTarget.appId, closeProgramTarget, normalizedToken)
|
? renderEmbeddedSharePlayApp(programTarget.appId, closeProgramTarget, normalizedToken)
|
||||||
: null;
|
: null;
|
||||||
const isServerCommandDrawerOpen = programTarget?.appId === 'server-command';
|
const isServerCommandDrawerOpen = programTarget?.appId === 'server-command';
|
||||||
|
const isAppSettingsDrawerOpen = programTarget?.appId === 'app-settings';
|
||||||
const indexedContentSearchResults = useMemo<ShareSearchIndexedResult[]>(() => {
|
const indexedContentSearchResults = useMemo<ShareSearchIndexedResult[]>(() => {
|
||||||
const results: ShareSearchIndexedResult[] = [];
|
const results: ShareSearchIndexedResult[] = [];
|
||||||
|
|
||||||
@@ -8832,19 +9002,19 @@ export function ChatSharePage() {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...(canCreateSharedRooms
|
...(shareAllowedAppIdSet.has('app-settings')
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
key: 'conversation-room-create',
|
key: 'conversation-personal-settings',
|
||||||
label: (
|
label: (
|
||||||
<span className="chat-share-page__settings-item">
|
<span className="chat-share-page__settings-item">
|
||||||
<span className="chat-share-page__settings-item-title">채팅방 추가</span>
|
<span className="chat-share-page__settings-item-title">개인설정</span>
|
||||||
<span className="chat-share-page__settings-item-description">
|
<span className="chat-share-page__settings-item-description">
|
||||||
같은 공유 토큰 안에 새 채팅방을 만들고 바로 전환합니다.
|
앱 전체 웹 푸시, 유도 문구, 클라이언트 저장값을 탭으로 관리합니다.
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
icon: <PlusOutlined />,
|
icon: <SettingOutlined />,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
@@ -8898,11 +9068,12 @@ export function ChatSharePage() {
|
|||||||
allowedPlayAppEntries.length,
|
allowedPlayAppEntries.length,
|
||||||
canSendMessage,
|
canSendMessage,
|
||||||
canOpenSharedRoomSettings,
|
canOpenSharedRoomSettings,
|
||||||
canCreateSharedRooms,
|
|
||||||
hasWorkServerCommandApp,
|
hasWorkServerCommandApp,
|
||||||
isClearingConversation,
|
isClearingConversation,
|
||||||
normalizedToken,
|
normalizedToken,
|
||||||
selectedTokenUsageSetting,
|
selectedTokenUsageSetting,
|
||||||
|
shareAllowedAppIdSet,
|
||||||
|
sharePermissionSet,
|
||||||
shareWorkServerCommand,
|
shareWorkServerCommand,
|
||||||
snapshot?.conversation.title,
|
snapshot?.conversation.title,
|
||||||
selectedShareRoomSessionId,
|
selectedShareRoomSessionId,
|
||||||
@@ -8911,6 +9082,12 @@ export function ChatSharePage() {
|
|||||||
tokenUsageOverview.fiveHourCountdownLabel,
|
tokenUsageOverview.fiveHourCountdownLabel,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
const openShareAppsPanel = useCallback(() => {
|
||||||
|
setSearchPanelMode('apps');
|
||||||
|
setSearchKeyword('');
|
||||||
|
setIsShareRoomListVisible(false);
|
||||||
|
setIsSearchOpen(true);
|
||||||
|
}, []);
|
||||||
const handleShareHeaderSettingsClick = useCallback<NonNullable<MenuProps['onClick']>>(
|
const handleShareHeaderSettingsClick = useCallback<NonNullable<MenuProps['onClick']>>(
|
||||||
({ key }) => {
|
({ key }) => {
|
||||||
if (key === 'conversation-search') {
|
if (key === 'conversation-search') {
|
||||||
@@ -8926,9 +9103,7 @@ export function ChatSharePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (key === 'conversation-apps') {
|
if (key === 'conversation-apps') {
|
||||||
setSearchPanelMode('apps');
|
openShareAppsPanel();
|
||||||
setSearchKeyword('');
|
|
||||||
setIsSearchOpen(true);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8942,8 +9117,8 @@ export function ChatSharePage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === 'conversation-room-create') {
|
if (key === 'conversation-personal-settings') {
|
||||||
openCreateRoomDialog();
|
openProgramTarget(buildShareManagementProgramTarget('app-settings', '개인설정'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8956,8 +9131,127 @@ export function ChatSharePage() {
|
|||||||
void handleClearConversation();
|
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<MenuProps['items']>(
|
const shareExpandModeMenuItems = useMemo<MenuProps['items']>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -9363,6 +9657,28 @@ export function ChatSharePage() {
|
|||||||
<Text type="secondary" className="chat-share-page__header-summary">
|
<Text type="secondary" className="chat-share-page__header-summary">
|
||||||
{headerSummaryLabel}
|
{headerSummaryLabel}
|
||||||
</Text>
|
</Text>
|
||||||
|
{snapshot.hasOlderMessages ? (
|
||||||
|
<Alert
|
||||||
|
showIcon
|
||||||
|
type="info"
|
||||||
|
className="chat-share-page__initial-load-alert"
|
||||||
|
message="최근 대화만 먼저 불러왔습니다."
|
||||||
|
description="초기 진입 속도를 위해 최신 구간만 표시 중입니다. 필요할 때만 이전 대화를 페이지 단위로 더 불러오세요."
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
icon={<ThunderboltOutlined />}
|
||||||
|
loading={isLoadingFullSnapshot || isLoadingOlderShareHistory}
|
||||||
|
onClick={() => {
|
||||||
|
void handleLoadOlderShareHistory();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
이전 대화 더 불러오기
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="chat-share-page__section-actions">
|
<div className="chat-share-page__section-actions">
|
||||||
{canToggleShareRoomList ? (
|
{canToggleShareRoomList ? (
|
||||||
@@ -10724,8 +11040,36 @@ export function ChatSharePage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
<Drawer
|
||||||
|
open={isAppSettingsDrawerOpen}
|
||||||
|
title="개인설정"
|
||||||
|
rootClassName="chat-share-page__app-settings-drawer-shell"
|
||||||
|
className="chat-share-page__app-settings-drawer"
|
||||||
|
placement={isServerCommandDrawerMobile ? 'bottom' : 'right'}
|
||||||
|
width={isServerCommandDrawerMobile ? undefined : '100vw'}
|
||||||
|
height={isServerCommandDrawerMobile ? '100dvh' : undefined}
|
||||||
|
maskClosable={false}
|
||||||
|
destroyOnHidden={false}
|
||||||
|
extra={(
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
className="fullscreen-preview-modal__icon-button"
|
||||||
|
aria-label="개인설정 새로고침"
|
||||||
|
title="개인설정 새로고침"
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={handleReloadProgram}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
onClose={closeProgramTarget}
|
||||||
|
>
|
||||||
|
{isAppSettingsDrawerOpen ? (
|
||||||
|
<div className="chat-share-page__app-settings-drawer-body">
|
||||||
|
<SharedAppSettingsPage shareToken={normalizedToken} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Drawer>
|
||||||
<FullscreenPreviewModal
|
<FullscreenPreviewModal
|
||||||
open={Boolean(programTarget) && !isServerCommandDrawerOpen}
|
open={Boolean(programTarget) && !isServerCommandDrawerOpen && !isAppSettingsDrawerOpen}
|
||||||
title={programTarget?.label ?? '공유 프로그램'}
|
title={programTarget?.label ?? '공유 프로그램'}
|
||||||
meta={programTarget?.meta ?? '공유 토큰 실행'}
|
meta={programTarget?.meta ?? '공유 토큰 실행'}
|
||||||
actions={
|
actions={
|
||||||
@@ -10825,13 +11169,6 @@ export function ChatSharePage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : programTarget.appId === 'app-settings' ? (
|
|
||||||
<div
|
|
||||||
key={`${programTarget.key}:${programReloadKey}`}
|
|
||||||
className="chat-share-page__program-app-shell chat-share-page__program-app-shell--surface"
|
|
||||||
>
|
|
||||||
<SharedAppSettingsPage shareToken={normalizedToken} />
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div key={`${programTarget.key}:${programReloadKey}`} className="chat-share-page__program-app-shell">
|
<div key={`${programTarget.key}:${programReloadKey}`} className="chat-share-page__program-app-shell">
|
||||||
<ChatPreviewBody
|
<ChatPreviewBody
|
||||||
|
|||||||
Reference in New Issue
Block a user