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 | 115 | cursor: pointer; |
| 116 | 116 | gap: 10px; |
| 117 | 117 | border: 1px solid var(--SmartThemeBorderColor); |
| 118 | + position: relative; | |
| 118 | 119 | } |
| 119 | 120 | |
| 120 | 121 | .welcomeRecent .recentChatList .recentChat .avatar { |
| @@ -222,6 +223,14 @@ body.big-avatars .welcomeRecent .recentChatList .recentChat .chatMessageContaine | ||
| 222 | 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 | 234 | @media screen and (max-width: 1000px) { |
| 226 | 235 | .welcomePanel .welcomeShortcuts a span { |
| 227 | 236 | display: none; |
| @@ -44,6 +44,11 @@ | ||
| 44 | 44 | {{#each chats}} |
| 45 | 45 | {{#with this}} |
| 46 | 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 | 52 | <div class="avatar" title="[Character] {{char_name}} File: {{avatar}}"> |
| 48 | 53 | <img src="{{char_thumbnail}}" alt="{{char_name}}"> |
| 49 | 54 | </div> |
| @@ -56,6 +61,9 @@ | ||
| 56 | 61 | </div> |
| 57 | 62 | <small class="chatDate" title="{{date_long}}">{{date_short}}</small> |
| 58 | 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 | 67 | <button class="menu_button menu_button_icon renameChat" title="Rename chat" data-i18n="[title]Rename chat"> |
| 60 | 68 | <i class="fa-solid fa-pen-to-square fa-fw"></i> |
| 61 | 69 | </button> |
| @@ -35,14 +35,121 @@ import { callGenericPopup, POPUP_TYPE } from './popup.js'; | ||
| 35 | 35 | import { getMessageTimeStamp } from './RossAscends-mods.js'; |
| 36 | 36 | import { renderTemplateAsync } from './templates.js'; |
| 37 | 37 | import { accountStorage } from './util/AccountStorage.js'; |
| 38 | 38 | import { flashHighlight, sortMoments, timestampToMoment } from './utils.js'; |
| 39 | 39 | |
| 40 | 40 | const assistantAvatarKey = 'assistant'; |
| 41 | +const pinnedChatsKey = 'pinnedChats'; | |
| 41 | 42 | const defaultAssistantAvatar = 'default_Assistant.png'; |
| 42 | 43 | |
| 43 | 44 | const DEFAULT_DISPLAYED = 3; |
| 44 | 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 | 153 | export function getPermanentAssistantAvatar() { |
| 47 | 154 | const assistantAvatar = accountStorage.getItem(assistantAvatarKey); |
| 48 | 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 | 403 | chatElement.append(fragment.firstChild); |
| 277 | 404 | if (expand) { |
| 278 | 405 | chatElement.querySelectorAll('button.showMoreChats').forEach((button) => { |
| @@ -461,9 +588,11 @@ async function deleteRecentGroupChat(groupId, fileName) { | ||
| 461 | 588 | |
| 462 | 589 | /** |
| 463 | 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 | 593 | * @returns {Promise<void>} |
| 465 | 594 | */ |
| 466 | 595 | async function refreshWelcomeScreen({ flashChat = null } = {}) { |
| 467 | 596 | const chatElement = document.getElementById('chat'); |
| 468 | 597 | if (!chatElement) { |
| 469 | 598 | console.error('Chat element not found'); |
| @@ -476,8 +605,24 @@ async function refreshWelcomeScreen() { | ||
| 476 | 605 | |
| 477 | 606 | await openWelcomeScreen({ force: true, expand }); |
| 478 | 607 | |
| 479 | 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 | 644 | * @property {string} group Group ID (if applicable) |
| 500 | 645 | * @property {boolean} is_group Indicates if the chat is a group chat |
| 501 | 646 | * @property {boolean} hidden Chat will be hidden by default |
| 647 | + * @property {boolean} pinned Indicates if the chat is pinned | |
| 502 | 648 | */ |
| 503 | 649 | async function getRecentChats() { |
| 504 | 650 | const response = await fetch('/api/chats/recent', { |
| 505 | 651 | method: 'POST', |
| 506 | 652 | headers: getRequestHeaders(), |
| 507 | 653 | body: JSON.stringify({ max: MAX_DISPLAYED, pinned: PinnedChatsManager.getAll() }), |
| 654 | + cache: 'no-cache', | |
| 508 | 655 | }); |
| 509 | 656 | |
| 510 | 657 | if (!response.ok) { |
| @@ -520,9 +667,22 @@ async function getRecentChats() { | ||
| 520 | 667 | } |
| 521 | 668 | |
| 522 | 669 | const dataWithEntities = data |
| 523 | - .sort((a, b) => sortMoments(timestampToMoment(a.last_mes), timestampToMoment(b.last_mes))) | |
| 524 | 670 | .map(chat => ({ chat, character: characters.find(x => x.avatar === chat.avatar), group: groups.find(x => x.id === chat.group) })) |
| 525 | 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 | 687 | dataWithEntities.forEach(({ chat, character, group }, index) => { |
| 528 | 688 | const chatTimestamp = timestampToMoment(chat.last_mes); |
| @@ -535,6 +695,7 @@ async function getRecentChats() { | ||
| 535 | 695 | chat.hidden = index >= DEFAULT_DISPLAYED; |
| 536 | 696 | chat.avatar = chat.avatar || ''; |
| 537 | 697 | chat.group = chat.group || ''; |
| 698 | + chat.pinned = PinnedChatsManager.isPinned(chat); | |
| 538 | 699 | }); |
| 539 | 700 | |
| 540 | 701 | return dataWithEntities.map(t => t.chat); |
| @@ -648,6 +809,8 @@ export function assignCharacterAsAssistant(characterId) { | ||
| 648 | 809 | } |
| 649 | 810 | |
| 650 | 811 | export function initWelcomeScreen() { |
| 812 | + PinnedChatsManager.init(); | |
| 813 | + | |
| 651 | 814 | const events = [event_types.CHAT_CHANGED, event_types.APP_READY]; |
| 652 | 815 | for (const event of events) { |
| 653 | 816 | eventSource.makeFirst(event, openWelcomeScreen); |
| @@ -950,8 +950,11 @@ router.post('/search', validateAvatarUrlMiddleware, function (request, response) | ||
| 950 | 950 | |
| 951 | 951 | router.post('/recent', async function (request, response) { |
| 952 | 952 | try { |
| 953 | 953 | /** @typetypedef {{pngFile?: string, groupId?: string, filePath: string, mtime: number}[]} ChatFile */ |
| 954 | + /** @type {ChatFile[]} */ | |
| 954 | 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 | 959 | const getCharacterChatFiles = async () => { |
| 957 | 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 | 1021 | await Promise.allSettled([getCharacterChatFiles(), getGroupChatFiles(), getRootChatFiles()]); |
| 1019 | 1022 | |
| 1020 | 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 | 1034 | const jsonFilesPromise = recentChats.map((file) => { |
| 1023 | 1035 | const withMetadata = !!request.body.metadata; |
| 1024 | 1036 | return file.groupId |