2 directories, 29 files

tinai

Home / testing / ai / tinai
import { createElementFromHTML } from './util/dom-utils.js';
import { SelectionManager } from './util/selection-manager.js';
import { customConfirm } from './util/confirm-dialog.js';

/**
 * Class ConversationsList
 * Manages the primary navigation list of conversations.
 * Handles rendering the list, filtering active/archived conversations,
 * and managing multi-selection for bulk actions.
 */
class ConversationsList {

	#storage;
	#app_callbacks;
	#selection;

	div_list;
	div_title;
	btn_select_conversations;
	btn_archive_conversations;
	btn_delete_conversations;
	btn_archives_toggle;
	btn_conversations;
	btn_show_conversations_new;

	BREAKPOINT_MOBILE;
	BREAKPOINT_TABLET;

	/**
	 * Initializes the ConversationsList instance with storage, callbacks, element references, and breakpoints.
	 * @param {Storage} storage_instance - Storage manager instance.
	 * @param {object} app_callbacks - Application callback methods.
	 * @param {object} elements - DOM element references.
	 * @param {object} breakpoints - Responsive breakpoint constants.
	 */
	constructor(storage_instance, app_callbacks, elements, breakpoints) {
		this.#storage = storage_instance;
		this.#app_callbacks = app_callbacks;

		this.div_list = elements.div_list;
		this.div_title = elements.div_title;
		this.btn_select_conversations = elements.btn_select_conversations;
		this.btn_archive_conversations = elements.btn_archive_conversations;
		this.btn_delete_conversations = elements.btn_delete_conversations;
		this.btn_archives_toggle = elements.btn_archives_toggle;
		this.btn_conversations = elements.btn_conversations;
		this.btn_show_conversations_new = elements.btn_show_conversations_new;

		this.BREAKPOINT_MOBILE = breakpoints.BREAKPOINT_MOBILE;
		this.BREAKPOINT_TABLET = breakpoints.BREAKPOINT_TABLET;

		this.#selection = new SelectionManager(() => this._update_selection_ui());

		if (this.btn_show_conversations_new) {
			this.btn_show_conversations_new.onclick = () => this.#app_callbacks.close_conversation();
		}
	}

	//region Conversation Management

	/**
	 * Selects and activates a conversation, handling responsive panel transitions.
	 * @param {string} guid - The conversation GUID.
	 * @param {string} type - The conversation type.
	 * @private
	 */
	_select_and_open_conversation(guid, type) {
		if (window.innerWidth <= this.BREAKPOINT_MOBILE) {
			this.#app_callbacks.set_v_open(false);
			this.#app_callbacks.set_i_open(false);
		}
		this.#app_callbacks.update_app_config(this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID, guid);
		if (type === this.#storage.CONVERSATION_TYPE_CHAT) {
			this.#app_callbacks.set_active_tab('conversation');
		} else if (type === this.#storage.CONVERSATION_TYPE_NOTEBOOK) {
			this.#app_callbacks.set_active_tab('document');
		}
		this.#app_callbacks.on_conversation_updated();
	}

	/**
	 * Creates a new conversation, updates the index, and sets it as the currently selected conversation.
	 * @param {string} type - The conversation type.
	 */
	index_new(type) {
		const guid = this.#storage.update_app_index('', false, type);
		this._select_and_open_conversation(guid, type);
	}

	/**
	 * Deletes a conversation by its GUID from the application index and localStorage.
	 * @param {string} guid - The unique identifier of the conversation to delete.
	 */
	index_delete(guid) {
		this.#storage.index_delete(guid);
		this.#app_callbacks.apply_panels_layout();
	}

	//endregion

	//region Selection and Actions

	/**
	 * Updates the UI elements related to conversation selection (button text and disabled states).
	 * @private
	 */
	_update_selection_ui() {
		this.#selection.updateControls(
			this.btn_select_conversations,
			[this.btn_archive_conversations, this.btn_delete_conversations],
			'Select'
		);
	}

	/**
	 * Toggles the multi-selection mode for managing the list of conversations.
	 */
	toggle_conversation_selection_mode() {
		this.#selection.toggleMode();
		this._update_selection_ui();
		this.on_app_index_updated();
	}

	/**
	 * Archives or unarchives all conversations currently selected in the list.
	 */
	archive_selected_conversations() {
		const selectedGuids = this.#selection.selectedItems;
		if (selectedGuids.length === 0) return;

		selectedGuids.forEach(guid => {
			const conversation = this.#storage.get_conversation(guid);
			if (conversation) {
				const isArchived = conversation[this.#storage.KEY_CONVERSATION_ARCHIVED] || false;
				conversation[this.#storage.KEY_CONVERSATION_ARCHIVED] = !isArchived;
				this.#storage.save_conversation(guid, conversation);
			}
		});

		this.#selection.clear();
		this._update_selection_ui();
		this.on_app_index_updated();
	}

	/**
	 * Deletes all conversations currently selected in the list after user confirmation.
	 */
	async delete_selected_conversations() {
		const selectedGuids = this.#selection.selectedItems;
		const count = selectedGuids.length;
		if (count === 0) return;

		const message = count === 1
			? 'Are you sure you want to delete this conversation?'
			: `Are you sure you want to delete ${count} selected conversations?`;

		if (await customConfirm('Delete Conversations', message)) {
			const config = this.#storage.get_app_config();
			const selected_guid = config[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];

			const toDelete = [...selectedGuids];
			toDelete.forEach(guid => {
				if (selected_guid === guid) {
					this.#app_callbacks.close_conversation();
				}
				this.index_delete(guid);
			});

			this.#selection.clear();
			this._update_selection_ui();
			this.on_app_index_updated();
		}
	}

	/**
	 * Toggles whether archived conversations are displayed in the main conversations list.
	 */
	toggle_archives() {
		const config = this.#storage.get_app_config();
		const current = config[this.#storage.KEY_SHOW_ARCHIVES] || false;
		const next = !current;
		this.#app_callbacks.update_app_config(this.#storage.KEY_SHOW_ARCHIVES, next);

		if (!next) {
			const selected_guid = config[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
			if (selected_guid) {
				const conversation = this.#storage.get_conversation(selected_guid);
				if (conversation && conversation[this.#storage.KEY_CONVERSATION_ARCHIVED]) {
					this.#app_callbacks.close_conversation();
				}
			}
		}

		this.on_app_index_updated();
	}

	//endregion

	//region HTML Generation

	/**
	 * Creates and returns a DOM element for a single conversation entry in the navigation list.
	 * @param {Object} data - The metadata from the application index.
	 * @param {string} guid - The GUID of the conversation.
	 * @param {string} title - The display title of the conversation.
	 * @param {boolean} is_archived - Whether the conversation is currently archived.
	 * @param {string} type - The type of conversation.
	 * @returns {HTMLElement} The created list item element.
	 */
	create_index_item_element(data, guid, title, is_archived, type) {
		const isChecked = this.#selection.isSelected(guid);
		const html = this._get_index_item_html(guid, title, is_archived, this.#selection.isSelectionMode, isChecked, type);
		const div = createElementFromHTML(html);
		if (!div) return document.createElement('div');

		div.onclick = () => {
			this._select_and_open_conversation(guid, type);
		};

		const chk = div.querySelector('.index-item-checkbox');
		if (chk) {
			chk.onclick = (e) => {
				e.stopPropagation();
				this.#selection.toggleItem(guid, chk.checked);
				this._update_selection_ui();
			};
		}

		return div;
	}

	/**
	 * Generates the HTML string for a single conversation index item.
	 * @param {string} guid - The GUID of the conversation.
	 * @param {string} title - The display title of the conversation.
	 * @param {boolean} is_archived - Whether the conversation is currently archived.
	 * @param {boolean} isSelectionMode - Whether conversation selection mode is active.
	 * @param {boolean} isChecked - Whether the checkbox for this item should be checked.
	 * @param {string} type - The type of conversation.
	 * @returns {string} The HTML string for the conversation index item.
	 */
	_get_index_item_html(guid, title, is_archived, isSelectionMode, isChecked, type) {
		const icon = type === this.#storage.CONVERSATION_TYPE_NOTEBOOK ? '&#128221;' : '&#128172;';
		return `
			<div class="div-index-item div-index-item-flex ${is_archived ? 'div-conversation-item-archived' : ''}"
				 id="conversation-${guid}">
				${isSelectionMode ? `<input type="checkbox" ${isChecked ? 'checked' : ''} class="index-item-checkbox index-item-checkbox-margin">` : ''}
				<span class="index-item-icon as-icon">${icon}</span>
				<span class="index-item-text-grow">${title}</span>
			</div>
		`;
	}

	/**
	 * Generates the HTML string for the "Archives" header.
	 * @returns {string} The HTML string for the archives header.
	 */
	_get_archive_header_html() {
		return '<h5 class="div-index-archive-header">Archives</h5>' +
			'<hr/>';
	}

	/**
	 * Generates the HTML string for the "No archived conversations" message.
	 * @returns {string} The HTML string for the no archived conversations message.
	 */
	_get_no_archive_message_html() {
		return '<div class="div-index-archive-empty">No archived conversations</div>';
	}

	/**
	 * Creates and returns the DOM element for the "New Conversation" trigger button.
	 * @returns {HTMLElement}
	 */
	create_new_conversation_trigger_item() {
		const html = `
			<button class="btn-new-scratchpad btn-new-conversation">
				<span class="index-item-icon as-icon" style="margin-right: 0.5em;">&#10133;</span>
				<span>New Conversation</span>
			</button>
		`;
		const div = createElementFromHTML(html);
		if (!div) return document.createElement('button');

		div.onclick = (e) => {
			e.stopPropagation();
			this.index_new(this.#storage.CONVERSATION_TYPE_CHAT);
		};
		return div;
	}

	/**
	 * Returns the total number of scratchpads in the index.
	 * @returns {number}
	 * @private
	 */
	_get_scratchpad_count() {
		const index = this.#storage.get_app_index() || [];
		let count = 0;
		index.forEach(item => {
			const guid = item[this.#storage.KEY_INDEX_GUID];
			const conversation = this.#storage.get_conversation(guid);
			const type = conversation?.[this.#storage.KEY_CONVERSATION_TYPE] || item?.[this.#storage.KEY_CONVERSATION_TYPE];
			if (type === this.#storage.CONVERSATION_TYPE_SCRATCHPAD) {
				count++;
			}
		});
		return count;
	}

	/**
	 * Creates and returns the DOM element for the Scratchpad list entry.
	 * @returns {HTMLElement}
	 */
	create_scratchpad_item_element() {
		const config = this.#storage.get_app_config();
		const selected_guid = config[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
		const conversation = this.#storage.get_conversation(selected_guid);
		const isSelected = selected_guid === 'scratchpad' || (conversation && conversation[this.#storage.KEY_CONVERSATION_TYPE] === this.#storage.CONVERSATION_TYPE_SCRATCHPAD);
		const count = this._get_scratchpad_count();
		const html = `
			<div class="div-index-item div-index-item-flex ${isSelected ? 'div-index-item-selected' : ''}"
				 id="conversation-scratchpad">
				<span class="index-item-icon as-icon">&#128196;</span>
				<span class="index-item-text-grow">Scratchpads</span>
				<span class="index-item-count">${count}</span>
			</div>
		`;
		const div = createElementFromHTML(html);
		if (!div) return document.createElement('div');

		div.onclick = () => {
			if (window.innerWidth <= (this.BREAKPOINT_TABLET || 1560)) {
				this.#app_callbacks.set_v_open(false);
				this.#app_callbacks.set_i_open(true);
			} else {
				this.#app_callbacks.set_i_open(true);
			}
			this.#app_callbacks.update_app_config(this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID, 'scratchpad');
			this.#app_callbacks.on_conversation_updated();
		};
		return div;
	}

	//endregion

	//region Rendering

	/**
	 * Updates the active/selected CSS class on conversation list items without re-rendering the whole list.
	 */
	apply_selected_index_class() {
		if (!this.div_list) return;

		const config = this.#storage.get_app_config();
		const selected_guid = config[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
		const conversation = this.#storage.get_conversation(selected_guid);
		const isScratchpad = selected_guid === 'scratchpad' || (conversation && conversation[this.#storage.KEY_CONVERSATION_TYPE] === this.#storage.CONVERSATION_TYPE_SCRATCHPAD);

		const items = this.div_list.querySelectorAll('.div-index-item');
		items.forEach(el => {
			el.classList.remove('div-index-item-selected');
		});

		if (isScratchpad) {
			const scratchpadEl = this.div_list.querySelector('#conversation-scratchpad');
			if (scratchpadEl) {
				scratchpadEl.classList.add('div-index-item-selected');
			}
		} else if (selected_guid) {
			const activeEl = this.div_list.querySelector(`#conversation-${selected_guid}`);
			if (activeEl) {
				activeEl.classList.add('div-index-item-selected');
			}
		}
	}

	/**
	 * Re-renders the full conversations list in the DOM based on current storage and filter state.
	 */
	on_app_index_updated() {
		const rawIndex = this.#storage.get_app_index() || [];
		const index = [...rawIndex];
		index.sort((a, b) => (b[this.#storage.KEY_INDEX_DATE_UPDATED] || 0) - (a[this.#storage.KEY_INDEX_DATE_UPDATED] || 0));

		const config = this.#storage.get_app_config();
		const show_archives = config[this.#storage.KEY_SHOW_ARCHIVES] || false;
		const selected_guid = config[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];

		let active_count = 0;
		let archived_count = 0;

		if (this.div_list) {
			this.div_list.innerHTML = '';
			const fragment = document.createDocumentFragment();

			fragment.appendChild(this.create_scratchpad_item_element());
			fragment.appendChild(this.create_new_conversation_trigger_item());

			index.forEach(item => {
				const guid = item[this.#storage.KEY_INDEX_GUID];
				const conversation = this.#storage.get_conversation(guid);
				if (!conversation) return;

				const is_archived = conversation[this.#storage.KEY_CONVERSATION_ARCHIVED] || false;
				const type = conversation[this.#storage.KEY_CONVERSATION_TYPE] || this.#storage.CONVERSATION_TYPE_CHAT;
				if (type === this.#storage.CONVERSATION_TYPE_SCRATCHPAD) {
					return;
				}

				if (is_archived) {
					archived_count++;
					if (!show_archives) {
						return;
					}
				} else {
					active_count++;
				}

				const title = conversation[this.#storage.KEY_CONVERSATION_TITLE] || 'Untitled';
				const element = this.create_index_item_element(item, guid, title, is_archived, type);

				if (guid === selected_guid) {
					element.classList.add('div-index-item-selected');
				}

				fragment.appendChild(element);
			});

			if (show_archives) {
				const archive_header = createElementFromHTML(this._get_archive_header_html());
				if (archive_header) fragment.appendChild(archive_header);

				if (archived_count === 0) {
					const no_archives = createElementFromHTML(this._get_no_archive_message_html());
					if (no_archives) fragment.appendChild(no_archives);
				}
			}

			this.div_list.appendChild(fragment);
		}

		const total_selectable = active_count + (show_archives ? archived_count : 0);
		if (this.btn_select_conversations) {
			this.btn_select_conversations.disabled = (total_selectable === 0);
		}

		if (total_selectable === 0 && this.#selection.isSelectionMode) {
			this.#selection.clear();
		}

		this._update_selection_ui();

		if (this.btn_archives_toggle) {
			this.btn_archives_toggle.textContent = show_archives ? 'Hide Archives' : 'Show Archives';
		}
	}

	//endregion

}

export default ConversationsList;
🌐
conversations.js ×
Type: Web, text/plain
15.16 Kilobytes
Last Modified 2026-09-04 14:23:40
⬇ Download File