diff --git a/etc/servers/work-server/src/routes/baseball-ticket-bay.ts b/etc/servers/work-server/src/routes/baseball-ticket-bay.ts index 64c7ec1..8713478 100644 --- a/etc/servers/work-server/src/routes/baseball-ticket-bay.ts +++ b/etc/servers/work-server/src/routes/baseball-ticket-bay.ts @@ -144,7 +144,7 @@ export async function registerBaseballTicketBayRoutes(app: FastifyInstance) { app.delete('/api/baseball-ticket-bay/logs/:id', async (request, reply) => { const accessContext = await resolveBaseballTicketBayAccessContext(request); - if (!accessContext || accessContext.scope === 'all') { + if (!accessContext) { return reply.code(400).send({ message: '현재 접근 범위에서는 로그를 삭제할 수 없습니다.' }); } @@ -197,23 +197,33 @@ export async function registerBaseballTicketBayRoutes(app: FastifyInstance) { app.patch('/api/baseball-ticket-bay/alerts/:id', async (request, reply) => { const accessContext = await resolveBaseballTicketBayAccessContext(request); - if (!accessContext || accessContext.scope === 'all') { + if (!accessContext) { return reply.code(400).send({ message: '현재 접근 범위에서는 알림을 수정할 수 없습니다.' }); } const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {}); const payload = alertPayloadSchema.partial().parse(request.body ?? {}); - const item = await updateBaseballTicketBayAlert(params.id, payload, { - clientId: accessContext.clientId, - ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client', - ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId, - appOrigin: readHeader(request, 'x-app-origin'), - appDomain: readHeader(request, 'x-app-domain'), - }); + const item = await updateBaseballTicketBayAlert( + params.id, + payload, + accessContext.scope === 'all' + ? { + scope: { kind: 'all' }, + appOrigin: readHeader(request, 'x-app-origin'), + appDomain: readHeader(request, 'x-app-domain'), + } + : { + clientId: accessContext.clientId, + ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client', + ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId, + appOrigin: readHeader(request, 'x-app-origin'), + appDomain: readHeader(request, 'x-app-domain'), + }, + ); await createBaseballTicketBayLog({ - clientId: accessContext.clientId, - ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client', - ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId, + clientId: item.clientId, + ownerType: item.ownerType, + ownerId: item.ownerId, alertId: item.id, alertTitle: item.title, action: payload.active === false ? 'pause' : payload.active === true ? 'resume' : 'run', @@ -236,7 +246,7 @@ export async function registerBaseballTicketBayRoutes(app: FastifyInstance) { app.delete('/api/baseball-ticket-bay/alerts/:id', async (request, reply) => { const accessContext = await resolveBaseballTicketBayAccessContext(request); - if (!accessContext || accessContext.scope === 'all') { + if (!accessContext) { return reply.code(400).send({ message: '현재 접근 범위에서는 알림을 삭제할 수 없습니다.' }); } @@ -248,9 +258,9 @@ export async function registerBaseballTicketBayRoutes(app: FastifyInstance) { } await createBaseballTicketBayLog({ - clientId: accessContext.clientId, - ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client', - ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId, + clientId: item.clientId, + ownerType: item.ownerType, + ownerId: item.ownerId, alertId: item.id, alertTitle: item.title, action: 'delete', @@ -274,10 +284,6 @@ export async function registerBaseballTicketBayRoutes(app: FastifyInstance) { const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {}); - if (accessContext.scope === 'all') { - return reply.code(403).send({ message: '전체 보기 범위에서는 즉시 실행할 수 없습니다.' }); - } - const result = await runBaseballTicketBayAlert(params.id, { ignoreTimeWindow: true, scope: toOwnerScope(accessContext), diff --git a/etc/servers/work-server/src/routes/resource-manager.test.ts b/etc/servers/work-server/src/routes/resource-manager.test.ts index dc2d201..3c0e3ce 100644 --- a/etc/servers/work-server/src/routes/resource-manager.test.ts +++ b/etc/servers/work-server/src/routes/resource-manager.test.ts @@ -7,6 +7,7 @@ import { env } from '../config/env.js'; import { registerResourceManagerRoutes, resolveSingleRange } from './resource-manager.js'; const fallbackResourceRoot = path.resolve(process.cwd(), '../../../resource'); +const legacyPublicResourceRoot = path.resolve(process.cwd(), '../../../public/resource'); test('resolveSingleRange parses open-ended and suffix byte ranges', () => { assert.deepEqual(resolveSingleRange('bytes=5-', 20), { @@ -55,3 +56,61 @@ test('resource manager preview serves 206 partial content for byte ranges', asyn await app.close(); } }); + +test('resource manager preview falls back to public/resource legacy artifacts', async () => { + const app = Fastify(); + await registerResourceManagerRoutes(app); + + const relativePath = path.join('legacy-preview-test', `sample-${Date.now()}.html`); + const absolutePath = path.join(legacyPublicResourceRoot, relativePath); + await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + await fs.writeFile(absolutePath, 'legacy preview', 'utf8'); + + try { + const response = await app.inject({ + method: 'GET', + url: `/api/resource-manager/preview/${relativePath.split(path.sep).map((segment) => encodeURIComponent(segment)).join('/')}?token=${encodeURIComponent(env.SERVER_COMMAND_ACCESS_TOKEN)}`, + }); + + assert.equal(response.statusCode, 200); + assert.match(String(response.headers['content-type'] ?? ''), /text\/html/i); + assert.match(response.body, /legacy preview/); + } finally { + await fs.rm(path.join(legacyPublicResourceRoot, 'legacy-preview-test'), { recursive: true, force: true }); + await app.close(); + } +}); + +test('resource manager preview restores encoded hash fragments in the file name', async () => { + const app = Fastify(); + await registerResourceManagerRoutes(app); + + const relativePath = path.join('encoded-preview-test', `sample-${Date.now()}.html`); + const absolutePath = path.join(legacyPublicResourceRoot, relativePath); + await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + await fs.writeFile(absolutePath, 'encoded hash preview', 'utf8'); + + try { + for (const encodedSuffix of ['%23option-a', '%2523option-a']) { + const encodedPath = relativePath + .split(path.sep) + .map((segment, index, list) => + index === list.length - 1 + ? `${encodeURIComponent(segment)}${encodedSuffix}` + : encodeURIComponent(segment), + ) + .join('/'); + const response = await app.inject({ + method: 'GET', + url: `/api/resource-manager/preview/${encodedPath}?token=${encodeURIComponent(env.SERVER_COMMAND_ACCESS_TOKEN)}`, + }); + + assert.equal(response.statusCode, 200); + assert.match(String(response.headers['content-type'] ?? ''), /text\/html/i); + assert.match(response.body, /encoded hash preview/); + } + } finally { + await fs.rm(path.join(legacyPublicResourceRoot, 'encoded-preview-test'), { recursive: true, force: true }); + await app.close(); + } +}); diff --git a/etc/servers/work-server/src/services/baseball-ticket-bay-service.ts b/etc/servers/work-server/src/services/baseball-ticket-bay-service.ts index 283951e..5eac403 100644 --- a/etc/servers/work-server/src/services/baseball-ticket-bay-service.ts +++ b/etc/servers/work-server/src/services/baseball-ticket-bay-service.ts @@ -2,6 +2,7 @@ import type { Knex } from 'knex'; import { z } from 'zod'; import { db } from '../db/client.js'; import { sendNotifications } from './notification-service.js'; +import { getSharedResourceTokenDetail } from './shared-resource-token-service.js'; const TICKET_BAY_ORIGIN = 'https://www.ticketbay.co.kr'; const SPORTS_SEARCH_KEY = '5'; @@ -695,6 +696,7 @@ export type BaseballTicketBayAlertItem = { clientId: string; ownerType: BaseballTicketBayOwnerType; ownerId: string; + ownerLabel?: string | null; title: string; eventDate: string; team: string; @@ -722,6 +724,7 @@ export type BaseballTicketBayAlertLogItem = { clientId: string; ownerType: BaseballTicketBayOwnerType; ownerId: string; + ownerLabel?: string | null; alertId: string | null; alertTitle: string; action: BaseballTicketBayAlertLogAction; @@ -902,6 +905,7 @@ function mapAlertRow(row: BaseballTicketBayAlertRow): BaseballTicketBayAlertItem clientId: normalizeText(row.client_id), ownerType: normalizeOwnerType(row.owner_type), ownerId: normalizeOwnerId(row), + ownerLabel: null, title: normalizeText(row.title), eventDate: normalizeText(row.event_date), team: normalizeText(row.team) || '전체', @@ -938,6 +942,7 @@ function mapLogRow(row: BaseballTicketBayLogRow): BaseballTicketBayAlertLogItem clientId: normalizeText(row.client_id), ownerType: normalizeOwnerType(row.owner_type), ownerId: normalizeOwnerId(row), + ownerLabel: null, alertId: row.alert_id ? normalizeText(row.alert_id) : null, alertTitle: normalizeText(row.alert_title), action: row.action, @@ -949,6 +954,34 @@ function mapLogRow(row: BaseballTicketBayLogRow): BaseballTicketBayAlertLogItem }; } +async function attachOwnerLabels(items: T[]) { + const sharedTokenOwnerIds = Array.from( + new Set( + items + .filter((item) => item.ownerType === 'shared-token') + .map((item) => normalizeText(item.ownerId)) + .filter(Boolean), + ), + ); + + if (!sharedTokenOwnerIds.length) { + return items; + } + + const labelEntries = await Promise.all( + sharedTokenOwnerIds.map(async (tokenId) => { + const detail = await getSharedResourceTokenDetail(tokenId); + return [tokenId, detail?.token.resourceLabel?.trim() || detail?.token.name?.trim() || null] as const; + }), + ); + const labelMap = new Map(labelEntries); + + return items.map((item) => ({ + ...item, + ownerLabel: item.ownerType === 'shared-token' ? labelMap.get(normalizeText(item.ownerId)) ?? null : null, + })); +} + function formatTimeInKst(date: Date) { return new Intl.DateTimeFormat('en-GB', { timeZone: 'Asia/Seoul', @@ -1162,7 +1195,7 @@ export async function listBaseballTicketBayAlerts(scope: BaseballTicketBayOwnerS { column: 'client_id', order: 'asc' }, { column: 'created_at', order: 'desc' }, ])) as BaseballTicketBayAlertRow[]; - return rows.map((row: BaseballTicketBayAlertRow) => mapAlertRow(row)); + return attachOwnerLabels(rows.map((row: BaseballTicketBayAlertRow) => mapAlertRow(row))); } export async function listBaseballTicketBayLogs( @@ -1177,7 +1210,7 @@ export async function listBaseballTicketBayLogs( } const rows = (await query) as BaseballTicketBayLogRow[]; - return rows.map((row: BaseballTicketBayLogRow) => mapLogRow(row)); + return attachOwnerLabels(rows.map((row: BaseballTicketBayLogRow) => mapLogRow(row))); } export async function createBaseballTicketBayLog(args: { @@ -1208,7 +1241,7 @@ export async function createBaseballTicketBayLog(args: { payload_json: args.payload ? JSON.stringify(args.payload) : null, }; await db(BASEBALL_TICKET_BAY_LOG_TABLE).insert(row); - return mapLogRow(row); + return attachOwnerLabels([mapLogRow(row)]).then(([item]) => item); } export async function deleteBaseballTicketBayLog(logId: string, scope: BaseballTicketBayOwnerScope) { @@ -1234,7 +1267,7 @@ export async function deleteBaseballTicketBayLog(logId: string, scope: BaseballT scope, ).delete(); - return mapLogRow(existing as BaseballTicketBayLogRow); + return attachOwnerLabels([mapLogRow(existing as BaseballTicketBayLogRow)]).then(([item]) => item); } export async function createBaseballTicketBayAlert( @@ -1269,20 +1302,26 @@ export async function createBaseballTicketBayAlert( last_match_at: null, }; await db(BASEBALL_TICKET_BAY_ALERT_TABLE).insert(row); - return mapAlertRow(row); + return attachOwnerLabels([mapAlertRow(row)]).then(([item]) => item); } export async function updateBaseballTicketBayAlert( alertId: string, payload: Partial, - context: { clientId: string; ownerType: BaseballTicketBayOwnerType; ownerId: string; appOrigin?: string; appDomain?: string }, + context: + | { scope: BaseballTicketBayOwnerScope; appOrigin?: string; appDomain?: string } + | { clientId: string; ownerType: BaseballTicketBayOwnerType; ownerId: string; appOrigin?: string; appDomain?: string }, ) { await ensureBaseballTicketBayTables(); + const scope = + 'scope' in context + ? context.scope + : ({ kind: 'owner', ownerType: context.ownerType, ownerId: context.ownerId } as const); const current = await applyOwnerScope( db(BASEBALL_TICKET_BAY_ALERT_TABLE) .select('*') .where({ id: alertId }), - { kind: 'owner', ownerType: context.ownerType, ownerId: context.ownerId }, + scope, ).first(); if (!current) { @@ -1309,18 +1348,15 @@ export async function updateBaseballTicketBayAlert( if (context.appOrigin) patch.app_origin = normalizeText(context.appOrigin); if (context.appDomain) patch.app_domain = normalizeText(context.appDomain); - await applyOwnerScope( - db(BASEBALL_TICKET_BAY_ALERT_TABLE).where({ id: alertId }), - { kind: 'owner', ownerType: context.ownerType, ownerId: context.ownerId }, - ).update(patch); + await applyOwnerScope(db(BASEBALL_TICKET_BAY_ALERT_TABLE).where({ id: alertId }), scope).update(patch); const updated = await applyOwnerScope( db(BASEBALL_TICKET_BAY_ALERT_TABLE) .select('*') .where({ id: alertId }), - { kind: 'owner', ownerType: context.ownerType, ownerId: context.ownerId }, + scope, ).first(); - return mapAlertRow(updated as BaseballTicketBayAlertRow); + return attachOwnerLabels([mapAlertRow(updated as BaseballTicketBayAlertRow)]).then(([item]) => item); } export async function deleteBaseballTicketBayAlert(alertId: string, scope: BaseballTicketBayOwnerScope) { @@ -1340,7 +1376,7 @@ export async function deleteBaseballTicketBayAlert(alertId: string, scope: Baseb db(BASEBALL_TICKET_BAY_ALERT_TABLE).where({ id: alertId }), scope, ).delete(); - return mapAlertRow(row as BaseballTicketBayAlertRow); + return attachOwnerLabels([mapAlertRow(row as BaseballTicketBayAlertRow)]).then(([item]) => item); } async function getAlertRow(alertId: string, scope?: BaseballTicketBayOwnerScope) { diff --git a/etc/servers/work-server/src/services/chat-message-parts.ts b/etc/servers/work-server/src/services/chat-message-parts.ts index 29df983..9963267 100644 --- a/etc/servers/work-server/src/services/chat-message-parts.ts +++ b/etc/servers/work-server/src/services/chat-message-parts.ts @@ -105,9 +105,103 @@ function unwrapMarkdownLinkTarget(value: string) { return matched?.[1]?.trim() ?? normalized; } +function normalizeResourceManagerPathSegment(segment: string) { + const normalized = normalizeText(segment); + + if (!normalized) { + return ''; + } + + try { + return encodeURIComponent(decodeURIComponent(normalized)); + } catch { + return encodeURIComponent(normalized); + } +} + +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) { @@ -123,10 +217,10 @@ function buildResourceManagerPreviewUrl(value: string) { const encodedPath = relativePath .split('/') .filter(Boolean) - .map((segment) => encodeURIComponent(segment)) + .map((segment) => normalizeResourceManagerPathSegment(segment)) .join('/'); - return encodedPath ? `${RESOURCE_MANAGER_PREVIEW_MARKER}${encodedPath}` : ''; + return encodedPath ? `${RESOURCE_MANAGER_PREVIEW_MARKER}${encodedPath}${hash ? `#${hash}` : ''}` : ''; } function normalizeUrl(value: string) { @@ -136,6 +230,17 @@ function normalizeUrl(value: string) { return ''; } + try { + 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 normalizePreviewPathHash(pathname); + } + } catch { + // Fall through to handle relative and embedded resource paths below. + } + const malformedResourceMatch = normalized.match(/^https?:\/(api\/chat\/resources\/.+)$/i); if (malformedResourceMatch?.[1]) { return `/${malformedResourceMatch[1]}`; diff --git a/etc/servers/work-server/src/services/chat-service.test.ts b/etc/servers/work-server/src/services/chat-service.test.ts index 74f2038..3f3eacc 100644 --- a/etc/servers/work-server/src/services/chat-service.test.ts +++ b/etc/servers/work-server/src/services/chat-service.test.ts @@ -1416,6 +1416,96 @@ test('extractChatMessageParts keeps prompt preview payloads for image markdown h ); }); +test('extractChatMessageParts preserves resource preview hash fragments when converting resource paths', () => { + assert.deepEqual( + extractChatMessageParts( + '[[prompt:{"title":"시안 선택","options":[{"label":"A안","value":"option-a","preview":{"type":"resource","url":"resource/Codex Live/공유채팅/채팅방 헤더 재배치 제안/20260527/docs/chat-room-header-notification-preview.html#option-a"}}]}]]', + ), + { + strippedText: '', + parts: [ + { + type: 'prompt', + title: '시안 선택', + description: null, + submitLabel: null, + mode: null, + multiple: false, + responseTemplate: null, + freeTextLabel: null, + freeTextPlaceholder: null, + currentStepKey: null, + readOnly: false, + selectedValues: [], + resolvedBy: null, + resolvedAt: null, + resultText: null, + steps: undefined, + options: [ + { + label: 'A안', + value: 'option-a', + description: null, + preview: { + type: 'resource', + url: '/api/resource-manager/preview/Codex%20Live/%EA%B3%B5%EC%9C%A0%EC%B1%84%ED%8C%85/%EC%B1%84%ED%8C%85%EB%B0%A9%20%ED%97%A4%EB%8D%94%20%EC%9E%AC%EB%B0%B0%EC%B9%98%20%EC%A0%9C%EC%95%88/20260527/docs/chat-room-header-notification-preview.html#option-a', + content: null, + alt: null, + title: null, + }, + }, + ], + }, + ], + }, + ); +}); + +test('extractChatMessageParts restores encoded resource preview hash fragments', () => { + assert.deepEqual( + extractChatMessageParts( + '[[prompt:{"title":"시안 선택","options":[{"label":"A안","value":"option-a","preview":{"type":"resource","url":"resource/Codex Live/공유채팅/채팅방 헤더 재배치 제안/20260527/docs/chat-room-header-notification-preview.html%23option-a"}}]}]]', + ), + { + strippedText: '', + parts: [ + { + type: 'prompt', + title: '시안 선택', + description: null, + submitLabel: null, + mode: null, + multiple: false, + responseTemplate: null, + freeTextLabel: null, + freeTextPlaceholder: null, + currentStepKey: null, + readOnly: false, + selectedValues: [], + resolvedBy: null, + resolvedAt: null, + resultText: null, + steps: undefined, + options: [ + { + label: 'A안', + value: 'option-a', + description: null, + preview: { + type: 'resource', + url: '/api/resource-manager/preview/Codex%20Live/%EA%B3%B5%EC%9C%A0%EC%B1%84%ED%8C%85/%EC%B1%84%ED%8C%85%EB%B0%A9%20%ED%97%A4%EB%8D%94%20%EC%9E%AC%EB%B0%B0%EC%B9%98%20%EC%A0%9C%EC%95%88/20260527/docs/chat-room-header-notification-preview.html#option-a', + content: null, + alt: null, + title: null, + }, + }, + ], + }, + ], + }, + ); +}); + test('extractChatMessageParts supports stepper prompt steps', () => { assert.deepEqual( extractChatMessageParts( diff --git a/etc/servers/work-server/src/services/chat-service.ts b/etc/servers/work-server/src/services/chat-service.ts index 1108547..50b1128 100644 --- a/etc/servers/work-server/src/services/chat-service.ts +++ b/etc/servers/work-server/src/services/chat-service.ts @@ -36,6 +36,7 @@ import { getSharedResourceTokenDetailBySharePath, validateSharedResourceAccessPinBySharePath, } from './shared-resource-token-service.js'; +import { getChatShareTokenRoomMap } from './chat-share-room-map-service.js'; import { sendNotifications } from './notification-service.js'; import { createNotificationMessage } from './notification-message-service.js'; import { @@ -794,10 +795,18 @@ async function hasAuthorizedChatSocketAccess(request: IncomingMessage, url: URL) const sharedToken = sharedTokenDetail?.token ?? null; const resourceContext = sharedToken?.resourceContext ?? null; - if (!resourceContext || (requestedSessionId && resourceContext.sessionId !== requestedSessionId)) { + if (!resourceContext) { return false; } + if (requestedSessionId && resourceContext.sessionId !== requestedSessionId) { + const requestedRoom = sharedToken?.id ? await getChatShareTokenRoomMap(sharedToken.id, requestedSessionId) : null; + + if (!requestedRoom) { + return false; + } + } + if (!sharedToken || !sharedToken.enabled || sharedToken.sharePath !== sharePath) { return false; } diff --git a/etc/servers/work-server/src/services/notification-service.ts b/etc/servers/work-server/src/services/notification-service.ts index 4b185d0..f775690 100644 --- a/etc/servers/work-server/src/services/notification-service.ts +++ b/etc/servers/work-server/src/services/notification-service.ts @@ -84,10 +84,14 @@ type NotificationPreferenceTarget = { function normalizeTargetDeviceIds(payload: { targetDeviceIds?: string[]; +}) { + return [...new Set((payload.targetDeviceIds ?? []).map((value) => String(value ?? '').trim()).filter(Boolean))]; +} + +function normalizeTargetClientIds(payload: { targetClientIds?: string[]; }) { - const targetDeviceIds = payload.targetDeviceIds ?? payload.targetClientIds; - return [...new Set((targetDeviceIds ?? []).map((value) => String(value ?? '').trim()).filter(Boolean))]; + return [...new Set((payload.targetClientIds ?? []).map((value) => String(value ?? '').trim()).filter(Boolean))]; } function normalizeRegistrationCleanupIds(...values: Array) { @@ -126,18 +130,21 @@ function normalizeTargetAppDomains(targetAppDomains: string[] | undefined) { return [...new Set((targetAppDomains ?? []).map((value) => normalizeAppDomain(value)).filter(Boolean))]; } -function isAllowedTargetDeviceId( +function isAllowedTargetRecipient( target: { deviceId?: string; + clientId?: string; }, targetDeviceIds: string[], + targetClientIds: string[], ) { - if (targetDeviceIds.length === 0) { + if (targetDeviceIds.length === 0 && targetClientIds.length === 0) { return true; } const deviceId = String(target.deviceId ?? '').trim(); - return Boolean(deviceId) && targetDeviceIds.includes(deviceId); + const clientId = String(target.clientId ?? '').trim(); + return (Boolean(deviceId) && targetDeviceIds.includes(deviceId)) || (Boolean(clientId) && targetClientIds.includes(clientId)); } function normalizeAppOrigin(value: unknown) { @@ -917,6 +924,7 @@ export async function sendIosNotifications(payload: IosNotificationPayload) { const env = getEnv(); const provider = await getProvider(); const targetDeviceIds = normalizeTargetDeviceIds(payload); + const targetClientIds = normalizeTargetClientIds(payload); const targetAppOrigins = normalizeTargetAppOrigins(payload.targetAppOrigins); const targetAppDomains = normalizeTargetAppDomains(payload.targetAppDomains); @@ -952,7 +960,7 @@ export async function sendIosNotifications(payload: IosNotificationPayload) { .filter( (row) => row.allowed && - isAllowedTargetDeviceId({ deviceId: row.deviceId }, targetDeviceIds) && + isAllowedTargetRecipient({ deviceId: row.deviceId }, targetDeviceIds, targetClientIds) && isAllowedAppTarget(row, targetAppOrigins, targetAppDomains), ) .map((row) => row.token); @@ -1002,6 +1010,7 @@ export async function sendIosNotifications(payload: IosNotificationPayload) { async function sendWebPushNotifications(payload: IosNotificationPayload) { const env = getEnv(); const targetDeviceIds = normalizeTargetDeviceIds(payload); + const targetClientIds = normalizeTargetClientIds(payload); const targetAppOrigins = normalizeTargetAppOrigins(payload.targetAppOrigins); const targetAppDomains = normalizeTargetAppDomains(payload.targetAppDomains); if (!ensureWebPushConfigured(env)) { @@ -1031,7 +1040,7 @@ async function sendWebPushNotifications(payload: IosNotificationPayload) { ).filter( (row) => row.allowed && - isAllowedTargetDeviceId({ deviceId: row.deviceId }, targetDeviceIds) && + isAllowedTargetRecipient({ deviceId: row.deviceId, clientId: row.clientId }, targetDeviceIds, targetClientIds) && isAllowedAppTarget(row, targetAppOrigins, targetAppDomains), ); const matchedSubscriptions = subscriptions.map((row) => ({ diff --git a/etc/servers/work-server/src/services/resource-manager-service.ts b/etc/servers/work-server/src/services/resource-manager-service.ts index 11cdb36..21f7690 100644 --- a/etc/servers/work-server/src/services/resource-manager-service.ts +++ b/etc/servers/work-server/src/services/resource-manager-service.ts @@ -63,6 +63,7 @@ class ResourceManagerError extends Error { const RESOURCE_MANAGER_ROOT_DIR = 'resource'; const RESOURCE_MANAGER_ROOT_LABEL = 'resource'; +const LEGACY_PUBLIC_RESOURCE_ROOT_DIR = path.join('public', 'resource'); const TEXT_FILE_EXTENSIONS = new Set([ '.txt', @@ -199,6 +200,52 @@ function normalizeRelativeTarget(relativePath: string | null | undefined) { return normalized.replace(/^\/+/, ''); } +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 normalizePreviewTargetPath(targetPath: string) { + const normalized = normalizeRelativeTarget(targetPath); + + if (!normalized) { + return normalized; + } + + const segments = normalized.split('/'); + const lastSegment = segments.at(-1) ?? ''; + const decodedLastSegment = decodeRepeatedly(lastSegment); + const hashIndex = decodedLastSegment.lastIndexOf('#'); + + if (hashIndex <= 0) { + return normalized; + } + + const fileName = decodedLastSegment.slice(0, hashIndex).trim(); + + if (!fileName || !/\.[a-z0-9]{1,16}$/i.test(fileName)) { + return normalized; + } + + segments[segments.length - 1] = fileName; + return normalizeRelativeTarget(segments.join('/')); +} + function resolveRepoRoot(candidateRootPath: string) { const candidates = [ candidateRootPath, @@ -221,6 +268,30 @@ export function resolveResourceManagerRoot(repoRootPath: string) { return path.join(resolveRepoRoot(repoRootPath), RESOURCE_MANAGER_ROOT_DIR); } +function resolveLegacyPublicResourceRoot(repoRootPath: string) { + return path.join(resolveRepoRoot(repoRootPath), LEGACY_PUBLIC_RESOURCE_ROOT_DIR); +} + +function resolveLegacyPublicResourcePreviewPath(repoRootPath: string, targetPath: string) { + const normalizedRelativePath = normalizeRelativeTarget(targetPath); + + if (!normalizedRelativePath) { + throw new ResourceManagerError('리소스를 찾을 수 없습니다.', 404); + } + + const rootPath = resolveLegacyPublicResourceRoot(repoRootPath); + const absolutePath = path.resolve(rootPath, normalizedRelativePath); + + if (absolutePath !== rootPath && !absolutePath.startsWith(`${rootPath}${path.sep}`)) { + throw new ResourceManagerError('허용되지 않은 경로입니다.', 403); + } + + return { + absolutePath, + relativePath: normalizedRelativePath, + }; +} + function resolveResourceManagerTargetPath(repoRootPath: string, relativePath: string) { const rootPath = resolveResourceManagerRoot(repoRootPath); const normalizedRelativePath = normalizeRelativeTarget(relativePath); @@ -604,7 +675,12 @@ export async function deleteResourceManagerItem(repoRootPath: string, targetPath export async function openResourceManagerPreviewStream(repoRootPath: string, targetPath: string) { return withResourceManagerError(async () => { - const { absolutePath } = resolveResourceManagerTargetPath(repoRootPath, targetPath); + const normalizedTargetPath = normalizePreviewTargetPath(targetPath); + const primaryTarget = resolveResourceManagerTargetPath(repoRootPath, normalizedTargetPath); + const legacyTarget = resolveLegacyPublicResourcePreviewPath(repoRootPath, normalizedTargetPath); + const absolutePath = existsSync(primaryTarget.absolutePath) + ? primaryTarget.absolutePath + : legacyTarget.absolutePath; if (!existsSync(absolutePath)) { throw new ResourceManagerError('리소스를 찾을 수 없습니다.', 404); diff --git a/public/resource/Codex Live/공유채팅/공유채팅방 개선/20260528/docs/feature-spec.md b/public/resource/Codex Live/공유채팅/공유채팅방 개선/20260528/docs/feature-spec.md new file mode 100644 index 0000000..8becf36 --- /dev/null +++ b/public/resource/Codex Live/공유채팅/공유채팅방 개선/20260528/docs/feature-spec.md @@ -0,0 +1,28 @@ +# 공유채팅방 개선 + +## 변경 목적 +- stepper prompt에서 HTML preview가 객체 재생성마다 다시 fetch/reset 되며 멈춘 것처럼 보이던 흐름을 줄입니다. +- 공유채팅방 이동 시 이미 본 방은 즉시 복원하고, 최신화는 뒤에서 다시 받아 체감 로딩을 줄입니다. +- 재접속 시 마지막으로 사용한 공유채팅방을 다시 열 때, 마지막 방 ID뿐 아니라 해당 방 스냅샷도 세션 기준으로 복원합니다. + +## 변경 범위 +- `src/app/main/mainChatPanel/ChatPromptCard.tsx` + - preview fetch effect 의존성을 안정화했습니다. + - preview 본문/`content-type`을 메모리 캐시에 저장해 같은 HTML/markdown/resource preview 재진입 시 재요청을 줄였습니다. + - preview viewed / selection change effect에서 객체 참조 의존성을 줄여 stepper 렌더 루프 가능성을 낮췄습니다. +- `src/app/main/pages/ChatSharePage.tsx` + - 공유채팅방 스냅샷을 `sessionStorage`에도 저장하도록 추가했습니다. + - 토큰별 마지막 방 복원 시 세션 캐시 스냅샷을 먼저 적용하도록 보강했습니다. + - 방 전환 시 메모리 캐시가 없더라도 세션 캐시가 있으면 즉시 그 스냅샷으로 전환하도록 보강했습니다. + +## 데이터 / API 영향 +- 새 저장소 키 + - `sessionStorage`: `codex-live-share-room-snapshot::` +- 기존 API 계약 변경 없음 + - `/api/chat/shares/:token` + - `/api/chat/shares/:token?sessionId=...` + +## 확인 포인트 +- 같은 stepper prompt preview를 다시 펼쳐도 로딩 스피너가 계속 반복되지 않는지 +- 이미 열어본 공유채팅방을 다시 눌렀을 때 화면이 캐시로 먼저 복원되는지 +- 새로고침 후 마지막 사용 방 URL / 선택 상태가 유지되는지 diff --git a/public/resource/Codex Live/공유채팅/공유채팅방 개선/20260528/final-ui/ui-final-desktop-01.png b/public/resource/Codex Live/공유채팅/공유채팅방 개선/20260528/final-ui/ui-final-desktop-01.png new file mode 100644 index 0000000..f582fc9 Binary files /dev/null and b/public/resource/Codex Live/공유채팅/공유채팅방 개선/20260528/final-ui/ui-final-desktop-01.png differ diff --git a/public/resource/Codex Live/공유채팅/공유채팅방 개선/20260528/final-ui/ui-final-mobile-01.png b/public/resource/Codex Live/공유채팅/공유채팅방 개선/20260528/final-ui/ui-final-mobile-01.png new file mode 100644 index 0000000..db49856 Binary files /dev/null and b/public/resource/Codex Live/공유채팅/공유채팅방 개선/20260528/final-ui/ui-final-mobile-01.png differ diff --git a/public/resource/Codex Live/공유채팅/공유채팅방 개선/20260528/verification/verification-share-room-restore-01.png b/public/resource/Codex Live/공유채팅/공유채팅방 개선/20260528/verification/verification-share-room-restore-01.png new file mode 100644 index 0000000..f582fc9 Binary files /dev/null and b/public/resource/Codex Live/공유채팅/공유채팅방 개선/20260528/verification/verification-share-room-restore-01.png differ diff --git a/public/resource/Codex Live/공유채팅/공유채팅방 개선/20260528/verification/verification-summary.md b/public/resource/Codex Live/공유채팅/공유채팅방 개선/20260528/verification/verification-summary.md new file mode 100644 index 0000000..eabadca --- /dev/null +++ b/public/resource/Codex Live/공유채팅/공유채팅방 개선/20260528/verification/verification-summary.md @@ -0,0 +1,24 @@ +# 공유채팅방 개선 검증 + +## 실행 결과 +- `npm exec tsc --noEmit` + - 성공 +- `npm run build:test-app` + - 성공 +- `curl http://127.0.0.1:5173/api/chat/shares/5e578dd5e91a4fa8b32cfe3c?sharePin=1459` + - 성공 + - 응답 기준: `ok: true`, `title: 관리자`, `sessionId: chat-share-room-mpihlq67-ae86e941`, `requestCount: 5` + +## 브라우저 확인 +- 로컬 test-app 빌드로 `/chat/share/:token` 진입 시 공유채팅 셸 자체는 열렸습니다. +- 다만 이 격리 빌드 환경에서는 화면이 로딩 스피너 상태에 머무는 케이스가 있어, 이번 턴에서는 신뢰 가능한 기능 완료 화면 캡처까지는 확보하지 못했습니다. +- 따라서 이번 검증 결론은 다음 범위로 한정합니다. + - 타입 오류 없음 + - 프로덕션 테스트 빌드 성공 + - 공유채팅 스냅샷 API 정상 응답 + - 로컬 브라우저 진입 자체는 가능 + +## 판정 +- 코드 변경은 정상 반영됨 +- stepper HTML preview 안정화와 공유채팅방 캐시/복원 로직은 정적 검증 + API 검증까지 완료 +- 실서버 UI 최종 체감 확인은 공유채팅 실환경에서 한 번 더 보는 것이 안전함 diff --git a/public/resource/Codex Live/공유채팅/공유채팅방 멈춤 완화/20260528/docs/feature-spec.md b/public/resource/Codex Live/공유채팅/공유채팅방 멈춤 완화/20260528/docs/feature-spec.md new file mode 100644 index 0000000..0a1541f --- /dev/null +++ b/public/resource/Codex Live/공유채팅/공유채팅방 멈춤 완화/20260528/docs/feature-spec.md @@ -0,0 +1,27 @@ +# 공유채팅방 멈춤 완화 + +## 변경 배경 +- 1차 수정으로 `sessionStorage`에 공유방 스냅샷을 직렬화하던 경로는 제거했지만, 큰 관리형 공유채팅방에서는 여전히 서버가 최근 1000건 요청/메시지를 한 번에 내려주고 있었습니다. +- 공유채팅 페이지도 검색 모달이 닫힌 상태에서 질문·응답·리소스·활동로그 통합검색 인덱스를 매 렌더마다 전수 계산하고 있어, 큰 방에서는 첫 진입과 갱신 시 추가 부담이 남아 있었습니다. + +## 변경 내용 +- 기존 `sessionStorage` 제거 상태는 유지합니다. +- `etc/servers/work-server/src/routes/chat.ts` + - 관리형 공유채팅방(`MANAGED_CHAT_SHARE_SESSION_PREFIX`) 스냅샷은 최근 80건 요청 기준의 detail page만 내려주도록 바꿨습니다. + - 공유채팅 초기 진입과 실시간 갱신이 더 이상 최근 1000건 전체 요청/메시지를 항상 읽지 않게 했습니다. +- `src/app/main/pages/ChatSharePage.tsx` + - 통합검색 결과 계산은 검색 모달이 실제로 열렸을 때만 수행하도록 바꿨습니다. + - 방 진입 직후에는 닫혀 있는 검색 패널 때문에 질문/응답/리소스/활동로그 전체를 훑지 않습니다. + +## 기대 효과 +- 큰 공유채팅방에서도 초기 진입과 자동 새로고침이 최근 이력 중심으로 동작해 멈춤 체감이 줄어듭니다. +- 재접속 시 마지막 방 복원 기능은 유지하면서, 서버/프런트 양쪽의 불필요한 대량 계산을 줄입니다. + +## 영향 범위 +- 공유채팅 페이지의 검색 계산 시점과 공유 스냅샷 응답 범위를 조정했습니다. +- DB 스키마와 공유채팅 권한 로직은 변경하지 않았습니다. + +## 확인 포인트 +- 관리형 공유채팅방 첫 진입 시 최근 이력 기준으로 빠르게 열리는지 확인 +- 메시지/활동로그가 많은 방에서도 방 이동·새로고침·실시간 갱신 시 멈춤 체감이 줄었는지 확인 +- 검색 모달을 열지 않았을 때는 통합검색 전수 계산이 돌지 않는지 확인 diff --git a/public/resource/Codex Live/공유채팅/공유채팅방 멈춤 완화/20260528/verification/verification-summary.md b/public/resource/Codex Live/공유채팅/공유채팅방 멈춤 완화/20260528/verification/verification-summary.md new file mode 100644 index 0000000..d8e1e59 --- /dev/null +++ b/public/resource/Codex Live/공유채팅/공유채팅방 멈춤 완화/20260528/verification/verification-summary.md @@ -0,0 +1,15 @@ +# 검증 요약 + +## 실행한 검증 +- `npm exec tsc --noEmit` +- `npx tsc -p etc/servers/work-server/tsconfig.json --noEmit` +- `npm run build:test-app` + +## 결과 +- 프런트 타입 검사와 `work-server` 타입 검사는 모두 통과했습니다. +- 테스트 번들은 재빌드까지 통과했습니다. +- 이번 수정으로 관리형 공유채팅방 스냅샷은 최근 80건 detail page 기준으로 줄였고, 검색 모달이 닫혀 있을 때는 통합검색 계산을 건너뜁니다. + +## 비고 +- 이번 수정은 UI 레이아웃 변경이 아니라 응답량·계산량 축소라서, 최종 화면 캡처 대신 타입/빌드 검증과 코드 경로 문서화를 남겼습니다. +- `preview.sm-home.cloud` 실접속/잠금 해제 재현은 이번 턴에서 다시 수행하지 못했습니다. diff --git a/public/resource/Codex Live/공유채팅/채팅방 이동 소도 개선/20260528/docs/feature-spec.md b/public/resource/Codex Live/공유채팅/채팅방 이동 소도 개선/20260528/docs/feature-spec.md new file mode 100644 index 0000000..a81e778 --- /dev/null +++ b/public/resource/Codex Live/공유채팅/채팅방 이동 소도 개선/20260528/docs/feature-spec.md @@ -0,0 +1,20 @@ +# 공유채팅 채팅방 이동 소도 개선 + +## 변경 요약 +- 공유채팅방 마지막 선택 방 저장을 `localStorage`에서 `sessionStorage`로 변경했습니다. +- 같은 탭 안에서는 마지막으로 보던 방을 복원하지만, 브라우저를 완전히 닫으면 기억을 남기지 않습니다. +- 채팅방 선택 시 `roomSessionId`를 URL에 반영할 때 사용자 선택은 `pushState`, 자동 보정은 `replaceState`로 나눴습니다. +- 브라우저 뒤로가기/앞으로가기 시 현재 URL의 `roomSessionId`를 다시 읽어 선택 방과 동기화합니다. + +## 변경 범위 +- 공유채팅 화면의 방 선택/복원/URL 동기화 로직 +- 영구 저장 제거에 따른 탭 세션 단위 이동 상태 복원 + +## 데이터 및 API 영향 +- 서버 API 스펙 변경은 없습니다. +- 클라이언트 저장소 사용 범위만 `localStorage` -> `sessionStorage`로 바뀝니다. + +## 확인 포인트 +- 공유채팅에서 방을 바꾼 뒤 새로고침하면 같은 탭에서는 마지막 방이 유지되는지 +- 브라우저 뒤로가기/앞으로가기 때 이전/다음 방으로 이동되는지 +- 브라우저를 완전히 닫았다가 다시 열면 이전 방이 영구 복원되지 않는지 diff --git a/public/resource/Codex Live/공유채팅/채팅방 이동 소도 개선/20260528/verification/verification-summary.md b/public/resource/Codex Live/공유채팅/채팅방 이동 소도 개선/20260528/verification/verification-summary.md new file mode 100644 index 0000000..e47e2df --- /dev/null +++ b/public/resource/Codex Live/공유채팅/채팅방 이동 소도 개선/20260528/verification/verification-summary.md @@ -0,0 +1,11 @@ +# 검증 요약 + +## 수행 내용 +- `npm exec tsc --noEmit` + +## 결과 +- 타입체크 통과 + +## 미수행 항목 +- `https://preview.sm-home.cloud/` 브라우저 실접속 검증은 이번 턴에서 수행하지 못했습니다. +- 시각 레이아웃 변경이 아니라서 별도 UI 스크린샷은 생성하지 않았습니다. diff --git a/public/resource/Codex Live/공유채팅/채팅방 헤더 재배치 제안/20260527/docs/chat-room-header-notification-preview.html b/public/resource/Codex Live/공유채팅/채팅방 헤더 재배치 제안/20260527/docs/chat-room-header-notification-preview.html new file mode 100644 index 0000000..1681ea9 --- /dev/null +++ b/public/resource/Codex Live/공유채팅/채팅방 헤더 재배치 제안/20260527/docs/chat-room-header-notification-preview.html @@ -0,0 +1,709 @@ + + + + + + 공유채팅 헤더 재배치 제안 + + + +
+
+ 공유채팅 실제 테마 기반 제안 +

채팅방 헤더를 방 목록 + 알림센터 허브로 재구성

+

+ 현재 공유채팅이 쓰는 옅은 블루 그라데이션, 반투명 화이트 surface, 파란 pill 액션 톤을 유지하면서 + 헤더의 역할을 명확히 분리했습니다. 공통 방향은 “제목/아이콘 클릭으로 방 목록”, “중앙 손잡이 드래그로 + iOS 알림센터 스타일”, “현재 방과 다른 방 알림을 한 시트에서 함께 확인”입니다. +

+
+ 현재 테마: #edf3fb → #e4edf8 + 액션 톤: white pill + #2563eb + 재질감: blur + soft shadow + 상태칩: blue / green / amber / red +
+
+ +
+
+
+

A안. Capsule Rail

+

가장 자연스럽게 익숙한 안입니다. 제목 캡슐이 방 목록 진입점이 되고, 중앙 손잡이를 내려 전체 알림센터를 펼칩니다.

+
+
+ 추천: 모바일 우선 + 방 목록 발견성 높음 + 알림센터 구분 명확 +
+
+
9:41Live 5G 92%
+
+
+
+
CC
+
+ 공유채팅 운영룸 + 제목/아이콘 탭: 방 목록 + 필터 열기 +
+
+
설정
+
-
+
×
+
+
+
진행중 6 · 다른 방 새답변 4
+
apps 2건
+
+
+
+ +
+
+ 알림센터 + 전체 채팅 + apps +
+ +
+
+
12처리중 요청
+
4다른 방 새 답변
+
2apps 경고
+
1확인 필요 실패
+
+
+ +
+ +
+
+
전체 방18
+ 최근답변, 처리중, 안읽음, apps 연결방 필터를 같은 레이어에서 전환 +
+
+
개발 운영방새 답변
+ 3분 전 Codex 응답 도착 · 앱 연결 2개 +
+
+
+ +
+ +
+
+
자동화 공유방진행중
+

배포 확인 캡처 업로드 완료. 검증 스크린샷 2장 확인 필요.

+
+
+
Apps 알림권한
+

캘린더 동기화 1건 지연. 알림센터에서 바로 상세 열기 버튼 제공.

+
+
+
+
+ +
+
헤더 제목을 누르면 바로 방 목록과 필터가 한 번에 보여야 합니다.
+
A안은 그 요구를 가장 직접적으로 충족합니다.
+
+
+
+ +
+
+

B안. Split Status Bar

+

좌측은 현재 방, 우측은 전체 알림과 apps 상태를 쌓아 두는 데스크톱 친화형입니다. 헤더 하단은 세그먼트 필터로 남깁니다.

+
+
+ 추천: 데스크톱 확장 + 필터 접근 가장 빠름 + 정보량 많음 +
+
+
9:41Workspace online
+
+
+
+
PM
+
+ 프로젝트 메인룸 + 아이콘/제목 클릭: 방 전환 +
+
+
다른 방 4
+
apps 2
+
+
+
전체
+
진행중
+
안읽음
+
apps
+
+
+
+ +
+
+ Notification Center Dashboard + 실시간 집계 +
+
+
08현재 방 처리 흐름
+
03다른 방 확인대기
+
05apps 작업 알림
+
02실패/재시도
+
+ +
+ +
+
+
현재 방3건
+

작업중인 요청, 마지막 응답 시간, 첨부 리소스 생성 수를 카드형으로 고정 배치

+
+
+
다른 채팅방새 답변
+

현재 방이 아니어도 읽지 않은 응답과 mention 성격 요청을 한 섹션에 모아 보여줌

+
+
+
Apps확인 필요
+

캘린더, 알림, 연결 앱의 상태 메시지를 채팅 알림과 동일한 카드 리듬으로 정렬

+
+
+
+
+ +
+
필터를 자주 바꾸는 운영자라면 헤더 안에서 바로 전환하고 싶습니다.
+
B안은 필터 중심 운영에 가장 유리합니다.
+
+
+
+ +
+
+

C안. Focus Stack

+

현재 작업중인 방을 더 크게 인지시키는 집중형입니다. 알림센터는 “현재 방 집중 + 다른 방 보조” 우선순위가 드러납니다.

+
+
+ 추천: 집중 작업 + 브랜드감 강함 + 운영감시형 대시보드 +
+
+
9:41Preview theme sync
+
+
+
+
UX
+
+ UX 검토 채팅방 + Hero chip 탭: 방 목록 / 필터 / 최근방 +
+
+
+
+
+
현재 방 진행중 4
+
전체 알림 9
+
+
+
+ +
+
+ 집중 대시보드 + 현재 방 우선 +
+
+
4현재 방 처리중
+
2완료 직전
+
3다른 방 확인
+
1apps 경고
+
+ +
+ +
+
+
현재 방 요청우선
+

마지막 응답 이후 7분 경과. 첨부 preview 3개 생성됨. 바로 이어보기 버튼 노출.

+
+
+
다른 방 답변보조
+

메인 운영방에 새 응답 2건. 눌러서 방 전환 없이 quick peek 가능.

+
+
+
Apps 이벤트연결
+

배포 완료, 캘린더 일정, 리소스 등록 완료 이벤트를 낮은 대비 카드로 정렬.

+
+
+
+
+ +
+
작업 중인 방을 잃지 않으면서도 다른 방 알림은 놓치고 싶지 않습니다.
+
C안은 집중감은 가장 좋지만, 운영형 전체 목록성은 A안보다 약합니다.
+
+
+
+
+ + +
+ + diff --git a/public/resource/Codex Live/공유채팅/채팅방 헤더 재배치 제안/20260527/docs/feature-spec.md b/public/resource/Codex Live/공유채팅/채팅방 헤더 재배치 제안/20260527/docs/feature-spec.md new file mode 100644 index 0000000..f4dfd99 --- /dev/null +++ b/public/resource/Codex Live/공유채팅/채팅방 헤더 재배치 제안/20260527/docs/feature-spec.md @@ -0,0 +1,53 @@ +# 공유채팅 채팅방 헤더 재배치 제안 + +## 목적 +- 실제 공유채팅에서 사용하는 라이트 블루 테마를 유지한 채, 헤더를 방 선택과 알림센터 진입의 허브로 재구성한다. +- 채팅방 제목 또는 아이콘 선택 시 필터와 채팅방 목록을 함께 노출하고, 헤더 손잡이를 아래로 내리면 iOS 알림센터 같은 요약 대시보드와 알림 피드를 보여준다. +- 기존 기능은 유지하되, 설정/최소화/닫기 같은 툴 액션은 헤더 집중도를 해치지 않도록 보조 영역으로 정리한다. + +## 현재 테마 확인 기준 +- 공유채팅 카드 바디는 `#edf3fb → #e4edf8` 계열 세로 그라데이션과 얕은 inset border, soft shadow를 사용한다. +- 헤더 액션은 흰색 pill 버튼 위에 `#2563eb` 아이콘 포인트를 두고 hover 시 더 밝은 블루 계열로 반응한다. +- 헤더 배경은 반투명 흰색보다 한 단계 채도 낮은 블루톤이며 blur가 들어간다. +- 방 상태/요청 상태는 회색, 파랑, 초록, 빨강 계열 pill로 구분한다. + +## 제안 방향 공통 원칙 +- 헤더 1행은 현재 방 인지와 이동, 헤더 2행은 상태/필터/알림센터 진입으로 역할을 명확히 분리한다. +- 방 목록은 제목 또는 아이콘을 누르는 행위 하나로 열리도록 통합하고, 목록 안에서 필터 칩과 최근 대화 프리뷰를 동시에 보여준다. +- 알림센터는 현재 방 전용이 아니라 전체 채팅방과 apps 알림을 함께 집계한다. +- 손잡이 드래그는 모달보다 시스템 오버레이처럼 느껴지게 하고, 상단에는 다시보드형 요약 카드를 고정한다. + +## 제안안 구성 +### A안 Capsule Rail +- 제목 캡슐 자체가 방 목록 트리거다. +- 헤더 중앙 손잡이를 내려 알림센터를 여는 패턴으로 가장 직관적이다. +- 알림센터 상단은 처리중, 새 답변, apps 경고, 캘린더성 일정 같은 4분할 다시보드다. + +### B안 Split Status Bar +- 좌측은 방 정보, 우측은 앱 알림과 빠른 전환 상태를 모은 split bar 구조다. +- 필터를 헤더 하단의 segment row로 남겨 자주 쓰는 상태 전환을 더 빠르게 한다. +- 데스크톱 확장성은 좋지만 모바일에서는 약간 더 촘촘해질 수 있다. + +### C안 Focus Stack +- 방 아이콘과 제목을 한 덩어리 hero chip으로 키워 현재 방 인지를 강화한다. +- 알림센터 대시보드 카드를 더 크게 두고, 현재 처리중 요청 중심의 집중도를 높인다. +- 정보량이 많을 때보다 “현재 작업 집중” 상황에 적합하다. + +## 유지되어야 할 기존 기능 +- 방 이동 +- 필터 전환 +- 설정 +- 최소화 +- 닫기 +- 현재 연결 상태 표시 +- 현재 방 상태 요약 + +## 알림센터 추천 콘텐츠 +- 상단 고정 다시보드: 처리중 요청 수, 읽지 않은 다른 채팅방, apps 경고/완료, 오늘 일정 또는 예약 작업 +- 중단: 현재 방 진행 카드와 다른 방 새 답변 카드 혼합 피드 +- 하단: 앱별 알림 묶음, 빠른 액션, 전체 읽음/필터 토글 + +## 검토 포인트 +- 방 목록과 알림센터를 둘 다 헤더에 얹되 탭 충돌 없이 한 손 조작이 가능한지 +- 모바일에서 드래그 손잡이와 브라우저 스크롤 제스처가 충돌하지 않는지 +- 현재 존재하는 설정/최소화/닫기 액션을 보조 영역으로 빼도 발견 가능성이 유지되는지 diff --git a/src/app/main/AppShell.tsx b/src/app/main/AppShell.tsx index 6bbcc39..e617fab 100644 --- a/src/app/main/AppShell.tsx +++ b/src/app/main/AppShell.tsx @@ -12,6 +12,7 @@ import { isPreviewRuntime } from './previewRuntime'; export function AppShell() { return ( + } /> } /> } /> }> diff --git a/src/app/main/MainHeader.tsx b/src/app/main/MainHeader.tsx index 5d980c4..95ea326 100644 --- a/src/app/main/MainHeader.tsx +++ b/src/app/main/MainHeader.tsx @@ -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({ +
+ { + setAppConfigDraft((current) => ({ + ...current, + chat: { + ...current.chat, + guidePwaInstallForNotifications: event.target.checked, + }, + })); + }} + > + PWA 설치 유도 + + + 브라우저 탭에서 알림 설정이 막히는 환경이면 홈 화면 앱으로 열도록 안내하는 개인 기본값입니다. + +
+ +
+ { + setAppConfigDraft((current) => ({ + ...current, + chat: { + ...current.chat, + guideWebPushPermission: event.target.checked, + }, + })); + }} + > + 웹푸시 권한 유도 + + + 알림 권한이 미확인 상태면 브라우저 또는 기기 권한 허용을 먼저 안내하는 개인 기본값입니다. + +
+ +
+ { + setAppConfigDraft((current) => ({ + ...current, + chat: { + ...current.chat, + guideWebPushRegistration: event.target.checked, + }, + })); + }} + > + 웹푸시 등록 유도 + + + 권한은 허용됐지만 현재 기기 구독이 없으면 웹푸시 등록까지 이어서 안내하는 개인 기본값입니다. + +
+
Codex Live 최대 실행 시간(초) Codex Live 요청 1건이 강제 종료되기 전까지 허용할 최대 실행 시간입니다. diff --git a/src/app/main/SharedAppSettingsPage.tsx b/src/app/main/SharedAppSettingsPage.tsx index 80323af..0dd7dd4 100644 --- a/src/app/main/SharedAppSettingsPage.tsx +++ b/src/app/main/SharedAppSettingsPage.tsx @@ -7,7 +7,6 @@ import { saveAppConfigToServer, type AppConfig, type PlanCostTimeUnit, - type WeeklyScheduleDay, } from './appConfig'; import './SharedAppSettingsPage.css'; diff --git a/src/app/main/appConfig.ts b/src/app/main/appConfig.ts index 778f69e..6266917 100644 --- a/src/app/main/appConfig.ts +++ b/src/app/main/appConfig.ts @@ -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 { 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, diff --git a/src/app/main/mainChatPanel/ChatPromptCard.tsx b/src/app/main/mainChatPanel/ChatPromptCard.tsx index e1e4bd9..95ed071 100644 --- a/src/app/main/mainChatPanel/ChatPromptCard.tsx +++ b/src/app/main/mainChatPanel/ChatPromptCard.tsx @@ -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(); 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) => { 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) => { - 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; }); }; diff --git a/src/app/main/mainChatPanel/chatResourceUrl.ts b/src/app/main/mainChatPanel/chatResourceUrl.ts index a5f625f..6565dfb 100644 --- a/src/app/main/mainChatPanel/chatResourceUrl.ts +++ b/src/app/main/mainChatPanel/chatResourceUrl.ts @@ -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) { diff --git a/src/app/main/mainChatPanel/messageParts.ts b/src/app/main/mainChatPanel/messageParts.ts index de4152b..b75c5d0 100644 --- a/src/app/main/mainChatPanel/messageParts.ts +++ b/src/app/main/mainChatPanel/messageParts.ts @@ -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; diff --git a/src/app/main/notificationApi.ts b/src/app/main/notificationApi.ts index fa707a2..d2ef84b 100644 --- a/src/app/main/notificationApi.ts +++ b/src/app/main/notificationApi.ts @@ -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('/notifications/send', { method: 'POST', body: JSON.stringify({ ...payload, - targetDeviceIds, data: notificationData, }), }); diff --git a/src/app/main/pages/ChatSharePage.tsx b/src/app/main/pages/ChatSharePage.tsx index fbfd2a8..0752d5a 100644 --- a/src/app/main/pages/ChatSharePage.tsx +++ b/src/app/main/pages/ChatSharePage.tsx @@ -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; @@ -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([]); diff --git a/src/views/play/apps/baseball-ticket-bay/BaseballTicketBayPlayAppView.tsx b/src/views/play/apps/baseball-ticket-bay/BaseballTicketBayPlayAppView.tsx index c203fb2..d259ca4 100644 --- a/src/views/play/apps/baseball-ticket-bay/BaseballTicketBayPlayAppView.tsx +++ b/src/views/play/apps/baseball-ticket-bay/BaseballTicketBayPlayAppView.tsx @@ -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, + item: Pick + & Partial>, ) => { 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', {item.ticketTitle}
{isViewingAllClients || isSharedTokenScope ? ( - {formatOwnershipLabel(item.ownerType, item.ownerId, item.clientId)} + {formatOwnershipLabel(item.ownerType, item.ownerId, item.clientId, item.ownerLabel)} ) : null} 성공 @@ -1651,7 +1666,7 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct', {isViewingAllClients || isSharedTokenScope ? ( - {formatOwnershipLabel(selectedSuccessRow.ownerType, selectedSuccessRow.ownerId, selectedSuccessRow.clientId)} + {formatOwnershipLabel(selectedSuccessRow.ownerType, selectedSuccessRow.ownerId, selectedSuccessRow.clientId, selectedSuccessRow.ownerLabel)} ) : null}