chore: test deploy snapshot
This commit is contained in:
@@ -12,6 +12,7 @@ import { isPreviewRuntime } from './previewRuntime';
|
||||
export function AppShell() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/shares/:token" element={<ChatSharePage />} />
|
||||
<Route path="/chat-share/:token" element={<ChatSharePage />} />
|
||||
<Route path="/chat/share/:token" element={<ChatSharePage />} />
|
||||
<Route path="/" element={<MainLayout />}>
|
||||
|
||||
@@ -257,6 +257,9 @@ function areChatSettingsEqual(left: AppConfig['chat'], right: AppConfig['chat'])
|
||||
left.codexLiveMaxExecutionSeconds === right.codexLiveMaxExecutionSeconds &&
|
||||
left.codexLiveIdleTimeoutSeconds === right.codexLiveIdleTimeoutSeconds &&
|
||||
left.receiveRoomNotifications === right.receiveRoomNotifications &&
|
||||
left.guidePwaInstallForNotifications === right.guidePwaInstallForNotifications &&
|
||||
left.guideWebPushPermission === right.guideWebPushPermission &&
|
||||
left.guideWebPushRegistration === right.guideWebPushRegistration &&
|
||||
left.restartReservationCompletionDelaySeconds === right.restartReservationCompletionDelaySeconds
|
||||
);
|
||||
}
|
||||
@@ -284,6 +287,18 @@ function getChatSettingsDiffLabels(saved: AppConfig['chat'], draft: AppConfig['c
|
||||
changedLabels.push('채팅방 알림 수신');
|
||||
}
|
||||
|
||||
if (saved.guidePwaInstallForNotifications !== draft.guidePwaInstallForNotifications) {
|
||||
changedLabels.push('PWA 설치 유도');
|
||||
}
|
||||
|
||||
if (saved.guideWebPushPermission !== draft.guideWebPushPermission) {
|
||||
changedLabels.push('웹푸시 권한 유도');
|
||||
}
|
||||
|
||||
if (saved.guideWebPushRegistration !== draft.guideWebPushRegistration) {
|
||||
changedLabels.push('웹푸시 등록 유도');
|
||||
}
|
||||
|
||||
if (saved.restartReservationCompletionDelaySeconds !== draft.restartReservationCompletionDelaySeconds) {
|
||||
changedLabels.push('재기동 예약 자동 실행 대기 시간');
|
||||
}
|
||||
@@ -3522,8 +3537,8 @@ export function MainHeader({
|
||||
message={chatSettingsDirty ? '채팅 문맥 설정에 저장되지 않은 변경이 있습니다.' : '채팅 문맥 설정이 저장되었습니다.'}
|
||||
description={
|
||||
chatSettingsDirty
|
||||
? `변경 항목: ${chatSettingsDiffLabels.join(', ')} / DB 저장값 기준: 최근 ${appConfig.chat.maxContextMessages}개, ${appConfig.chat.maxContextChars}자, 최대 실행 ${appConfig.chat.codexLiveMaxExecutionSeconds}초, 무출력 실패 ${appConfig.chat.codexLiveIdleTimeoutSeconds}초, 채팅방 알림 ${appConfig.chat.receiveRoomNotifications ? '수신' : '차단'}, 재기동 예약 자동 실행 대기 ${appConfig.chat.restartReservationCompletionDelaySeconds}초 / 편집 중: 최근 ${appConfigDraft.chat.maxContextMessages}개, ${appConfigDraft.chat.maxContextChars}자, 최대 실행 ${appConfigDraft.chat.codexLiveMaxExecutionSeconds}초, 무출력 실패 ${appConfigDraft.chat.codexLiveIdleTimeoutSeconds}초, 채팅방 알림 ${appConfigDraft.chat.receiveRoomNotifications ? '수신' : '차단'}, 재기동 예약 자동 실행 대기 ${appConfigDraft.chat.restartReservationCompletionDelaySeconds}초`
|
||||
: `현재 저장값: 최근 ${appConfig.chat.maxContextMessages}개 메시지, 최대 ${appConfig.chat.maxContextChars}자까지 채팅 문맥으로 참조하고 Codex Live 실행 시간은 최대 ${appConfig.chat.codexLiveMaxExecutionSeconds}초, 무출력 실패 시간은 ${appConfig.chat.codexLiveIdleTimeoutSeconds}초이며 채팅방 알림은 ${appConfig.chat.receiveRoomNotifications ? '수신' : '차단'} 상태, 재기동 예약 자동 실행 대기 시간은 ${appConfig.chat.restartReservationCompletionDelaySeconds}초`
|
||||
? `변경 항목: ${chatSettingsDiffLabels.join(', ')} / DB 저장값 기준: 최근 ${appConfig.chat.maxContextMessages}개, ${appConfig.chat.maxContextChars}자, 최대 실행 ${appConfig.chat.codexLiveMaxExecutionSeconds}초, 무출력 실패 ${appConfig.chat.codexLiveIdleTimeoutSeconds}초, 채팅방 알림 ${appConfig.chat.receiveRoomNotifications ? '수신' : '차단'}, PWA 유도 ${appConfig.chat.guidePwaInstallForNotifications ? '사용' : '끔'}, 권한 유도 ${appConfig.chat.guideWebPushPermission ? '사용' : '끔'}, 등록 유도 ${appConfig.chat.guideWebPushRegistration ? '사용' : '끔'}, 재기동 예약 자동 실행 대기 ${appConfig.chat.restartReservationCompletionDelaySeconds}초 / 편집 중: 최근 ${appConfigDraft.chat.maxContextMessages}개, ${appConfigDraft.chat.maxContextChars}자, 최대 실행 ${appConfigDraft.chat.codexLiveMaxExecutionSeconds}초, 무출력 실패 ${appConfigDraft.chat.codexLiveIdleTimeoutSeconds}초, 채팅방 알림 ${appConfigDraft.chat.receiveRoomNotifications ? '수신' : '차단'}, PWA 유도 ${appConfigDraft.chat.guidePwaInstallForNotifications ? '사용' : '끔'}, 권한 유도 ${appConfigDraft.chat.guideWebPushPermission ? '사용' : '끔'}, 등록 유도 ${appConfigDraft.chat.guideWebPushRegistration ? '사용' : '끔'}, 재기동 예약 자동 실행 대기 ${appConfigDraft.chat.restartReservationCompletionDelaySeconds}초`
|
||||
: `현재 저장값: 최근 ${appConfig.chat.maxContextMessages}개 메시지, 최대 ${appConfig.chat.maxContextChars}자까지 채팅 문맥으로 참조하고 Codex Live 실행 시간은 최대 ${appConfig.chat.codexLiveMaxExecutionSeconds}초, 무출력 실패 시간은 ${appConfig.chat.codexLiveIdleTimeoutSeconds}초이며 채팅방 알림은 ${appConfig.chat.receiveRoomNotifications ? '수신' : '차단'} 상태, PWA 유도는 ${appConfig.chat.guidePwaInstallForNotifications ? '사용' : '끔'}, 권한 유도는 ${appConfig.chat.guideWebPushPermission ? '사용' : '끔'}, 등록 유도는 ${appConfig.chat.guideWebPushRegistration ? '사용' : '끔'}, 재기동 예약 자동 실행 대기 시간은 ${appConfig.chat.restartReservationCompletionDelaySeconds}초`
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -3592,6 +3607,66 @@ export function MainHeader({
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Checkbox
|
||||
checked={appConfigDraft.chat.guidePwaInstallForNotifications}
|
||||
onChange={(event) => {
|
||||
setAppConfigDraft((current) => ({
|
||||
...current,
|
||||
chat: {
|
||||
...current.chat,
|
||||
guidePwaInstallForNotifications: event.target.checked,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
>
|
||||
PWA 설치 유도
|
||||
</Checkbox>
|
||||
<Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||
브라우저 탭에서 알림 설정이 막히는 환경이면 홈 화면 앱으로 열도록 안내하는 개인 기본값입니다.
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Checkbox
|
||||
checked={appConfigDraft.chat.guideWebPushPermission}
|
||||
onChange={(event) => {
|
||||
setAppConfigDraft((current) => ({
|
||||
...current,
|
||||
chat: {
|
||||
...current.chat,
|
||||
guideWebPushPermission: event.target.checked,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
>
|
||||
웹푸시 권한 유도
|
||||
</Checkbox>
|
||||
<Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||
알림 권한이 미확인 상태면 브라우저 또는 기기 권한 허용을 먼저 안내하는 개인 기본값입니다.
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Checkbox
|
||||
checked={appConfigDraft.chat.guideWebPushRegistration}
|
||||
onChange={(event) => {
|
||||
setAppConfigDraft((current) => ({
|
||||
...current,
|
||||
chat: {
|
||||
...current.chat,
|
||||
guideWebPushRegistration: event.target.checked,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
>
|
||||
웹푸시 등록 유도
|
||||
</Checkbox>
|
||||
<Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||
권한은 허용됐지만 현재 기기 구독이 없으면 웹푸시 등록까지 이어서 안내하는 개인 기본값입니다.
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong>Codex Live 최대 실행 시간(초)</Text>
|
||||
<Paragraph type="secondary">Codex Live 요청 1건이 강제 종료되기 전까지 허용할 최대 실행 시간입니다.</Paragraph>
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
saveAppConfigToServer,
|
||||
type AppConfig,
|
||||
type PlanCostTimeUnit,
|
||||
type WeeklyScheduleDay,
|
||||
} from './appConfig';
|
||||
import './SharedAppSettingsPage.css';
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ export type AppConfig = {
|
||||
codexLiveMaxExecutionSeconds: number;
|
||||
codexLiveIdleTimeoutSeconds: number;
|
||||
receiveRoomNotifications: boolean;
|
||||
guidePwaInstallForNotifications: boolean;
|
||||
guideWebPushPermission: boolean;
|
||||
guideWebPushRegistration: boolean;
|
||||
restartReservationCompletionDelaySeconds: number;
|
||||
};
|
||||
automation: {
|
||||
@@ -78,6 +81,9 @@ export const DEFAULT_APP_CONFIG: AppConfig = {
|
||||
codexLiveMaxExecutionSeconds: 600,
|
||||
codexLiveIdleTimeoutSeconds: 180,
|
||||
receiveRoomNotifications: true,
|
||||
guidePwaInstallForNotifications: true,
|
||||
guideWebPushPermission: true,
|
||||
guideWebPushRegistration: true,
|
||||
restartReservationCompletionDelaySeconds: 10,
|
||||
},
|
||||
automation: {
|
||||
@@ -311,6 +317,18 @@ function normalizeConfig(raw?: Partial<AppConfig>): AppConfig {
|
||||
chat?.receiveRoomNotifications,
|
||||
DEFAULT_APP_CONFIG.chat.receiveRoomNotifications,
|
||||
),
|
||||
guidePwaInstallForNotifications: normalizeBooleanValue(
|
||||
chat?.guidePwaInstallForNotifications,
|
||||
DEFAULT_APP_CONFIG.chat.guidePwaInstallForNotifications,
|
||||
),
|
||||
guideWebPushPermission: normalizeBooleanValue(
|
||||
chat?.guideWebPushPermission,
|
||||
DEFAULT_APP_CONFIG.chat.guideWebPushPermission,
|
||||
),
|
||||
guideWebPushRegistration: normalizeBooleanValue(
|
||||
chat?.guideWebPushRegistration,
|
||||
DEFAULT_APP_CONFIG.chat.guideWebPushRegistration,
|
||||
),
|
||||
restartReservationCompletionDelaySeconds: normalizeRestartReservationCompletionDelaySeconds(
|
||||
chat?.restartReservationCompletionDelaySeconds,
|
||||
DEFAULT_APP_CONFIG.chat.restartReservationCompletionDelaySeconds,
|
||||
|
||||
@@ -80,6 +80,7 @@ export type PromptSubmitPayload = {
|
||||
const PROMPT_FREE_TEXT_ONLY_LABEL = '선택 없이 기타 요청';
|
||||
const PROMPT_PARENT_QUESTION_MAX_LENGTH = 400;
|
||||
const PROMPT_PARENT_QUESTION_PREVIEW_MAX_LENGTH = 140;
|
||||
const PROMPT_PREVIEW_CONTENT_CACHE = new Map<string, { content: string | null; contentType: string | null }>();
|
||||
|
||||
function buildOptionSelectionText(options: PromptOption[], selectedValues: string[]) {
|
||||
const selectedOptions = options.filter((option) => selectedValues.includes(option.value));
|
||||
@@ -640,20 +641,23 @@ function usePromptPreviewContent(preview: PromptPreview | null | undefined) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const normalizedPreviewUrl = resolvePromptPreviewUrl(preview?.url);
|
||||
const previewContent = preview?.content ?? null;
|
||||
const previewType = preview?.type ?? null;
|
||||
const hasPreview = Boolean(preview);
|
||||
|
||||
useEffect(() => {
|
||||
setRemoteContent(preview?.content ?? null);
|
||||
setRemoteContent(previewContent);
|
||||
setRemoteContentType(null);
|
||||
setLoadError('');
|
||||
|
||||
const shouldFetchTextPreview =
|
||||
preview?.type === 'markdown' || preview?.type === 'html';
|
||||
previewType === 'markdown' || previewType === 'html';
|
||||
const shouldInspectResourcePreview =
|
||||
preview?.type === 'resource' && normalizedPreviewUrl && canRenderFramePreview(normalizedPreviewUrl);
|
||||
previewType === 'resource' && normalizedPreviewUrl && canRenderFramePreview(normalizedPreviewUrl);
|
||||
|
||||
if (
|
||||
!preview ||
|
||||
preview.content ||
|
||||
!hasPreview ||
|
||||
previewContent ||
|
||||
!normalizedPreviewUrl ||
|
||||
(!shouldFetchTextPreview && !shouldInspectResourcePreview)
|
||||
) {
|
||||
@@ -661,7 +665,17 @@ function usePromptPreviewContent(preview: PromptPreview | null | undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cachedPreview = PROMPT_PREVIEW_CONTENT_CACHE.get(`${previewType ?? 'unknown'}::${normalizedPreviewUrl}`);
|
||||
|
||||
if (cachedPreview) {
|
||||
setRemoteContent(cachedPreview.content);
|
||||
setRemoteContentType(cachedPreview.contentType);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
let resolvedContentType: string | null = null;
|
||||
setIsLoading(true);
|
||||
|
||||
void fetch(normalizedPreviewUrl, {
|
||||
@@ -674,6 +688,7 @@ function usePromptPreviewContent(preview: PromptPreview | null | undefined) {
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
resolvedContentType = contentType;
|
||||
|
||||
if (!controller.signal.aborted) {
|
||||
setRemoteContentType(contentType);
|
||||
@@ -687,6 +702,10 @@ function usePromptPreviewContent(preview: PromptPreview | null | undefined) {
|
||||
})
|
||||
.then((text) => {
|
||||
if (!controller.signal.aborted) {
|
||||
PROMPT_PREVIEW_CONTENT_CACHE.set(`${previewType ?? 'unknown'}::${normalizedPreviewUrl}`, {
|
||||
content: text,
|
||||
contentType: resolvedContentType,
|
||||
});
|
||||
setRemoteContent(text);
|
||||
}
|
||||
})
|
||||
@@ -704,7 +723,7 @@ function usePromptPreviewContent(preview: PromptPreview | null | undefined) {
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [normalizedPreviewUrl, preview]);
|
||||
}, [hasPreview, normalizedPreviewUrl, previewContent, previewType]);
|
||||
|
||||
return { remoteContent, remoteContentType, isLoading, loadError };
|
||||
}
|
||||
@@ -872,7 +891,7 @@ function PromptPreviewCard({
|
||||
|
||||
useEffect(() => {
|
||||
onPreviewViewed?.(option);
|
||||
}, [onPreviewViewed, option]);
|
||||
}, [normalizedPreviewUrl, onPreviewViewed, option.label, option.value]);
|
||||
|
||||
const handleSharePreview = (event: ReactKeyboardEvent | ReactMouseEvent<HTMLElement>) => {
|
||||
event.stopPropagation();
|
||||
@@ -1241,18 +1260,25 @@ export function ChatPromptCard({
|
||||
const progressPayload = buildPromptSelectionPayloadWithAttachments(target, stepSelections, attachments);
|
||||
const expandAllOptionPreviews = shouldExpandAllPromptPreviews(activeStep, activeSelection, isLocked);
|
||||
const expandedOptionPreviewUrl = expandedOption?.preview ? resolvePromptPreviewUrl(expandedOption.preview.url) : '';
|
||||
const onSelectionChangeRef = useRef(onSelectionChange);
|
||||
|
||||
useEffect(() => {
|
||||
onSelectionChangeRef.current = onSelectionChange;
|
||||
}, [onSelectionChange]);
|
||||
|
||||
const emitSelectionChange = (nextSelections: Record<string, PromptStepDraftSelection>) => {
|
||||
if (!onSelectionChange) {
|
||||
const selectionChangeHandler = onSelectionChangeRef.current;
|
||||
|
||||
if (!selectionChangeHandler) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLocked) {
|
||||
onSelectionChange(null);
|
||||
selectionChangeHandler(null);
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectionChange(buildPromptSelectionPayloadWithAttachments(target, nextSelections, attachments));
|
||||
selectionChangeHandler(buildPromptSelectionPayloadWithAttachments(target, nextSelections, attachments));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1287,17 +1313,16 @@ export function ChatPromptCard({
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectionChange(buildPromptSelectionPayloadWithAttachments(target, stepSelections, attachments));
|
||||
}, [attachments, isLocked, onSelectionChange, stepSelections, target]);
|
||||
emitSelectionChange(stepSelections);
|
||||
}, [attachments, isLocked, stepSelections]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!expandedOption?.preview) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previewUrl = resolvePromptPreviewUrl(expandedOption.preview.url);
|
||||
onPreviewViewed?.(previewUrl || null);
|
||||
}, [expandedOption, onPreviewViewed]);
|
||||
onPreviewViewed?.(expandedOptionPreviewUrl || null);
|
||||
}, [expandedOption?.value, expandedOptionPreviewUrl, onPreviewViewed]);
|
||||
|
||||
const handleShareExpandedPreview = () => {
|
||||
if (!expandedOption?.preview) {
|
||||
@@ -1382,7 +1407,6 @@ export function ChatPromptCard({
|
||||
...current,
|
||||
[activeStep.key]: nextSelection,
|
||||
};
|
||||
emitSelectionChange(nextSelections);
|
||||
return nextSelections;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -6,6 +6,83 @@ const CHAT_PUBLIC_DOT_CODEX_MARKER = '/public/.codex_chat/';
|
||||
const RESOURCE_MANAGER_PREVIEW_MARKER = '/api/resource-manager/preview/';
|
||||
const RESOURCE_MANAGER_ROOT_MARKER = 'resource/';
|
||||
|
||||
function normalizeUrlFragmentValue(value: string) {
|
||||
const normalized = String(value ?? '').trim().replace(/^#+/, '');
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return decodeURIComponent(normalized);
|
||||
} catch {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeRepeatedly(value: string, maxIterations = 3) {
|
||||
let current = value;
|
||||
|
||||
for (let index = 0; index < maxIterations; index += 1) {
|
||||
try {
|
||||
const decoded = decodeURIComponent(current);
|
||||
|
||||
if (!decoded || decoded === current) {
|
||||
break;
|
||||
}
|
||||
|
||||
current = decoded;
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
function normalizePreviewPathHash(value: string) {
|
||||
const normalized = String(value ?? '').trim();
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const isAbsoluteUrl = /^[a-z][a-z0-9+.-]*:\/\//i.test(normalized);
|
||||
const isRootRelative = normalized.startsWith('/');
|
||||
|
||||
try {
|
||||
const parsed = new URL(normalized, 'https://local.invalid');
|
||||
const segments = parsed.pathname.split('/');
|
||||
const lastSegment = segments.at(-1) ?? '';
|
||||
const decodedLastSegment = decodeRepeatedly(lastSegment);
|
||||
const hashIndex = decodedLastSegment.lastIndexOf('#');
|
||||
|
||||
if (hashIndex <= 0 || parsed.hash) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const fileName = decodedLastSegment.slice(0, hashIndex).trim();
|
||||
const fragment = normalizeUrlFragmentValue(decodedLastSegment.slice(hashIndex + 1));
|
||||
|
||||
if (!fileName || !fragment || !/\.[a-z0-9]{1,16}$/i.test(fileName)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
segments[segments.length - 1] = encodeURIComponent(fileName);
|
||||
parsed.pathname = segments.join('/');
|
||||
parsed.hash = fragment;
|
||||
|
||||
if (isAbsoluteUrl) {
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
const pathWithQueryAndHash = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||
return isRootRelative ? pathWithQueryAndHash : pathWithQueryAndHash.replace(/^\/+/, '');
|
||||
} catch {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeResourceManagerPathSegment(segment: string) {
|
||||
const normalized = String(segment ?? '').trim();
|
||||
|
||||
@@ -21,8 +98,11 @@ function normalizeResourceManagerPathSegment(segment: string) {
|
||||
}
|
||||
|
||||
function buildResourceManagerPreviewPath(value: string) {
|
||||
const normalized = String(value ?? '').trim().replace(/\\/g, '/');
|
||||
const matchedResourcePath = normalized.match(/(?:^|\/)(resource\/.+)$/i)?.[1];
|
||||
const normalized = normalizePreviewPathHash(String(value ?? '').trim().replace(/\\/g, '/'));
|
||||
const hashIndex = normalized.indexOf('#');
|
||||
const hash = hashIndex >= 0 ? normalizeUrlFragmentValue(normalized.slice(hashIndex + 1)) : '';
|
||||
const normalizedPath = hashIndex >= 0 ? normalized.slice(0, hashIndex) : normalized;
|
||||
const matchedResourcePath = normalizedPath.match(/(?:^|\/)(resource\/.+)$/i)?.[1];
|
||||
const resourcePath = String(matchedResourcePath ?? '').trim().replace(/^\/+/, '');
|
||||
|
||||
if (!resourcePath) {
|
||||
@@ -41,7 +121,7 @@ function buildResourceManagerPreviewPath(value: string) {
|
||||
.map((segment) => normalizeResourceManagerPathSegment(segment))
|
||||
.join('/');
|
||||
|
||||
return encodedPath ? `${RESOURCE_MANAGER_PREVIEW_MARKER}${encodedPath}` : '';
|
||||
return encodedPath ? `${RESOURCE_MANAGER_PREVIEW_MARKER}${encodedPath}${hash ? `#${hash}` : ''}` : '';
|
||||
}
|
||||
|
||||
function resolveLegacyExternalImageUrl(value: string) {
|
||||
@@ -113,17 +193,17 @@ function extractKnownPreviewPath(value: string) {
|
||||
const pathname = `${parsed.pathname || ''}${parsed.search || ''}${parsed.hash || ''}`;
|
||||
|
||||
if (pathname.startsWith(CHAT_API_RESOURCE_MARKER) || pathname.startsWith(RESOURCE_MANAGER_PREVIEW_MARKER)) {
|
||||
return pathname;
|
||||
return normalizePreviewPathHash(pathname);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
} catch {
|
||||
return extractEmbeddedResourcePath(normalized);
|
||||
return normalizePreviewPathHash(extractEmbeddedResourcePath(normalized));
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeChatResourceUrl(value: string) {
|
||||
const normalized = extractKnownPreviewPath(value);
|
||||
const normalized = normalizePreviewPathHash(extractKnownPreviewPath(value));
|
||||
const legacyExternalImageUrl = resolveLegacyExternalImageUrl(normalized);
|
||||
|
||||
if (legacyExternalImageUrl) {
|
||||
|
||||
@@ -51,9 +51,89 @@ function normalizeResourceManagerPathSegment(segment: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUrlFragmentValue(value: string) {
|
||||
const normalized = normalizeText(value).replace(/^#+/, '');
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return decodeURIComponent(normalized);
|
||||
} catch {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeRepeatedly(value: string, maxIterations = 3) {
|
||||
let current = value;
|
||||
|
||||
for (let index = 0; index < maxIterations; index += 1) {
|
||||
try {
|
||||
const decoded = decodeURIComponent(current);
|
||||
|
||||
if (!decoded || decoded === current) {
|
||||
break;
|
||||
}
|
||||
|
||||
current = decoded;
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
function normalizePreviewPathHash(value: string) {
|
||||
const normalized = normalizeText(value);
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const isAbsoluteUrl = /^[a-z][a-z0-9+.-]*:\/\//i.test(normalized);
|
||||
const isRootRelative = normalized.startsWith('/');
|
||||
|
||||
try {
|
||||
const parsed = new URL(normalized, 'https://local.invalid');
|
||||
const segments = parsed.pathname.split('/');
|
||||
const lastSegment = segments.at(-1) ?? '';
|
||||
const decodedLastSegment = decodeRepeatedly(lastSegment);
|
||||
const hashIndex = decodedLastSegment.lastIndexOf('#');
|
||||
|
||||
if (hashIndex <= 0 || parsed.hash) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const fileName = decodedLastSegment.slice(0, hashIndex).trim();
|
||||
const fragment = normalizeUrlFragmentValue(decodedLastSegment.slice(hashIndex + 1));
|
||||
|
||||
if (!fileName || !fragment || !/\.[a-z0-9]{1,16}$/i.test(fileName)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
segments[segments.length - 1] = encodeURIComponent(fileName);
|
||||
parsed.pathname = segments.join('/');
|
||||
parsed.hash = fragment;
|
||||
|
||||
if (isAbsoluteUrl) {
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
const pathWithQueryAndHash = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||
return isRootRelative ? pathWithQueryAndHash : pathWithQueryAndHash.replace(/^\/+/, '');
|
||||
} catch {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
function buildResourceManagerPreviewUrl(value: string) {
|
||||
const normalized = normalizeText(value).replace(/\\/g, '/');
|
||||
const matchedResourcePath = normalized.match(/(?:^|\/)(resource\/.+)$/i)?.[1];
|
||||
const normalized = normalizePreviewPathHash(normalizeText(value).replace(/\\/g, '/'));
|
||||
const hashIndex = normalized.indexOf('#');
|
||||
const hash = hashIndex >= 0 ? normalizeUrlFragmentValue(normalized.slice(hashIndex + 1)) : '';
|
||||
const normalizedPath = hashIndex >= 0 ? normalized.slice(0, hashIndex) : normalized;
|
||||
const matchedResourcePath = normalizedPath.match(/(?:^|\/)(resource\/.+)$/i)?.[1];
|
||||
const resourcePath = normalizeText(matchedResourcePath).replace(/^\/+/, '');
|
||||
|
||||
if (!resourcePath) {
|
||||
@@ -72,7 +152,7 @@ function buildResourceManagerPreviewUrl(value: string) {
|
||||
.map((segment) => normalizeResourceManagerPathSegment(segment))
|
||||
.join('/');
|
||||
|
||||
return encodedPath ? `${RESOURCE_MANAGER_PREVIEW_MARKER}${encodedPath}` : '';
|
||||
return encodedPath ? `${RESOURCE_MANAGER_PREVIEW_MARKER}${encodedPath}${hash ? `#${hash}` : ''}` : '';
|
||||
}
|
||||
|
||||
function extractKnownPreviewPath(value: string) {
|
||||
@@ -83,11 +163,11 @@ function extractKnownPreviewPath(value: string) {
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(normalized);
|
||||
const parsed = new URL(normalized, 'https://local.invalid');
|
||||
const pathname = `${parsed.pathname || ''}${parsed.search || ''}${parsed.hash || ''}`;
|
||||
|
||||
if (pathname.startsWith(CHAT_API_RESOURCE_MARKER) || pathname.startsWith(RESOURCE_MANAGER_PREVIEW_MARKER)) {
|
||||
return pathname;
|
||||
return normalizePreviewPathHash(pathname);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
|
||||
@@ -903,12 +903,10 @@ export async function showLocalClientNotification(payload: ClientNotificationPay
|
||||
|
||||
export async function sendClientNotification(payload: ClientNotificationPayload) {
|
||||
const notificationData = withCurrentAppOriginMetadata(payload.data);
|
||||
const targetDeviceIds = payload.targetDeviceIds ?? payload.targetClientIds;
|
||||
return request<ClientNotificationSendResult>('/notifications/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
targetDeviceIds,
|
||||
data: notificationData,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -337,7 +337,7 @@ function readStoredShareLastRoomByToken() {
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(SHARE_LAST_ROOM_STORAGE_KEY);
|
||||
const raw = window.sessionStorage.getItem(SHARE_LAST_ROOM_STORAGE_KEY);
|
||||
|
||||
if (!raw) {
|
||||
return {} as Record<string, string>;
|
||||
@@ -390,7 +390,48 @@ function writeStoredShareLastRoomSessionId(token: string, sessionId: string | nu
|
||||
delete nextMap[normalizedToken];
|
||||
}
|
||||
|
||||
window.localStorage.setItem(SHARE_LAST_ROOM_STORAGE_KEY, JSON.stringify(nextMap));
|
||||
window.sessionStorage.setItem(SHARE_LAST_ROOM_STORAGE_KEY, JSON.stringify(nextMap));
|
||||
}
|
||||
|
||||
function readShareRoomSessionIdFromLocation() {
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return new URLSearchParams(window.location.search).get('roomSessionId')?.trim() || '';
|
||||
}
|
||||
|
||||
function writeShareRoomSessionIdToLocation(roomSessionId: string | null, mode: 'push' | 'replace' = 'replace') {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedSessionId = String(roomSessionId ?? '').trim();
|
||||
const nextUrl = new URL(window.location.href);
|
||||
const currentSessionId = nextUrl.searchParams.get('roomSessionId')?.trim() || '';
|
||||
|
||||
if (normalizedSessionId) {
|
||||
if (currentSessionId === normalizedSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
nextUrl.searchParams.set('roomSessionId', normalizedSessionId);
|
||||
} else {
|
||||
if (!currentSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
nextUrl.searchParams.delete('roomSessionId');
|
||||
}
|
||||
|
||||
const nextPath = `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`;
|
||||
|
||||
if (mode === 'push') {
|
||||
window.history.pushState(window.history.state, '', nextPath);
|
||||
return;
|
||||
}
|
||||
|
||||
window.history.replaceState(window.history.state, '', nextPath);
|
||||
}
|
||||
|
||||
function getClientNotificationPermission(): ClientNotificationPermissionState {
|
||||
@@ -3557,7 +3598,7 @@ export function ChatSharePage() {
|
||||
return '';
|
||||
}
|
||||
|
||||
const urlRoomSessionId = new URLSearchParams(window.location.search).get('roomSessionId')?.trim() || '';
|
||||
const urlRoomSessionId = readShareRoomSessionIdFromLocation();
|
||||
|
||||
if (urlRoomSessionId) {
|
||||
return urlRoomSessionId;
|
||||
@@ -5278,13 +5319,31 @@ export function ChatSharePage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const urlRoomSessionId = new URLSearchParams(window.location.search).get('roomSessionId')?.trim() || '';
|
||||
const urlRoomSessionId = readShareRoomSessionIdFromLocation();
|
||||
const restoredRoomSessionId = urlRoomSessionId || readStoredShareLastRoomSessionId(normalizedToken);
|
||||
|
||||
requestedRoomSessionIdRef.current = restoredRoomSessionId;
|
||||
setRequestedRoomSessionId(restoredRoomSessionId);
|
||||
}, [normalizedToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handlePopState = () => {
|
||||
const nextRoomSessionId = readShareRoomSessionIdFromLocation() || readStoredShareLastRoomSessionId(normalizedToken);
|
||||
requestedRoomSessionIdRef.current = nextRoomSessionId;
|
||||
setRequestedRoomSessionId(nextRoomSessionId);
|
||||
setIsShareRoomListVisible(false);
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => {
|
||||
window.removeEventListener('popstate', handlePopState);
|
||||
};
|
||||
}, [normalizedToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!normalizedToken || !hasSnapshotRef.current) {
|
||||
return;
|
||||
@@ -5329,22 +5388,10 @@ export function ChatSharePage() {
|
||||
writeStoredShareLastRoomSessionId(normalizedToken, persistedRoomSessionId);
|
||||
}, [normalizedToken, selectedShareRoomSessionId, shareRooms]);
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextUrl = new URL(window.location.href);
|
||||
const roomSessionId = shareRooms.some((room) => room.sessionId === requestedRoomSessionId)
|
||||
? requestedRoomSessionId
|
||||
: activeShareRoomSessionId;
|
||||
|
||||
if (roomSessionId) {
|
||||
nextUrl.searchParams.set('roomSessionId', roomSessionId);
|
||||
} else {
|
||||
nextUrl.searchParams.delete('roomSessionId');
|
||||
}
|
||||
|
||||
window.history.replaceState(null, '', `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`);
|
||||
writeShareRoomSessionIdToLocation(roomSessionId || null, 'replace');
|
||||
}, [activeShareRoomSessionId, requestedRoomSessionId, shareRooms]);
|
||||
|
||||
const handleUnlockShare = useCallback(async (inputPin?: string) => {
|
||||
@@ -5826,9 +5873,12 @@ export function ChatSharePage() {
|
||||
|
||||
roomSwitchSequenceRef.current += 1;
|
||||
setIsRoomSwitching(true);
|
||||
requestedRoomSessionIdRef.current = normalizedSessionId;
|
||||
writeStoredShareLastRoomSessionId(normalizedToken, normalizedSessionId);
|
||||
writeShareRoomSessionIdToLocation(normalizedSessionId, 'push');
|
||||
setRequestedRoomSessionId(normalizedSessionId);
|
||||
setIsShareRoomListVisible(false);
|
||||
}, [selectedShareRoomSessionId]);
|
||||
}, [normalizedToken, selectedShareRoomSessionId]);
|
||||
|
||||
const handleCreateShareRoom = useCallback(async () => {
|
||||
if (!normalizedToken || isCreatingRoom) {
|
||||
@@ -5871,6 +5921,9 @@ export function ChatSharePage() {
|
||||
? current
|
||||
: [...current, createdRoom]
|
||||
));
|
||||
requestedRoomSessionIdRef.current = createdRoom.sessionId;
|
||||
writeStoredShareLastRoomSessionId(normalizedToken, createdRoom.sessionId);
|
||||
writeShareRoomSessionIdToLocation(createdRoom.sessionId, 'push');
|
||||
setRequestedRoomSessionId(createdRoom.sessionId);
|
||||
setDraftText('');
|
||||
setComposerAttachments([]);
|
||||
|
||||
@@ -79,6 +79,7 @@ type SuccessLogRow = {
|
||||
clientId: string;
|
||||
ownerType: 'client' | 'shared-token';
|
||||
ownerId: string;
|
||||
ownerLabel?: string | null;
|
||||
alertTitle: string;
|
||||
ticketTitle: string;
|
||||
eventDateTime: string;
|
||||
@@ -641,9 +642,15 @@ function formatScopeIdentifierLabel(value: string | null | undefined) {
|
||||
return formatClientIdLabel(value?.trim() ?? '');
|
||||
}
|
||||
|
||||
function formatOwnershipLabel(ownerType: 'client' | 'shared-token', ownerId: string | null | undefined, clientId: string) {
|
||||
function formatOwnershipLabel(
|
||||
ownerType: 'client' | 'shared-token',
|
||||
ownerId: string | null | undefined,
|
||||
clientId: string,
|
||||
ownerLabel?: string | null,
|
||||
) {
|
||||
if (ownerType === 'shared-token') {
|
||||
return `토큰 ${formatScopeIdentifierLabel(ownerId)}`;
|
||||
const normalizedOwnerLabel = ownerLabel?.trim() ?? '';
|
||||
return normalizedOwnerLabel || `토큰 ${formatScopeIdentifierLabel(ownerId)}`;
|
||||
}
|
||||
|
||||
return `기기 ${formatClientIdLabel(clientId)}`;
|
||||
@@ -825,6 +832,7 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct',
|
||||
clientId: item.clientId,
|
||||
ownerType: item.ownerType,
|
||||
ownerId: item.ownerId,
|
||||
ownerLabel: item.ownerLabel ?? alert?.ownerLabel ?? null,
|
||||
alertTitle: alert.title,
|
||||
ticketTitle: result.title,
|
||||
eventDateTime: result.eventDateTime,
|
||||
@@ -865,7 +873,7 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct',
|
||||
[selectedSuccessRows],
|
||||
);
|
||||
const selectableSuccessRows = useMemo(
|
||||
() => (accessScope === 'shared-token' ? successRows : successRows.filter((item) => item.clientId === clientId)),
|
||||
() => (accessScope === 'client' ? successRows.filter((item) => item.clientId === clientId) : successRows),
|
||||
[accessScope, clientId, successRows],
|
||||
);
|
||||
const isAllSuccessRowsSelected = selectableSuccessRows.length > 0 && selectedSuccessRowIds.length === selectableSuccessRows.length;
|
||||
@@ -882,7 +890,7 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct',
|
||||
}
|
||||
|
||||
if (accessScope === 'all') {
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
return targetClientId === clientId;
|
||||
@@ -976,13 +984,17 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct',
|
||||
};
|
||||
|
||||
const appendLog = (
|
||||
item: Pick<AlertLogItem, 'alertId' | 'alertTitle' | 'action' | 'status' | 'message' | 'detail'>,
|
||||
item: Pick<AlertLogItem, 'alertId' | 'alertTitle' | 'action' | 'status' | 'message' | 'detail'>
|
||||
& Partial<Pick<AlertLogItem, 'ownerType' | 'ownerId' | 'ownerLabel'>>,
|
||||
) => {
|
||||
setLogs((previous) => [
|
||||
{
|
||||
id: createId('log'),
|
||||
clientId,
|
||||
createdAt: new Date().toISOString(),
|
||||
ownerType: 'client',
|
||||
ownerId: clientId,
|
||||
ownerLabel: null,
|
||||
...item,
|
||||
},
|
||||
...previous,
|
||||
@@ -1027,6 +1039,9 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct',
|
||||
status: 'error',
|
||||
message: error instanceof Error ? error.message : '즉시 실행에 실패했습니다.',
|
||||
detail: buildAlertSummary(alert),
|
||||
ownerType: alert.ownerType,
|
||||
ownerId: alert.ownerId,
|
||||
ownerLabel: alert.ownerLabel ?? null,
|
||||
});
|
||||
messageApi.error(error instanceof Error ? error.message : '즉시 실행에 실패했습니다.');
|
||||
} finally {
|
||||
@@ -1618,7 +1633,7 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct',
|
||||
<strong>{item.ticketTitle}</strong>
|
||||
<div className="baseball-ticket-bay-app__success-item-top-tags">
|
||||
{isViewingAllClients || isSharedTokenScope ? (
|
||||
<Tag bordered={false}>{formatOwnershipLabel(item.ownerType, item.ownerId, item.clientId)}</Tag>
|
||||
<Tag bordered={false}>{formatOwnershipLabel(item.ownerType, item.ownerId, item.clientId, item.ownerLabel)}</Tag>
|
||||
) : null}
|
||||
<Tag bordered={false} color="green">
|
||||
성공
|
||||
@@ -1651,7 +1666,7 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct',
|
||||
</Tag>
|
||||
{isViewingAllClients || isSharedTokenScope ? (
|
||||
<Tag bordered={false}>
|
||||
{formatOwnershipLabel(selectedSuccessRow.ownerType, selectedSuccessRow.ownerId, selectedSuccessRow.clientId)}
|
||||
{formatOwnershipLabel(selectedSuccessRow.ownerType, selectedSuccessRow.ownerId, selectedSuccessRow.clientId, selectedSuccessRow.ownerLabel)}
|
||||
</Tag>
|
||||
) : null}
|
||||
<Button
|
||||
@@ -2043,7 +2058,7 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct',
|
||||
</div>
|
||||
{isViewingAllClients || isSharedTokenScope ? (
|
||||
<div className="baseball-ticket-bay-app__scope-note">
|
||||
{isSharedTokenScope ? '현재 공유 토큰 기준으로 목록을 표시합니다.' : '등록 토큰으로 전체 기기 목록을 보고 있습니다. 수정과 삭제는 현재 기기 항목만 가능합니다.'}
|
||||
{isSharedTokenScope ? '현재 공유 리소스 별명 기준으로 목록을 표시합니다.' : '등록 토큰으로 전체 기기 목록을 보고 있습니다. 리소스 별명으로 확인하고 수정과 삭제도 바로 할 수 있습니다.'}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="baseball-ticket-bay-app__items">
|
||||
@@ -2063,7 +2078,7 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct',
|
||||
<div className="baseball-ticket-bay-app__item-tags">
|
||||
{isViewingAllClients || isSharedTokenScope ? (
|
||||
<Tag bordered={false} color={isOwnedByCurrentClient ? 'blue' : 'default'}>
|
||||
{formatOwnershipLabel(item.ownerType, item.ownerId, item.clientId)}
|
||||
{formatOwnershipLabel(item.ownerType, item.ownerId, item.clientId, item.ownerLabel)}
|
||||
</Tag>
|
||||
) : null}
|
||||
<Tag bordered={false} color={item.active ? 'green' : 'default'}>
|
||||
@@ -2168,7 +2183,7 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct',
|
||||
<strong>{item.alertTitle}</strong>
|
||||
{isViewingAllClients || isSharedTokenScope ? (
|
||||
<span className="baseball-ticket-bay-app__log-client">
|
||||
{formatOwnershipLabel(item.ownerType, item.ownerId, item.clientId)}
|
||||
{formatOwnershipLabel(item.ownerType, item.ownerId, item.clientId, item.ownerLabel)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@ export type BaseballTicketBayAlertItem = {
|
||||
clientId: string;
|
||||
ownerType: 'client' | 'shared-token';
|
||||
ownerId: string;
|
||||
ownerLabel?: string | null;
|
||||
title: string;
|
||||
eventDate: string;
|
||||
team: string;
|
||||
@@ -92,6 +93,7 @@ export type BaseballTicketBayAlertLogItem = {
|
||||
clientId: string;
|
||||
ownerType: 'client' | 'shared-token';
|
||||
ownerId: string;
|
||||
ownerLabel?: string | null;
|
||||
alertId: string | null;
|
||||
alertTitle: string;
|
||||
action: 'create' | 'run' | 'pause' | 'resume' | 'delete' | 'push';
|
||||
@@ -104,7 +106,7 @@ export type BaseballTicketBayAlertLogItem = {
|
||||
|
||||
type BaseballTicketBayAlertMutation = Omit<
|
||||
BaseballTicketBayAlertItem,
|
||||
'id' | 'clientId' | 'ownerType' | 'ownerId' | 'createdAt' | 'updatedAt' | 'lastRunAt' | 'lastMatchAt'
|
||||
'id' | 'clientId' | 'ownerType' | 'ownerId' | 'ownerLabel' | 'createdAt' | 'updatedAt' | 'lastRunAt' | 'lastMatchAt'
|
||||
>;
|
||||
|
||||
type BaseballTicketBayAlertsResponse = {
|
||||
|
||||
Reference in New Issue
Block a user