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;