diff --git a/etc/servers/work-server/src/routes/chat.ts b/etc/servers/work-server/src/routes/chat.ts index 375a30c..8d2c39c 100644 --- a/etc/servers/work-server/src/routes/chat.ts +++ b/etc/servers/work-server/src/routes/chat.ts @@ -1591,6 +1591,7 @@ export async function registerChatRoutes(app: FastifyInstance) { accessPinPromptTtlMinutes: z.number().int().min(0).max(7 * 24 * 60).optional().nullable(), chatTypeId: z.string().trim().min(1).max(120).optional().nullable(), chatTypeLabel: z.string().trim().min(1).max(200).optional().nullable(), + title: z.string().trim().min(1).max(200).optional().nullable(), notifyOffline: z.boolean().optional().nullable(), }).parse(request.body ?? {}); const managedContext = await resolveManagedChatShareContext(params.token); @@ -1655,13 +1656,14 @@ export async function registerChatRoutes(app: FastifyInstance) { let updatedConversation = await getChatConversation(tokenPayload.sessionId, getRequestClientId(request)); - if (payload.chatTypeId || payload.notifyOffline != null) { + if (payload.chatTypeId || payload.title || payload.notifyOffline != null) { updatedConversation = await updateChatConversationContext(tokenPayload.sessionId, { clientId: getRequestClientId(request), chatTypeId: payload.chatTypeId?.trim() || undefined, lastChatTypeId: payload.chatTypeId?.trim() || undefined, contextLabel: payload.chatTypeLabel?.trim() || undefined, contextDescription: payload.chatTypeId ? null : undefined, + title: payload.title?.trim() || undefined, notifyOffline: payload.notifyOffline ?? undefined, }); } diff --git a/src/app/main/mainChatPanel/chatUtils.ts b/src/app/main/mainChatPanel/chatUtils.ts index f00b4b7..1c70039 100644 --- a/src/app/main/mainChatPanel/chatUtils.ts +++ b/src/app/main/mainChatPanel/chatUtils.ts @@ -2569,6 +2569,7 @@ export async function saveChatShareRoomSettings( accessPinPromptTtlMinutes?: number | null; chatTypeId?: string | null; chatTypeLabel?: string | null; + title?: string | null; notifyOffline?: boolean | null; }, ) { @@ -2595,6 +2596,7 @@ export async function saveChatShareRoomSettings( accessPinPromptTtlMinutes: input.accessPinPromptTtlMinutes, chatTypeId: input.chatTypeId, chatTypeLabel: input.chatTypeLabel, + title: input.title, notifyOffline: input.notifyOffline, }), }, diff --git a/src/app/main/pages/ChatSharePage.css b/src/app/main/pages/ChatSharePage.css index cd3da46..b89b55e 100644 --- a/src/app/main/pages/ChatSharePage.css +++ b/src/app/main/pages/ChatSharePage.css @@ -1826,6 +1826,18 @@ min-width: 0; } +.chat-share-page__first-inquiry-title-row { + display: flex; + align-items: flex-start; + gap: 6px; + min-width: 0; +} + +.chat-share-page__first-inquiry-title-row .chat-share-page__section-action.ant-btn { + flex: 0 0 auto; + margin-top: -2px; +} + .chat-share-page__first-inquiry-menu-badge { display: inline-flex; align-items: center; diff --git a/src/app/main/pages/ChatSharePage.tsx b/src/app/main/pages/ChatSharePage.tsx index 9355c12..955a942 100644 --- a/src/app/main/pages/ChatSharePage.tsx +++ b/src/app/main/pages/ChatSharePage.tsx @@ -1,4 +1,4 @@ -import { AppstoreOutlined, CheckOutlined, CloseOutlined, CopyOutlined, DeleteOutlined, DownOutlined, EyeInvisibleOutlined, EyeOutlined, FileTextOutlined, FilterOutlined, FullscreenOutlined, LeftOutlined, PlusOutlined, ReloadOutlined, RightOutlined, SearchOutlined, SendOutlined, SettingOutlined, ThunderboltOutlined, UpOutlined } from '@ant-design/icons'; +import { AppstoreOutlined, CheckOutlined, CloseOutlined, CopyOutlined, DeleteOutlined, DownOutlined, EditOutlined, EyeInvisibleOutlined, EyeOutlined, FileTextOutlined, FilterOutlined, FullscreenOutlined, LeftOutlined, PlusOutlined, ReloadOutlined, RightOutlined, SearchOutlined, SendOutlined, SettingOutlined, ThunderboltOutlined, UpOutlined } from '@ant-design/icons'; import { Alert, App, Button, Checkbox, Drawer, Dropdown, Input, Modal, Select, Spin, Tabs, Tag, Typography, type MenuProps } from 'antd'; import type { TextAreaRef } from 'antd/es/input/TextArea'; import { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ClipboardEvent, type FocusEvent, type KeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'; @@ -3006,6 +3006,7 @@ export function ChatSharePage() { const [isRoomSettingsOpen, setIsRoomSettingsOpen] = useState(false); const [isSavingRoomSettings, setIsSavingRoomSettings] = useState(false); const [roomSettingsTabKey, setRoomSettingsTabKey] = useState<'chat-type' | 'default-contexts' | 'room-context' | 'notifications' | 'security'>('chat-type'); + const [editingRoomTitle, setEditingRoomTitle] = useState(''); const [editingRoomChatTypeId, setEditingRoomChatTypeId] = useState(null); const [editingRoomDefaultContextIds, setEditingRoomDefaultContextIds] = useState([]); const [isEditingRoomDefaultContextsDirty, setIsEditingRoomDefaultContextsDirty] = useState(false); @@ -3482,6 +3483,12 @@ export function ChatSharePage() { } const nextChatTypeId = currentSharedChatTypeId ?? enabledChatTypes[0]?.id ?? null; + const fallbackRoomTitle = + snapshot.conversation.title?.trim() + || snapshot.targetRequest.userText?.trim() + || snapshot.requests?.[0]?.userText?.trim() + || '-'; + setEditingRoomTitle(fallbackRoomTitle); setEditingRoomChatTypeId(nextChatTypeId); setEditingRoomDefaultContextIds(resolveShareRoomDefaultContextIds(activeRoomContextSettings, chatTypeDefaults, nextChatTypeId)); setIsEditingRoomDefaultContextsDirty(false); @@ -3501,9 +3508,12 @@ export function ChatSharePage() { currentSharedChatTypeId, enabledChatTypes, snapshot?.conversation.notifyOffline, + snapshot?.conversation.title, + snapshot?.requests, snapshot?.share.accessPinPromptTtlMinutes, snapshot?.share.hasAccessPin, snapshot?.conversation.sessionId, + snapshot?.targetRequest.userText, ]); useEffect(() => { if (!isRoomSettingsOpen) { @@ -3542,6 +3552,7 @@ export function ChatSharePage() { const nextCustomContextContent = editingRoomCustomContextContent.trim(); const shouldPersistRoomDefaultContextIds = !areStringListsEqual(normalizedDefaultContextIds, inheritedDefaultContextIds); const shouldPersistRoomCustomContext = Boolean(nextCustomContextTitle || nextCustomContextContent); + const normalizedRoomTitle = editingRoomTitle.trim(); const normalizedAccessPin = editingRoomAccessPin.trim(); const currentHasAccessPin = snapshot?.share.hasAccessPin === true; const currentAccessPinPromptTtlMinutes = resolveAccessPinPromptTtlMinutes(snapshot?.share.accessPinPromptTtlMinutes); @@ -3549,10 +3560,17 @@ export function ChatSharePage() { canManageSharedRoomSettings && Boolean(nextChatType) && ( - currentSharedChatTypeId !== nextChatType.id + normalizedRoomTitle !== (snapshot?.conversation.title?.trim() || '') + || !(snapshot?.conversation.title?.trim()) + || currentSharedChatTypeId !== nextChatType.id || snapshot.conversation.notifyOffline !== editingRoomNotifyOffline ); + if (canManageSharedRoomSettings && !normalizedRoomTitle) { + message.warning('채팅방 이름을 입력하세요.'); + return; + } + if (canEditSharedRoomAccessPin && editingRoomUseAccessPin && !currentHasAccessPin && !normalizedAccessPin) { message.warning('비밀번호를 새로 켜려면 숫자 4자리를 입력하세요.'); return; @@ -3623,6 +3641,7 @@ export function ChatSharePage() { accessPinPromptTtlMinutes: editingRoomUseAccessPin ? editingRoomAccessPinPromptTtlMinutes : null, chatTypeId: shouldSaveConversationSettings ? nextChatType?.id ?? null : undefined, chatTypeLabel: shouldSaveConversationSettings ? nextChatType?.name ?? null : undefined, + title: shouldSaveConversationSettings ? normalizedRoomTitle : undefined, notifyOffline: shouldSaveConversationSettings ? editingRoomNotifyOffline : undefined, }); @@ -3669,6 +3688,7 @@ export function ChatSharePage() { canManageSharedRoomSettings, defaultContexts, editingRoomChatTypeId, + editingRoomTitle, editingRoomAccessPin, editingRoomAccessPinPromptTtlMinutes, editingRoomCustomContextContent, @@ -5214,6 +5234,15 @@ export function ChatSharePage() { return currentRequest ?? latestRequest ?? sortedRequests[0] ?? null; }, [currentRequest, displayedRequests, latestRequest, sortedRequests]); + const headerTitleText = useMemo(() => { + const savedConversationTitle = snapshot?.conversation.title?.trim() || ''; + + if (savedConversationTitle) { + return savedConversationTitle; + } + + return headerInquiryRequest?.userText.trim() || '-'; + }, [headerInquiryRequest?.userText, snapshot?.conversation.title]); const hiddenBeforeCount = expandMode === 'latest' && latestRequestIndex >= 0 ? latestRequestIndex : 0; const hiddenAfterCount = expandMode === 'latest' && latestRequestIndex >= 0 ? Math.max(0, sortedRequests.length - latestRequestIndex - 1) : 0; @@ -6538,13 +6567,28 @@ export function ChatSharePage() {
- - {headerInquiryRequest.userText.trim() || '-'} - +
+ + {headerTitleText} + + {canOpenSharedRoomSettings ? ( +
+
+ 채팅방 이름 + 공유 채팅 헤더와 알림에 표시할 이름을 직접 저장합니다. +
+ { + setEditingRoomTitle(event.target.value); + }} + />
기본 채팅유형 공유채팅이 기본으로 사용할 유형을 먼저 고릅니다. diff --git a/src/app/main/pwa/installManifest.ts b/src/app/main/pwa/installManifest.ts index 67f94de..7b5d971 100644 --- a/src/app/main/pwa/installManifest.ts +++ b/src/app/main/pwa/installManifest.ts @@ -159,6 +159,14 @@ type BootstrapInstallMetadataResult = { themeColor: string; } | null; +type BootstrapInstallMetadataDefinition = { + description: string; + scope?: string; + shortName?: string; + themeColor: string; + title: string; +} | null; + const PLAY_APP_INSTALL_METADATA: Record = { 'baseball-ticket-bay': { title: 'Baseball Ticket Bay', themeColor: '#1b3f91' }, 'photoprism': { title: 'PhotoPrism', themeColor: '#0f766e' }, @@ -190,13 +198,8 @@ function createCurrentRouteInstallManifestObjectUrl(options: { }); } -export function applyBootstrapInstallMetadataForCurrentRoute(): BootstrapInstallMetadataResult { - if (typeof window === 'undefined') { - return null; - } - - const pathname = window.location.pathname; - const searchParams = new URLSearchParams(window.location.search); +function resolveBootstrapInstallMetadataDefinition(pathname: string, search: string): BootstrapInstallMetadataDefinition { + const searchParams = new URLSearchParams(search); if (pathname === '/play/apps') { const appId = searchParams.get('app')?.trim() ?? ''; @@ -207,45 +210,52 @@ export function applyBootstrapInstallMetadataForCurrentRoute(): BootstrapInstall } return { - manifestObjectUrl: createCurrentRouteInstallManifestObjectUrl({ - title: metadata.title, - shortName: metadata.title, - description: `${metadata.title} 앱을 홈 화면에서 바로 엽니다.`, - themeColor: metadata.themeColor, - scope: pathname, - }), title: metadata.title, + shortName: metadata.title, + description: `${metadata.title} 앱을 홈 화면에서 바로 엽니다.`, themeColor: metadata.themeColor, + scope: pathname, }; } if (pathname === '/plans/shared-resource') { return { - manifestObjectUrl: createCurrentRouteInstallManifestObjectUrl({ - title: '공유 리소스 관리', - shortName: '공유 리소스', - description: '공유 리소스 관리 화면을 홈 화면 앱으로 바로 엽니다.', - themeColor: '#0f766e', - scope: pathname, - }), title: '공유 리소스 관리', + shortName: '공유 리소스', + description: '공유 리소스 관리 화면을 홈 화면 앱으로 바로 엽니다.', themeColor: '#0f766e', + scope: pathname, }; } - if (pathname.startsWith('/shares/')) { + if (pathname.startsWith('/chat/share/') || pathname.startsWith('/shares/')) { return { - manifestObjectUrl: createCurrentRouteInstallManifestObjectUrl({ - title: '리소스 공유 채팅방', - shortName: '공유채팅', - description: '리소스 공유 채팅방을 홈 화면 앱으로 바로 엽니다.', - themeColor: '#165dff', - scope: pathname, - }), title: '리소스 공유 채팅방', + shortName: '공유채팅', + description: '리소스 공유 채팅방을 홈 화면 앱으로 바로 엽니다.', themeColor: '#165dff', + scope: pathname, }; } return null; } + +export function applyBootstrapInstallMetadataForCurrentRoute(): BootstrapInstallMetadataResult { + if (typeof window === 'undefined') { + return null; + } + + const pathname = window.location.pathname; + const metadata = resolveBootstrapInstallMetadataDefinition(pathname, window.location.search); + + if (!metadata) { + return null; + } + + return { + manifestObjectUrl: createCurrentRouteInstallManifestObjectUrl(metadata), + title: metadata.title, + themeColor: metadata.themeColor, + }; +}