chore: test deploy snapshot

This commit is contained in:
2026-05-28 08:09:49 +09:00
parent e195ac8088
commit 983887dc05
30 changed files with 1730 additions and 108 deletions

View File

@@ -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 />}>

View File

@@ -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>

View File

@@ -7,7 +7,6 @@ import {
saveAppConfigToServer,
type AppConfig,
type PlanCostTimeUnit,
type WeeklyScheduleDay,
} from './appConfig';
import './SharedAppSettingsPage.css';

View File

@@ -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,

View File

@@ -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;
});
};

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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,
}),
});

View File

@@ -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([]);

View File

@@ -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>

View File

@@ -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 = {