0 directories, 46 files

util

Home / tinai / util
import { customConfirm, customAlert } from './confirm-dialog.js';
import { openDialog } from './dialog.js';
import { hashString } from './format-utils.js';
import { copyToClipboard } from './async-utils.js';

/** @typedef {import('../app.js').default} App */

/**
 * Conversation sharing: the share button's status dot and the share dialog.
 * Sharing copies the conversation JSON into the server's CONVERSATION_SHARES
 * table under a public share guid; the copy never updates automatically and
 * is served read-only by shared.php. The dot shows the theme's success color
 * while the conversation matches its shared copy and the alert color once it
 * has changed, and is hidden while the conversation is not shared.
 */
class Share {

	#app;
	#status_cache = new Map();
	#indicator_request = 0;

	static DOT_UPDATED = 'btn-share-updated';
	static DOT_OUTDATED = 'btn-share-outdated';

	/**
	 * @param {App} app - The main application instance.
	 */
	constructor(app) {
		this.#app = app;
	}

	/**
	 * Refreshes the share dot for the selected conversation: fetches (or
	 * reuses) the share status and compares the stored copy's hash against
	 * the local conversation. Skipped when the selection changes while the
	 * status request is in flight.
	 */
	update_share_indicator() {
		const storage = this.#app.storage;
		if (!this.#app.btn_share) return;
		const guid = storage.get_app_config()[storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
		if (!guid) {
			this.#set_dot('');
			return;
		}
		const request = ++this.#indicator_request;
		const conversation = storage.get_conversation(guid);
		this.#fetch_status(guid).then(status => {
			if (request !== this.#indicator_request) return;
			this.#apply_status(status, conversation);
		}).catch(() => {});
	}

	/**
	 * Opens the share flow: the copy warning for unshared conversations, or
	 * the link dialog with Update/Unshare for shared ones.
	 */
	async open_share_dialog() {
		const storage = this.#app.storage;
		const guid = storage.get_app_config()[storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
		if (!guid) return;
		const conversation = storage.get_conversation(guid);
		const title = conversation?.[storage.KEY_CONVERSATION_TITLE] || 'this conversation';

		let status;
		try {
			status = await this.#fetch_status(guid, true);
		} catch (error) {
			void customAlert('Share', String(error?.message || error));
			return;
		}

		if (!status.shared) {
			const confirmed = await customConfirm(
				'Share',
				`Sharing "${title}" creates a COPY of it that anyone with the link can view.\n\nThe copy does not update when the conversation changes.`,
				'OK',
				'Cancel'
			);
			if (!confirmed) return;
			try {
				status = await this.#create_share(guid, storage.get_conversation(guid));
				this.#apply_status(status, storage.get_conversation(guid));
			} catch (error) {
				void customAlert('Share Failed', String(error?.message || error));
				return;
			}
		}

		this.#open_link_dialog(guid, status, storage.get_conversation(guid));
	}

	/**
	 * Applies a share status to the dot: outdated when the stored copy's hash
	 * no longer matches the conversation.
	 * @param {Object} status - { shared, share_guid, hash, updated_at }.
	 * @param {Object} conversation - The local conversation object.
	 */
	#apply_status(status, conversation) {
		if (!status?.shared) {
			this.#set_dot('');
			return;
		}
		this.#set_dot(this.#is_outdated(status, conversation) ? Share.DOT_OUTDATED : Share.DOT_UPDATED);
	}

	/**
	 * Returns whether a shared copy's hash no longer matches the conversation.
	 * @param {Object} status - { shared, share_guid, hash, updated_at }.
	 * @param {Object} conversation - The local conversation object.
	 * @returns {boolean} True when the shared copy is outdated.
	 */
	#is_outdated(status, conversation) {
		return status.hash !== this.#conversation_hash(conversation);
	}

	/**
	 * Hashes a conversation for change detection. Storage.get_conversation
	 * fills a fresh TIMESTAMP for conversations that never saved one, which
	 * would change the serialization on every call, so the timestamp is
	 * excluded from the hash (nothing updates it after creation).
	 * @param {Object} conversation - The conversation object.
	 * @returns {string} The conversation's hash.
	 */
	#conversation_hash(conversation) {
		const copy = { ...conversation };
		delete copy.TIMESTAMP;
		return hashString(JSON.stringify(copy));
	}

	/**
	 * Sets the share button's dot state classes.
	 * @param {string} state - DOT_UPDATED, DOT_OUTDATED or '' for no dot.
	 */
	#set_dot(state) {
		const btn = this.#app.btn_share;
		if (!btn) return;
		btn.classList.toggle(Share.DOT_UPDATED, state === Share.DOT_UPDATED);
		btn.classList.toggle(Share.DOT_OUTDATED, state === Share.DOT_OUTDATED);
	}

	/**
	 * Fetches the share status for a conversation guid, caching the result.
	 * @param {string} guid - The conversation guid.
	 * @param {boolean} [force=false] - Bypass the cache.
	 * @returns {Promise<Object>} { shared, share_guid, hash, updated_at }.
	 */
	async #fetch_status(guid, force = false) {
		if (!force && this.#status_cache.has(guid)) {
			return this.#status_cache.get(guid);
		}
		const response = await this.#app.api.request({ action: 'status', guid }, 'share');
		if (response?.error) throw new Error(response.error);
		const status = {
			shared: !!response.shared,
			share_guid: response.share_guid || '',
			hash: response.hash || '',
			updated_at: response.updated_at || 0
		};
		this.#status_cache.set(guid, status);
		return status;
	}

	/**
	 * Copies the conversation to the server (first share and manual update).
	 * @param {string} guid - The conversation guid.
	 * @param {Object} conversation - The conversation object to copy.
	 * @returns {Promise<Object>} The resulting { shared, share_guid, hash, updated_at }.
	 */
	async #create_share(guid, conversation) {
		const hash = this.#conversation_hash(conversation);
		const response = await this.#app.api.request({ action: 'create', guid, conversation, hash }, 'share');
		if (response?.error) throw new Error(response.error);
		const status = {
			shared: true,
			share_guid: response.share_guid || '',
			hash,
			updated_at: response.updated_at || Date.now()
		};
		this.#status_cache.set(guid, status);
		return status;
	}

	/**
	 * Removes the shared copy for a conversation.
	 * @param {string} guid - The conversation guid.
	 */
	async #delete_share(guid) {
		const response = await this.#app.api.request({ action: 'delete', guid }, 'share');
		if (response?.error) throw new Error(response.error);
		this.#status_cache.set(guid, { shared: false, share_guid: '', hash: '', updated_at: 0 });
	}

	/**
	 * Cleans up after a conversation was deleted: drops the cached share
	 * state and removes the server's shared copy, so the public link stops
	 * working. The server request is idempotent; conversations without a
	 * shared copy delete nothing.
	 * @param {string} guid - The deleted conversation guid.
	 */
	conversation_deleted(guid) {
		if (!guid) return;
		void this.#delete_share(guid).catch(() => {});
	}

	/**
	 * Returns whether the conversation currently has a shared copy.
	 * @param {string} guid - The conversation guid.
	 * @returns {Promise<boolean>} True when a shared copy exists.
	 */
	async is_conversation_shared(guid) {
		if (!guid) return false;
		try {
			return !!(await this.#fetch_status(guid)).shared;
		} catch {
			return false;
		}
	}

	/**
	 * Counts how many of the given conversations currently have a shared
	 * copy, for the delete dialogs' shared-copy notes.
	 * @param {string[]} guids - The conversation guids.
	 * @returns {Promise<number>} The number of shared conversations.
	 */
	async count_shared_copies(guids) {
		const statuses = await Promise.all((guids || []).map(guid => this.#fetch_status(guid).catch(() => null)));
		return statuses.filter(status => status?.shared).length;
	}

	/**
	 * Builds the public viewer URL for a share guid.
	 * @param {string} share_guid - The share guid.
	 * @returns {string} The absolute URL.
	 */
	#share_url(share_guid) {
		return new URL('shared.php?g=' + encodeURIComponent(share_guid), location.href).href;
	}

	/**
	 * Opens the link dialog shown while a conversation is shared: the viewer
	 * URL with a copy button, the last update date colorized like the dot,
	 * plus Update (re-copy the conversation, refreshing the date line in
	 * place) and Unshare (remove the copy).
	 * @param {string} guid - The conversation guid.
	 * @param {Object} status - The current share status.
	 * @param {Object} conversation - The local conversation object.
	 */
	#open_link_dialog(guid, status, conversation) {
		const storage = this.#app.storage;
		const url = this.#share_url(status.share_guid);

		const content = document.createElement('div');
		const row = document.createElement('div');
		row.className = 'div-share-link-row';
		const input = document.createElement('input');
		input.type = 'text';
		input.className = 'prompt-style-input input-share-link';
		input.readOnly = true;
		input.value = url;
		const copy_btn = document.createElement('button');
		copy_btn.type = 'button';
		copy_btn.className = 'as-icon';
		copy_btn.textContent = 'Copy';
		copy_btn.addEventListener('click', () => void copyToClipboard(input.value, copy_btn));
		row.appendChild(input);
		row.appendChild(copy_btn);
		content.appendChild(row);

		const updated_line = document.createElement('p');
		const render_updated_line = (line_status, line_conversation) => {
			const outdated = this.#is_outdated(line_status, line_conversation);
			updated_line.className = 'status-msg ' + (outdated ? 'status-error' : 'status-success');
			updated_line.textContent = (outdated ? 'Outdated. ' : 'Up to date. ') + 'Last updated: ' + new Date(line_status.updated_at).toLocaleString();
		};
		render_updated_line(status, conversation);
		content.appendChild(updated_line);

		const close = openDialog({
			title: 'Share',
			content,
			buttons: [
				{
					caption: 'Update',
					onclick: async () => {
						try {
							const updated = await this.#create_share(guid, storage.get_conversation(guid));
							this.#apply_status(updated, storage.get_conversation(guid));
							render_updated_line(updated, storage.get_conversation(guid));
						} catch (error) {
							void customAlert('Update Failed', String(error?.message || error));
						}
					}
				},
				{
					caption: 'Unshare',
					onclick: async (dialog_close) => {
						if (!(await customConfirm('Unshare', 'This removes the shared copy. The link stops working. Continue?'))) return;
						try {
							await this.#delete_share(guid);
							this.#set_dot('');
							dialog_close();
						} catch (error) {
							void customAlert('Unshare Failed', String(error?.message || error));
						}
					}
				},
				{ caption: 'Open', onclick: () => window.open(url, '_blank') },
				{ caption: 'Close', onclick: (dialog_close) => dialog_close() }
			]
		});
		input.focus();
		input.select();
		return close;
	}
}

export default Share;
🌐
share.js ×
Type: Web, text/plain
10.75 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File