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 {
115115 cursor: pointer;
116116 gap: 10px;
117117 border: 1px solid var(--SmartThemeBorderColor);
118+ position: relative;
118119}
119120
120121.welcomeRecent .recentChatList .recentChat .avatar {
@@ -222,6 +223,14 @@ body.big-avatars .welcomeRecent .recentChatList .recentChat .chatMessageContaine
222223 transform: rotate(180deg);
223224}
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+
225234@media screen and (max-width: 1000px) {
226235 .welcomePanel .welcomeShortcuts a span {
227236 display: none;
public/scripts/templates/welcomePanel.html+8 -0
@@ -44,6 +44,11 @@
4444 {{#each chats}}
4545 {{#with this}}
4646 <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}}
4752 <div class="avatar" title="[Character] {{char_name}}&#10;File: {{avatar}}">
4853 <img src="{{char_thumbnail}}" alt="{{char_name}}">
4954 </div>
@@ -56,6 +61,9 @@
5661 </div>
5762 <small class="chatDate" title="{{date_long}}">{{date_short}}</small>
5863 <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>
5967 <button class="menu_button menu_button_icon renameChat" title="Rename chat" data-i18n="[title]Rename chat">
6068 <i class="fa-solid fa-pen-to-square fa-fw"></i>
6169 </button>
public/scripts/welcome-screen.js+168 -5
@@ -35,14 +35,121 @@ import { callGenericPopup, POPUP_TYPE } from './popup.js';
3535import { getMessageTimeStamp } from './RossAscends-mods.js';
3636import { renderTemplateAsync } from './templates.js';
3737import { accountStorage } from './util/AccountStorage.js';
3838import { flashHighlight, sortMoments, timestampToMoment } from './utils.js';
3939
4040const assistantAvatarKey = 'assistant';
41+const pinnedChatsKey = 'pinnedChats';
4142const defaultAssistantAvatar = 'default_Assistant.png';
4243
4344const DEFAULT_DISPLAYED = 3;
4445const 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+ */
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+
46153export function getPermanentAssistantAvatar() {
47154 const assistantAvatar = accountStorage.getItem(assistantAvatarKey);
48155 if (assistantAvatar === null) {
@@ -273,6 +380,26 @@ async function sendWelcomePanel(chats, expand = false) {
273380 }
274381 });
275382 });
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+ });
276403 chatElement.append(fragment.firstChild);
277404 if (expand) {
278405 chatElement.querySelectorAll('button.showMoreChats').forEach((button) => {
@@ -461,9 +588,11 @@ async function deleteRecentGroupChat(groupId, fileName) {
461588
462589/**
463590 * 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)
464593 * @returns {Promise<void>}
465594 */
466595async function refreshWelcomeScreen({ flashChat = null } = {}) {
467596 const chatElement = document.getElementById('chat');
468597 if (!chatElement) {
469598 console.error('Chat element not found');
@@ -476,9 +605,25 @@ async function refreshWelcomeScreen() {
476605
477606 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 {
479623 // Restore scroll position
480624 chatElement.scrollTop = scrollTop + (chatElement.scrollHeight - scrollHeight);
481625 }
626+}
482627
483628/**
484629 * Gets the list of recent chats from the server.
@@ -499,12 +644,14 @@ async function refreshWelcomeScreen() {
499644 * @property {string} group Group ID (if applicable)
500645 * @property {boolean} is_group Indicates if the chat is a group chat
501646 * @property {boolean} hidden Chat will be hidden by default
647+ * @property {boolean} pinned Indicates if the chat is pinned
502648 */
503649async function getRecentChats() {
504650 const response = await fetch('/api/chats/recent', {
505651 method: 'POST',
506652 headers: getRequestHeaders(),
507653 body: JSON.stringify({ max: MAX_DISPLAYED, pinned: PinnedChatsManager.getAll() }),
654+ cache: 'no-cache',
508655 });
509656
510657 if (!response.ok) {
@@ -520,9 +667,22 @@ async function getRecentChats() {
520667 }
521668
522669 const dataWithEntities = data
523- .sort((a, b) => sortMoments(timestampToMoment(a.last_mes), timestampToMoment(b.last_mes)))
524670 .map(chat => ({ chat, character: characters.find(x => x.avatar === chat.avatar), group: groups.find(x => x.id === chat.group) }))
525671 .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
527687 dataWithEntities.forEach(({ chat, character, group }, index) => {
528688 const chatTimestamp = timestampToMoment(chat.last_mes);
@@ -535,6 +695,7 @@ async function getRecentChats() {
535695 chat.hidden = index >= DEFAULT_DISPLAYED;
536696 chat.avatar = chat.avatar || '';
537697 chat.group = chat.group || '';
698+ chat.pinned = PinnedChatsManager.isPinned(chat);
538699 });
539700
540701 return dataWithEntities.map(t => t.chat);
@@ -648,6 +809,8 @@ export function assignCharacterAsAssistant(characterId) {
648809}
649810
650811export function initWelcomeScreen() {
812+ PinnedChatsManager.init();
813+
651814 const events = [event_types.CHAT_CHANGED, event_types.APP_READY];
652815 for (const event of events) {
653816 eventSource.makeFirst(event, openWelcomeScreen);
src/endpoints/chats.js+15 -3
@@ -950,8 +950,11 @@ router.post('/search', validateAvatarUrlMiddleware, function (request, response)
950950
951951router.post('/recent', async function (request, response) {
952952 try {
953953 /** @typetypedef {{pngFile?: string, groupId?: string, filePath: string, mtime: number}[]} ChatFile */
954+ /** @type {ChatFile[]} */
954955 const allChatFiles = [];
956+ /** @type {import('../../public/scripts/welcome-screen.js').PinnedChat[]} */
957+ const pinnedChats = Array.isArray(request.body.pinned) ? request.body.pinned : [];
955958
956959 const getCharacterChatFiles = async () => {
957960 const pngDirents = await fs.promises.readdir(request.user.directories.characters, { withFileTypes: true });
@@ -1017,8 +1020,17 @@ router.post('/recent', async function (request, response) {
10171020
10181021 await Promise.allSettled([getCharacterChatFiles(), getGroupChatFiles(), getRootChatFiles()]);
10191022
10201023 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);
10221034 const jsonFilesPromise = recentChats.map((file) => {
10231035 const withMetadata = !!request.body.metadata;
10241036 return file.groupId