Recent Chats: Add pin functionality (#5030) * feat: Implement pinned chat functionality in welcome screen * Highlight pinned / unpinned blocks * Reduce flash duration

1ff98e76f8a80f0d9025b2cc288bab8045c25335

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
4 files changed, +200 -8Showing whitespace changes
public/css/welcome.css+9 -0
@@ -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}
119120
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}
224225
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;
public/scripts/templates/welcomePanel.html+8 -0
@@ -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}}&#10;File: {{avatar}}">52 <div class="avatar" title="[Character] {{char_name}}&#10;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>
public/scripts/welcome-screen.js+168 -5
@@ -35,14 +35,121 @@ import { callGenericPopup, POPUP_TYPE } from './popup.js';
35import { getMessageTimeStamp } from './RossAscends-mods.js';35import { getMessageTimeStamp } from './RossAscends-mods.js';
36import { renderTemplateAsync } from './templates.js';36import { renderTemplateAsync } from './templates.js';
37import { accountStorage } from './util/AccountStorage.js';37import { accountStorage } from './util/AccountStorage.js';
38import { sortMoments, timestampToMoment } from './utils.js';38import { flashHighlight, sortMoments, timestampToMoment } from './utils.js';
3939
40const assistantAvatarKey = 'assistant';40const assistantAvatarKey = 'assistant';
41const pinnedChatsKey = 'pinnedChats';
41const defaultAssistantAvatar = 'default_Assistant.png';42const defaultAssistantAvatar = 'default_Assistant.png';
4243
43const DEFAULT_DISPLAYED = 3;44const DEFAULT_DISPLAYED = 3;
44const MAX_DISPLAYED = 15;45const MAX_DISPLAYED = 15;
4546
47/**
48 * @typedef {Pick<RecentChat, 'group' | 'avatar' | 'file_name'>} PinnedChat
49 */
50
51/**
52 * Manages pinned chat storage and operations.
53 */
54class 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
46export function getPermanentAssistantAvatar() {153export 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) {
461588
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 */
466async function refreshWelcomeScreen() {595async 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,9 +605,25 @@ async function refreshWelcomeScreen() {
476605
477 await openWelcomeScreen({ force: true, expand });606 await openWelcomeScreen({ force: true, expand });
478607
608 // Restore scroll position or flash specific chat
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 {
479 // Restore scroll position623 // Restore scroll position
480 chatElement.scrollTop = scrollTop + (chatElement.scrollHeight - scrollHeight);624 chatElement.scrollTop = scrollTop + (chatElement.scrollHeight - scrollHeight);
481 }625 }
626}
482627
483/**628/**
484 * Gets the list of recent chats from the server.629 * Gets the list of recent chats from the server.
@@ -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 chat645 * @property {boolean} is_group Indicates if the chat is a group chat
501 * @property {boolean} hidden Chat will be hidden by default646 * @property {boolean} hidden Chat will be hidden by default
647 * @property {boolean} pinned Indicates if the chat is pinned
502 */648 */
503async function getRecentChats() {649async 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 });
509656
510 if (!response.ok) {657 if (!response.ok) {
@@ -520,9 +667,22 @@ async function getRecentChats() {
520 }667 }
521668
522 const dataWithEntities = data669 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 });
526686
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 });
539700
540 return dataWithEntities.map(t => t.chat);701 return dataWithEntities.map(t => t.chat);
@@ -648,6 +809,8 @@ export function assignCharacterAsAssistant(characterId) {
648}809}
649810
650export function initWelcomeScreen() {811export 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);
src/endpoints/chats.js+15 -3
@@ -950,8 +950,11 @@ router.post('/search', validateAvatarUrlMiddleware, function (request, response)
950950
951router.post('/recent', async function (request, response) {951router.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 : [];
955958
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) {
10171020
1018 await Promise.allSettled([getCharacterChatFiles(), getGroupChatFiles(), getRootChatFiles()]);1021 await Promise.allSettled([getCharacterChatFiles(), getGroupChatFiles(), getRootChatFiles()]);
10191022
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.groupId1036 return file.groupId