445 lines
12 KiB
JavaScript
445 lines
12 KiB
JavaScript
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
|
|
const lazy = {};
|
|
|
|
import { getLogger } from "chrome://browser/content/firefoxview/helpers.mjs";
|
|
|
|
ChromeUtils.defineESModuleGetters(lazy, {
|
|
AIWindow:
|
|
"moz-src:///browser/components/aiwindow/ui/modules/AIWindow.sys.mjs",
|
|
});
|
|
|
|
const CHAT_MAP_L10N_IDS = {
|
|
"chat-date-today": "firefoxview-chat-date-today",
|
|
"chat-date-yesterday": "firefoxview-chat-date-yesterday",
|
|
"chat-date-this-month": "firefoxview-chat-date-this-month",
|
|
"chat-date-prev-month": "firefoxview-chat-date-prev-month",
|
|
};
|
|
|
|
const DAYS_IN_MILLIS = 86400000;
|
|
const SEARCH_RESULTS_LIMIT = 300;
|
|
|
|
/**
|
|
* A list of chat items displayed on a card.
|
|
*
|
|
* @typedef {object} CardEntry
|
|
* @property {Array} items - Chat items for this card
|
|
* @property {string} l10nId - Localization ID for the card header
|
|
*/
|
|
|
|
/**
|
|
* Manages chat history display and organization in Firefox View.
|
|
* Handles fetching, searching, grouping by date, and normalizing chat data for display.
|
|
*/
|
|
export class ChatsController {
|
|
/**
|
|
* @type {{ entries: CardEntry[]; searchQuery: string; }}
|
|
*/
|
|
cache;
|
|
|
|
/** @type {object} The host element this controller is attached to */
|
|
host;
|
|
|
|
/** @type {string} Current search query string */
|
|
searchQuery;
|
|
|
|
/** @type {Date} Today's date at midnight */
|
|
#todaysDate;
|
|
|
|
/** @type {Date} Yesterday's date at midnight */
|
|
#yesterdaysDate;
|
|
|
|
/**
|
|
* Creates a ChatsController instance.
|
|
*
|
|
* @param {object} host - The host element that this controller manages
|
|
* @param {object} options - Configuration options
|
|
* @param {number} [options.searchResultsLimit=300] - Maximum number of search results to return
|
|
*/
|
|
constructor(host, options) {
|
|
this.chatStore = lazy.AIWindow.chatStore;
|
|
this.searchQuery = "";
|
|
this.searchResultsLimit =
|
|
options?.searchResultsLimit || SEARCH_RESULTS_LIMIT;
|
|
this.cache = {
|
|
entries: null,
|
|
searchQuery: null,
|
|
};
|
|
this.host = host;
|
|
|
|
host.addController(this);
|
|
}
|
|
|
|
async deleteChat() {
|
|
const convID = this.host.triggerNode.closedId;
|
|
if (convID) {
|
|
await this.chatStore.deleteConversationById(convID);
|
|
await this.updateCache();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles search query events and updates the chat cache.
|
|
*
|
|
* @param {CustomEvent} e - The search query event
|
|
* @param {object} e.detail - Event details
|
|
* @param {string} e.detail.query - The search query string
|
|
*/
|
|
onSearchQuery(e) {
|
|
this.searchQuery = e.detail.query;
|
|
this.updateCache();
|
|
}
|
|
|
|
/**
|
|
* Gets all chat entries from the cache.
|
|
*
|
|
* @returns {CardEntry[]} Array of card entries containing chat items
|
|
*/
|
|
get totalChats() {
|
|
return this.cache.entries || [];
|
|
}
|
|
|
|
/**
|
|
* Gets the current search results.
|
|
*
|
|
* @returns {Array} Array of search result items, or empty array if no search is active
|
|
*/
|
|
get searchResults() {
|
|
if (this.cache.searchQuery && this.cache.entries?.length) {
|
|
return this.cache.entries[0].items;
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Gets the total number of chat items across all entries.
|
|
*
|
|
* @returns {number} Total count of chat items
|
|
*/
|
|
get totalVisitsCount() {
|
|
return this.totalChats.reduce(
|
|
(count, entry) => count + entry.items.length,
|
|
0
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Checks if there are no chats to display.
|
|
*
|
|
* @returns {boolean} True if there are no chat entries
|
|
*/
|
|
get isChatEmpty() {
|
|
return !this.totalChats.length;
|
|
}
|
|
|
|
/**
|
|
* Updates the cached chat data based on current search query.
|
|
* Fetches either search results or all chats grouped by date.
|
|
* Normalizes chat items and triggers host UI update.
|
|
*/
|
|
async updateCache() {
|
|
// Captured before the await to detect mid-flight query changes below.
|
|
const searchQuery = this.searchQuery;
|
|
const entries = searchQuery
|
|
? await this.#getChatsForSearchQuery(searchQuery)
|
|
: await this.#getAllChatsGroupedByDate();
|
|
|
|
if (this.searchQuery !== searchQuery || !entries) {
|
|
return;
|
|
}
|
|
|
|
// Normalize chat items for display
|
|
for (const { items } of entries) {
|
|
for (const item of items) {
|
|
this.#normalizeChat(item);
|
|
}
|
|
}
|
|
|
|
this.cache = { entries, searchQuery };
|
|
this.host.requestUpdate();
|
|
}
|
|
|
|
/**
|
|
* Normalize chat data for fxview-tabs-list.
|
|
*
|
|
* @param {ChatHistoryResult} chat
|
|
* The chat to format.
|
|
*/
|
|
#normalizeChat(chat) {
|
|
// Get most recent page URL (skip if it's the AI Window URL)
|
|
const mostRecentPage = chat.urls?.[chat.urls.length - 1];
|
|
const pageUrl =
|
|
mostRecentPage?.href === lazy.AIWindow.newTabURL
|
|
? null
|
|
: mostRecentPage?.href;
|
|
|
|
// All chats link to AI Window - click handler will decide what to load
|
|
const targetURI = lazy.AIWindow.newTabURL;
|
|
|
|
if (pageUrl) {
|
|
chat.icon = pageUrl;
|
|
chat.pageUrl = pageUrl;
|
|
chat.primaryL10nId = "fxviewtabrow-tabs-list-tab";
|
|
chat.primaryL10nArgs = JSON.stringify({
|
|
targetURI,
|
|
});
|
|
}
|
|
chat.url = targetURI;
|
|
chat.time = chat.updatedDate;
|
|
chat.closedId = chat.convId;
|
|
chat.secondaryL10nId = "fxviewtabrow-options-menu-button";
|
|
chat.secondaryL10nArgs = JSON.stringify({
|
|
tabTitle: chat.title || "Untitled Chat",
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Fetches and formats chats matching a search query.
|
|
*
|
|
* @param {string} searchQuery - The search query string
|
|
* @returns {Promise<CardEntry[]>} Array with single entry containing search results
|
|
*/
|
|
async #getChatsForSearchQuery(searchQuery) {
|
|
try {
|
|
const conversations = await this.chatStore.search(searchQuery, false);
|
|
// Add aliases to conversation objects for display compatibility
|
|
conversations.forEach(conv => {
|
|
conv.convId = conv.id;
|
|
conv.urls = conv.pageMeta?.urls || [];
|
|
});
|
|
|
|
return [{ items: conversations }];
|
|
} catch (e) {
|
|
getLogger("ChatsController").error(
|
|
"Search failed for query:",
|
|
searchQuery,
|
|
e.message,
|
|
e.stack
|
|
);
|
|
return [{ items: [] }];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetches all chats and groups them by date categories.
|
|
*
|
|
* @returns {Promise<CardEntry[]>} Array of card entries grouped by date
|
|
*/
|
|
async #getAllChatsGroupedByDate() {
|
|
const chats = await this.#fetchChats();
|
|
if (!chats || !chats.length) {
|
|
return [];
|
|
}
|
|
|
|
this.#setTodaysDate();
|
|
return this.#getChatsForDate(chats);
|
|
}
|
|
|
|
/**
|
|
* Groups chats into date-based categories: today, yesterday, this month, previous months.
|
|
*
|
|
* @param {Array} chats - Array of chat objects to group
|
|
* @returns {CardEntry[]} Array of card entries organized by date categories
|
|
*/
|
|
#getChatsForDate(chats) {
|
|
const entries = [];
|
|
const chatsFromToday = this.#getChatsFromToday(chats);
|
|
const chatsFromYesterday = this.#getChatsFromYesterday(chats);
|
|
const chatsByDay = this.#getChatsByDay(chats);
|
|
const chatsByMonth = this.#getChatsByMonth(chats);
|
|
|
|
if (chatsFromToday.length) {
|
|
entries.push({
|
|
l10nId: CHAT_MAP_L10N_IDS["chat-date-today"],
|
|
items: chatsFromToday,
|
|
});
|
|
}
|
|
|
|
if (chatsFromYesterday.length) {
|
|
entries.push({
|
|
l10nId: CHAT_MAP_L10N_IDS["chat-date-yesterday"],
|
|
items: chatsFromYesterday,
|
|
});
|
|
}
|
|
|
|
chatsByDay.forEach(dayChats => {
|
|
entries.push({
|
|
l10nId: CHAT_MAP_L10N_IDS["chat-date-this-month"],
|
|
items: dayChats,
|
|
});
|
|
});
|
|
|
|
chatsByMonth.forEach(monthChats => {
|
|
entries.push({
|
|
l10nId: CHAT_MAP_L10N_IDS["chat-date-prev-month"],
|
|
items: monthChats,
|
|
});
|
|
});
|
|
|
|
return entries;
|
|
}
|
|
|
|
/**
|
|
* Filters chats that were updated today.
|
|
*
|
|
* @param {Array} chats - Array of chat objects
|
|
* @returns {Array} Chats from today
|
|
*/
|
|
#getChatsFromToday(chats) {
|
|
const todayStart = this.#todaysDate.getTime();
|
|
const todayEnd = todayStart + DAYS_IN_MILLIS;
|
|
return chats.filter(
|
|
chat => chat.updatedDate >= todayStart && chat.updatedDate < todayEnd
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Filters chats that were updated yesterday.
|
|
*
|
|
* @param {Array} chats - Array of chat objects
|
|
* @returns {Array} Chats from yesterday
|
|
*/
|
|
#getChatsFromYesterday(chats) {
|
|
const todayStart = this.#todaysDate.getTime();
|
|
const yesterdayStart = this.#yesterdaysDate.getTime();
|
|
return chats.filter(
|
|
chat =>
|
|
chat.updatedDate >= yesterdayStart && chat.updatedDate < todayStart
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Groups chats by individual days within the current month.
|
|
* Excludes today and yesterday chats.
|
|
*
|
|
* @param {Array} chats - Array of chat objects
|
|
* @returns {Array<Array>} Array of arrays, each containing chats from the same day
|
|
*/
|
|
#getChatsByDay(chats) {
|
|
const yesterdayStart = this.#yesterdaysDate.getTime();
|
|
const chatsPerDay = [];
|
|
let currentDay = null;
|
|
let currentDayChats = [];
|
|
|
|
for (const chat of chats) {
|
|
if (chat.updatedDate >= yesterdayStart) {
|
|
continue;
|
|
}
|
|
|
|
const chatDate = new Date(chat.updatedDate);
|
|
if (!this.#isSameMonth(chatDate, this.#todaysDate)) {
|
|
break;
|
|
}
|
|
|
|
const dayKey = `${chatDate.getFullYear()}-${chatDate.getMonth()}-${chatDate.getDate()}`;
|
|
if (dayKey !== currentDay) {
|
|
if (currentDayChats.length) {
|
|
chatsPerDay.push(currentDayChats);
|
|
}
|
|
currentDay = dayKey;
|
|
currentDayChats = [chat];
|
|
} else {
|
|
currentDayChats.push(chat);
|
|
}
|
|
}
|
|
|
|
if (currentDayChats.length) {
|
|
chatsPerDay.push(currentDayChats);
|
|
}
|
|
|
|
return chatsPerDay;
|
|
}
|
|
|
|
/**
|
|
* Groups chats by month for dates outside the current month.
|
|
* Excludes today, yesterday, and current month chats.
|
|
*
|
|
* @param {Array} chats - Array of chat objects
|
|
* @returns {Array<Array>} Array of arrays, each containing chats from the same month
|
|
*/
|
|
#getChatsByMonth(chats) {
|
|
const yesterdayStart = this.#yesterdaysDate.getTime();
|
|
const chatsPerMonth = [];
|
|
let currentMonth = null;
|
|
let currentMonthChats = [];
|
|
|
|
for (const chat of chats) {
|
|
if (chat.updatedDate >= yesterdayStart) {
|
|
continue;
|
|
}
|
|
|
|
const chatDate = new Date(chat.updatedDate);
|
|
if (this.#isSameMonth(chatDate, this.#todaysDate)) {
|
|
continue;
|
|
}
|
|
|
|
const monthKey = `${chatDate.getFullYear()}-${chatDate.getMonth()}`;
|
|
if (monthKey !== currentMonth) {
|
|
if (currentMonthChats.length) {
|
|
chatsPerMonth.push(currentMonthChats);
|
|
}
|
|
currentMonth = monthKey;
|
|
currentMonthChats = [chat];
|
|
} else {
|
|
currentMonthChats.push(chat);
|
|
}
|
|
}
|
|
|
|
if (currentMonthChats.length) {
|
|
chatsPerMonth.push(currentMonthChats);
|
|
}
|
|
|
|
return chatsPerMonth;
|
|
}
|
|
|
|
/**
|
|
* Check if two dates have the same month and year.
|
|
*
|
|
* @param {Date} dateToCheck
|
|
* @param {Date} month
|
|
* @returns {boolean}
|
|
*/
|
|
#isSameMonth(dateToCheck, month) {
|
|
return (
|
|
dateToCheck.getMonth() === month.getMonth() &&
|
|
dateToCheck.getFullYear() === month.getFullYear()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Sets today's and yesterday's dates at midnight for date comparison operations.
|
|
*/
|
|
#setTodaysDate() {
|
|
const now = new Date();
|
|
this.#todaysDate = new Date(
|
|
now.getFullYear(),
|
|
now.getMonth(),
|
|
now.getDate()
|
|
);
|
|
this.#yesterdaysDate = new Date(
|
|
now.getFullYear(),
|
|
now.getMonth(),
|
|
now.getDate() - 1
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Fetches chats from the chat store.
|
|
*
|
|
* @returns {Promise<Array>} Array of chat objects, or empty array on error
|
|
*/
|
|
async #fetchChats() {
|
|
try {
|
|
// Use chatHistoryView for paginated, optimized results
|
|
// Get first 100 chats, sorted by date descending
|
|
const chats = await this.chatStore.chatHistoryView(1, 100, "desc");
|
|
return chats;
|
|
} catch (e) {
|
|
getLogger("ChatsController").error("Error fetching chats", e);
|
|
return [];
|
|
}
|
|
}
|
|
}
|