Recent Chats: Add pin functionality (#5030) * feat: Implement pinned chat functionality in welcome screen * Highlight pinned / unpinned blocks * Reduce flash duration
Signed| @@ -115,6 +115,7 @@ body.hideChatAvatars .welcomePanel .recentChatList .recentChat .avatar { | |||
| 115 | cursor: pointer; | 115 | cursor: pointer; |
| 116 | gap: 10px; | 116 | gap: 10px; |
| 117 | border: 1px solid var(--SmartThemeBorderColor); | 117 | border: 1px solid var(--SmartThemeBorderColor); |
| 118 | position: relative; | ||
| 118 | } | 119 | } |
| 119 | 120 | ||
| 120 | .welcomeRecent .recentChatList .recentChat .avatar { | 121 | .welcomeRecent .recentChatList .recentChat .avatar { |
| @@ -222,6 +223,14 @@ body.big-avatars .welcomeRecent .recentChatList .recentChat .chatMessageContaine | |||
| 222 | transform: rotate(180deg); | 223 | transform: rotate(180deg); |
| 223 | } | 224 | } |
| 224 | 225 | ||
| 226 | .welcomeRecent .recentChatList .recentChat .recentChatPinned { | ||
| 227 | top: 1px; | ||
| 228 | left: 1px; | ||
| 229 | position: absolute; | ||
| 230 | opacity: 0.8; | ||
| 231 | color: var(--SmartThemeQuoteColor); | ||
| 232 | } | ||
| 233 | |||
| 225 | @media screen and (max-width: 1000px) { | 234 | @media screen and (max-width: 1000px) { |
| 226 | .welcomePanel .welcomeShortcuts a span { | 235 | .welcomePanel .welcomeShortcuts a span { |
| 227 | display: none; | 236 | display: none; |
| @@ -44,6 +44,11 @@ | |||
| 44 | {{#each chats}} | 44 | {{#each chats}} |
| 45 | {{#with this}} | 45 | {{#with this}} |
| 46 | <div class="recentChat {{#if hidden}}hidden{{/if}} {{#if is_group}}group{{/if}}" data-file="{{chat_name}}" data-avatar="{{avatar}}" data-group="{{group}}"> | 46 | <div class="recentChat {{#if hidden}}hidden{{/if}} {{#if is_group}}group{{/if}}" data-file="{{chat_name}}" data-avatar="{{avatar}}" data-group="{{group}}"> |
| 47 | {{#if pinned}} | ||
| 48 | <div class="recentChatPinned"> | ||
| 49 | <i class="fa-solid fa-thumbtack fa-fw fa-xs" title="Pinned chat" data-i18n="[title]Pinned chat"></i> | ||
| 50 | </div> | ||
| 51 | {{/if}} | ||
| 47 | <div class="avatar" title="[Character] {{char_name}} File: {{avatar}}"> | 52 | <div class="avatar" title="[Character] {{char_name}} File: {{avatar}}"> |
| 48 | <img src="{{char_thumbnail}}" alt="{{char_name}}"> | 53 | <img src="{{char_thumbnail}}" alt="{{char_name}}"> |
| 49 | </div> | 54 | </div> |
| @@ -56,6 +61,9 @@ | |||
| 56 | </div> | 61 | </div> |
| 57 | <small class="chatDate" title="{{date_long}}">{{date_short}}</small> | 62 | <small class="chatDate" title="{{date_long}}">{{date_short}}</small> |
| 58 | <div class="chatActions"> | 63 | <div class="chatActions"> |
| 64 | <button class="menu_button menu_button_icon pinChat {{#if pinned}}active{{/if}}" title="Pin chat" data-i18n="[title]Pin chat"> | ||
| 65 | <i class="fa-solid fa-thumbtack fa-fw"></i> | ||
| 66 | </button> | ||
| 59 | <button class="menu_button menu_button_icon renameChat" title="Rename chat" data-i18n="[title]Rename chat"> | 67 | <button class="menu_button menu_button_icon renameChat" title="Rename chat" data-i18n="[title]Rename chat"> |
| 60 | <i class="fa-solid fa-pen-to-square fa-fw"></i> | 68 | <i class="fa-solid fa-pen-to-square fa-fw"></i> |
| 61 | </button> | 69 | </button> |
| @@ -35,14 +35,121 @@ import { callGenericPopup, POPUP_TYPE } from './popup.js'; | |||
| 35 | import { getMessageTimeStamp } from './RossAscends-mods.js'; | 35 | import { getMessageTimeStamp } from './RossAscends-mods.js'; |
| 36 | import { renderTemplateAsync } from './templates.js'; | 36 | import { renderTemplateAsync } from './templates.js'; |
| 37 | import { accountStorage } from './util/AccountStorage.js'; | 37 | import { accountStorage } from './util/AccountStorage.js'; |
| 38 | import { sortMoments, timestampToMoment } from './utils.js'; | 38 | import { flashHighlight, sortMoments, timestampToMoment } from './utils.js'; |
| 39 | 39 | ||
| 40 | const assistantAvatarKey = 'assistant'; | 40 | const assistantAvatarKey = 'assistant'; |
| 41 | const pinnedChatsKey = 'pinnedChats'; | ||
| 41 | const defaultAssistantAvatar = 'default_Assistant.png'; | 42 | const defaultAssistantAvatar = 'default_Assistant.png'; |
| 42 | 43 | ||
| 43 | const DEFAULT_DISPLAYED = 3; | 44 | const DEFAULT_DISPLAYED = 3; |
| 44 | const MAX_DISPLAYED = 15; | 45 | const MAX_DISPLAYED = 15; |
| 45 | 46 | ||
| 47 | /** | ||
| 48 | * @typedef {Pick<RecentChat, 'group' | 'avatar' | 'file_name'>} PinnedChat | ||
| 49 | */ | ||
| 50 | |||
| 51 | /** | ||
| 52 | * Manages pinned chat storage and operations. | ||
| 53 | */ | ||
| 54 | class PinnedChatsManager { | ||
| 55 | /** @type {Record<string, PinnedChat> | null} */ | ||
| 56 | static #cachedState = null; | ||
| 57 | |||
| 58 | /** | ||
| 59 | * Initializes the cached state from storage. | ||
| 60 | * Should be called once on app init. | ||
| 61 | */ | ||
| 62 | static init() { | ||
| 63 | this.#cachedState = this.#loadFromStorage(); | ||
| 64 | } | ||
| 65 | |||
| 66 | /** | ||
| 67 | * Loads state from storage. | ||
| 68 | * @returns {Record<string, PinnedChat>} | ||
| 69 | */ | ||
| 70 | static #loadFromStorage() { | ||
| 71 | const pinnedState = /** @type {Record<string, PinnedChat>} */ ({}); | ||
| 72 | const value = accountStorage.getItem(pinnedChatsKey); | ||
| 73 | if (value) { | ||
| 74 | try { | ||
| 75 | Object.assign(pinnedState, JSON.parse(value)); | ||
| 76 | } catch (error) { | ||
| 77 | console.warn('Failed to parse pinned chats from storage.', error); | ||
| 78 | } | ||
| 79 | } | ||
| 80 | return pinnedState; | ||
| 81 | } | ||
| 82 | |||
| 83 | /** | ||
| 84 | * Generates a key for pinned chat storage. | ||
| 85 | * @param {RecentChat} recentChat Recent chat data | ||
| 86 | * @returns {string} Key for pinned chat storage | ||
| 87 | */ | ||
| 88 | static getKey(recentChat) { | ||
| 89 | return `${recentChat.group ? 'group_' + recentChat.group : ''}${recentChat.avatar ? 'char_' + recentChat.avatar : ''}_${recentChat.file_name}`; | ||
| 90 | } | ||
| 91 | |||
| 92 | /** | ||
| 93 | * Gets the pinned chat state from cache. | ||
| 94 | * @returns {Record<string, PinnedChat>} | ||
| 95 | */ | ||
| 96 | static getState() { | ||
| 97 | if (this.#cachedState === null) { | ||
| 98 | this.#cachedState = this.#loadFromStorage(); | ||
| 99 | } | ||
| 100 | return this.#cachedState; | ||
| 101 | } | ||
| 102 | |||
| 103 | /** | ||
| 104 | * Saves the pinned chat state to storage and updates cache. | ||
| 105 | * @param {Record<string, PinnedChat>} state The state to save | ||
| 106 | */ | ||
| 107 | static #saveState(state) { | ||
| 108 | this.#cachedState = state; | ||
| 109 | accountStorage.setItem(pinnedChatsKey, JSON.stringify(state)); | ||
| 110 | } | ||
| 111 | |||
| 112 | /** | ||
| 113 | * Checks if a chat is pinned. | ||
| 114 | * @param {RecentChat} recentChat Recent chat data | ||
| 115 | * @returns {boolean} True if the chat is pinned, false otherwise | ||
| 116 | */ | ||
| 117 | static isPinned(recentChat) { | ||
| 118 | const pinKey = this.getKey(recentChat); | ||
| 119 | const pinState = this.getState(); | ||
| 120 | return pinKey in pinState; | ||
| 121 | } | ||
| 122 | |||
| 123 | /** | ||
| 124 | * Toggles the pinned state of a chat. | ||
| 125 | * @param {RecentChat} recentChat Recent chat data | ||
| 126 | * @param {boolean} pinned New pinned state | ||
| 127 | */ | ||
| 128 | static toggle(recentChat, pinned) { | ||
| 129 | const pinKey = this.getKey(recentChat); | ||
| 130 | const pinState = { ...this.getState() }; | ||
| 131 | if (pinned) { | ||
| 132 | pinState[pinKey] = { | ||
| 133 | group: recentChat.group, | ||
| 134 | avatar: recentChat.avatar, | ||
| 135 | file_name: recentChat.file_name, | ||
| 136 | }; | ||
| 137 | } else { | ||
| 138 | delete pinState[pinKey]; | ||
| 139 | } | ||
| 140 | this.#saveState(pinState); | ||
| 141 | } | ||
| 142 | |||
| 143 | /** | ||
| 144 | * Gets all pinned chats. | ||
| 145 | * @returns {PinnedChat[]} | ||
| 146 | */ | ||
| 147 | static getAll() { | ||
| 148 | const pinState = this.getState(); | ||
| 149 | return Object.values(pinState); | ||
| 150 | } | ||
| 151 | } | ||
| 152 | |||
| 46 | export function getPermanentAssistantAvatar() { | 153 | export function getPermanentAssistantAvatar() { |
| 47 | const assistantAvatar = accountStorage.getItem(assistantAvatarKey); | 154 | const assistantAvatar = accountStorage.getItem(assistantAvatarKey); |
| 48 | if (assistantAvatar === null) { | 155 | if (assistantAvatar === null) { |
| @@ -273,6 +380,26 @@ async function sendWelcomePanel(chats, expand = false) { | |||
| 273 | } | 380 | } |
| 274 | }); | 381 | }); |
| 275 | }); | 382 | }); |
| 383 | fragment.querySelectorAll('.recentChat .pinChat').forEach((pinButton) => { | ||
| 384 | pinButton.addEventListener('click', async (event) => { | ||
| 385 | event.stopPropagation(); | ||
| 386 | const chatItem = pinButton.closest('.recentChat'); | ||
| 387 | if (!chatItem) { | ||
| 388 | return; | ||
| 389 | } | ||
| 390 | const avatarId = chatItem.getAttribute('data-avatar'); | ||
| 391 | const groupId = chatItem.getAttribute('data-group'); | ||
| 392 | const fileName = chatItem.getAttribute('data-file'); | ||
| 393 | const recentChat = chats.find(c => c.chat_name === fileName && ((c.is_group && c.group === groupId) || (!c.is_group && c.avatar === avatarId))); | ||
| 394 | if (!recentChat) { | ||
| 395 | console.error('Recent chat not found for pinning.'); | ||
| 396 | return; | ||
| 397 | } | ||
| 398 | const currentlyPinned = PinnedChatsManager.isPinned(recentChat); | ||
| 399 | PinnedChatsManager.toggle(recentChat, !currentlyPinned); | ||
| 400 | await refreshWelcomeScreen({ flashChat: recentChat }); | ||
| 401 | }); | ||
| 402 | }); | ||
| 276 | chatElement.append(fragment.firstChild); | 403 | chatElement.append(fragment.firstChild); |
| 277 | if (expand) { | 404 | if (expand) { |
| 278 | chatElement.querySelectorAll('button.showMoreChats').forEach((button) => { | 405 | chatElement.querySelectorAll('button.showMoreChats').forEach((button) => { |
| @@ -461,9 +588,11 @@ async function deleteRecentGroupChat(groupId, fileName) { | |||
| 461 | 588 | ||
| 462 | /** | 589 | /** |
| 463 | * Reopens the welcome screen and restores the scroll position. | 590 | * Reopens the welcome screen and restores the scroll position. |
| 591 | * @param {object} param Additional parameters | ||
| 592 | * @param {RecentChat} [param.flashChat] Recent chat to flash (if any) | ||
| 464 | * @returns {Promise<void>} | 593 | * @returns {Promise<void>} |
| 465 | */ | 594 | */ |
| 466 | async function refreshWelcomeScreen() { | 595 | async function refreshWelcomeScreen({ flashChat = null } = {}) { |
| 467 | const chatElement = document.getElementById('chat'); | 596 | const chatElement = document.getElementById('chat'); |
| 468 | if (!chatElement) { | 597 | if (!chatElement) { |
| 469 | console.error('Chat element not found'); | 598 | console.error('Chat element not found'); |
| @@ -476,8 +605,24 @@ async function refreshWelcomeScreen() { | |||
| 476 | 605 | ||
| 477 | await openWelcomeScreen({ force: true, expand }); | 606 | await openWelcomeScreen({ force: true, expand }); |
| 478 | 607 | ||
| 479 | // Restore scroll position | 608 | // Restore scroll position or flash specific chat |
| 480 | chatElement.scrollTop = scrollTop + (chatElement.scrollHeight - scrollHeight); | 609 | if (flashChat) { |
| 610 | const recentChats = Array.from(chatElement.querySelectorAll('.recentChat')); | ||
| 611 | const chatToFlash = recentChats.find(el => { | ||
| 612 | const file = el.getAttribute('data-file'); | ||
| 613 | const group = el.getAttribute('data-group'); | ||
| 614 | const avatar = el.getAttribute('data-avatar'); | ||
| 615 | return file === flashChat.chat_name && | ||
| 616 | ((flashChat.is_group && group === flashChat.group) || (!flashChat.is_group && avatar === flashChat.avatar)); | ||
| 617 | }); | ||
| 618 | if (chatToFlash instanceof HTMLElement) { | ||
| 619 | chatElement.scrollTop = chatToFlash.offsetTop - chatElement.offsetTop - (chatToFlash.clientHeight / 2); | ||
| 620 | flashHighlight($(chatToFlash), 1000); | ||
| 621 | } | ||
| 622 | } else { | ||
| 623 | // Restore scroll position | ||
| 624 | chatElement.scrollTop = scrollTop + (chatElement.scrollHeight - scrollHeight); | ||
| 625 | } | ||
| 481 | } | 626 | } |
| 482 | 627 | ||
| 483 | /** | 628 | /** |
| @@ -499,12 +644,14 @@ async function refreshWelcomeScreen() { | |||
| 499 | * @property {string} group Group ID (if applicable) | 644 | * @property {string} group Group ID (if applicable) |
| 500 | * @property {boolean} is_group Indicates if the chat is a group chat | 645 | * @property {boolean} is_group Indicates if the chat is a group chat |
| 501 | * @property {boolean} hidden Chat will be hidden by default | 646 | * @property {boolean} hidden Chat will be hidden by default |
| 647 | * @property {boolean} pinned Indicates if the chat is pinned | ||
| 502 | */ | 648 | */ |
| 503 | async function getRecentChats() { | 649 | async function getRecentChats() { |
| 504 | const response = await fetch('/api/chats/recent', { | 650 | const response = await fetch('/api/chats/recent', { |
| 505 | method: 'POST', | 651 | method: 'POST', |
| 506 | headers: getRequestHeaders(), | 652 | headers: getRequestHeaders(), |
| 507 | body: JSON.stringify({ max: MAX_DISPLAYED }), | 653 | body: JSON.stringify({ max: MAX_DISPLAYED, pinned: PinnedChatsManager.getAll() }), |
| 654 | cache: 'no-cache', | ||
| 508 | }); | 655 | }); |
| 509 | 656 | ||
| 510 | if (!response.ok) { | 657 | if (!response.ok) { |
| @@ -520,9 +667,22 @@ async function getRecentChats() { | |||
| 520 | } | 667 | } |
| 521 | 668 | ||
| 522 | const dataWithEntities = data | 669 | const dataWithEntities = data |
| 523 | .sort((a, b) => sortMoments(timestampToMoment(a.last_mes), timestampToMoment(b.last_mes))) | ||
| 524 | .map(chat => ({ chat, character: characters.find(x => x.avatar === chat.avatar), group: groups.find(x => x.id === chat.group) })) | 670 | .map(chat => ({ chat, character: characters.find(x => x.avatar === chat.avatar), group: groups.find(x => x.id === chat.group) })) |
| 525 | .filter(t => t.character || t.group); | 671 | .filter(t => t.character || t.group) |
| 672 | .sort((a, b) => { | ||
| 673 | const isAPinned = PinnedChatsManager.isPinned(a.chat); | ||
| 674 | const isBPinned = PinnedChatsManager.isPinned(b.chat); | ||
| 675 | const momentComparison = sortMoments(timestampToMoment(a.chat.last_mes), timestampToMoment(b.chat.last_mes)); | ||
| 676 | |||
| 677 | if (isAPinned && !isBPinned) { | ||
| 678 | return -1; | ||
| 679 | } | ||
| 680 | if (!isAPinned && isBPinned) { | ||
| 681 | return 1; | ||
| 682 | } | ||
| 683 | |||
| 684 | return momentComparison; | ||
| 685 | }); | ||
| 526 | 686 | ||
| 527 | dataWithEntities.forEach(({ chat, character, group }, index) => { | 687 | dataWithEntities.forEach(({ chat, character, group }, index) => { |
| 528 | const chatTimestamp = timestampToMoment(chat.last_mes); | 688 | const chatTimestamp = timestampToMoment(chat.last_mes); |
| @@ -535,6 +695,7 @@ async function getRecentChats() { | |||
| 535 | chat.hidden = index >= DEFAULT_DISPLAYED; | 695 | chat.hidden = index >= DEFAULT_DISPLAYED; |
| 536 | chat.avatar = chat.avatar || ''; | 696 | chat.avatar = chat.avatar || ''; |
| 537 | chat.group = chat.group || ''; | 697 | chat.group = chat.group || ''; |
| 698 | chat.pinned = PinnedChatsManager.isPinned(chat); | ||
| 538 | }); | 699 | }); |
| 539 | 700 | ||
| 540 | return dataWithEntities.map(t => t.chat); | 701 | return dataWithEntities.map(t => t.chat); |
| @@ -648,6 +809,8 @@ export function assignCharacterAsAssistant(characterId) { | |||
| 648 | } | 809 | } |
| 649 | 810 | ||
| 650 | export function initWelcomeScreen() { | 811 | export function initWelcomeScreen() { |
| 812 | PinnedChatsManager.init(); | ||
| 813 | |||
| 651 | const events = [event_types.CHAT_CHANGED, event_types.APP_READY]; | 814 | const events = [event_types.CHAT_CHANGED, event_types.APP_READY]; |
| 652 | for (const event of events) { | 815 | for (const event of events) { |
| 653 | eventSource.makeFirst(event, openWelcomeScreen); | 816 | eventSource.makeFirst(event, openWelcomeScreen); |
| @@ -950,8 +950,11 @@ router.post('/search', validateAvatarUrlMiddleware, function (request, response) | |||
| 950 | 950 | ||
| 951 | router.post('/recent', async function (request, response) { | 951 | router.post('/recent', async function (request, response) { |
| 952 | try { | 952 | try { |
| 953 | /** @type {{pngFile?: string, groupId?: string, filePath: string, mtime: number}[]} */ | 953 | /** @typedef {{pngFile?: string, groupId?: string, filePath: string, mtime: number}} ChatFile */ |
| 954 | /** @type {ChatFile[]} */ | ||
| 954 | const allChatFiles = []; | 955 | const allChatFiles = []; |
| 956 | /** @type {import('../../public/scripts/welcome-screen.js').PinnedChat[]} */ | ||
| 957 | const pinnedChats = Array.isArray(request.body.pinned) ? request.body.pinned : []; | ||
| 955 | 958 | ||
| 956 | const getCharacterChatFiles = async () => { | 959 | const getCharacterChatFiles = async () => { |
| 957 | const pngDirents = await fs.promises.readdir(request.user.directories.characters, { withFileTypes: true }); | 960 | const pngDirents = await fs.promises.readdir(request.user.directories.characters, { withFileTypes: true }); |
| @@ -1017,8 +1020,17 @@ router.post('/recent', async function (request, response) { | |||
| 1017 | 1020 | ||
| 1018 | await Promise.allSettled([getCharacterChatFiles(), getGroupChatFiles(), getRootChatFiles()]); | 1021 | await Promise.allSettled([getCharacterChatFiles(), getGroupChatFiles(), getRootChatFiles()]); |
| 1019 | 1022 | ||
| 1020 | const max = parseInt(request.body.max ?? Number.MAX_SAFE_INTEGER); | 1023 | const max = parseInt(request.body.max ?? Number.MAX_SAFE_INTEGER) + pinnedChats.length; |
| 1021 | const recentChats = allChatFiles.sort((a, b) => b.mtime - a.mtime).slice(0, max); | 1024 | const isPinned = (/** @type {ChatFile} */ chatFile) => pinnedChats.some(p => p.file_name === path.basename(chatFile.filePath) && (p.avatar === chatFile.pngFile || p.group === chatFile.groupId)); |
| 1025 | const recentChats = allChatFiles.sort((a, b) => { | ||
| 1026 | const isAPinned = isPinned(a); | ||
| 1027 | const isBPinned = isPinned(b); | ||
| 1028 | |||
| 1029 | if (isAPinned && !isBPinned) return -1; | ||
| 1030 | if (!isAPinned && isBPinned) return 1; | ||
| 1031 | |||
| 1032 | return b.mtime - a.mtime; | ||
| 1033 | }).slice(0, max); | ||
| 1022 | const jsonFilesPromise = recentChats.map((file) => { | 1034 | const jsonFilesPromise = recentChats.map((file) => { |
| 1023 | const withMetadata = !!request.body.metadata; | 1035 | const withMetadata = !!request.body.metadata; |
| 1024 | return file.groupId | 1036 | return file.groupId |