import { html, render } from "/js/lib/lit-html.js"; import { Component } from "/js/components/component.js"; import { scrollLocks } from "/js/scrollLocks.js"; import { closeWithAnimation } from "/js/dialogHelpers.js"; import { enableDragToDismiss } from "/js/dragHelpers.js"; import { Signal, ReactiveStore, effect, untrack } from "/js/signals.js"; import { normalizeThreadgateAllowSettings, buildThreadgateAllowFromNormalized, hasDisableEmbeddingRule, buildPostgateEmbeddingRules, cdnImageUrl, parseUri, } from "/js/dataHelpers.js"; import "/js/components/toggle-switch.js"; import "/js/components/app-icon.js"; const MAX_ALLOW_RULES = 5; const CURATE_LIST_PURPOSE = "app.bsky.graph.defs#curatelist"; const RULE_ROWS = [ { type: "mention", label: "Mentioned users" }, { type: "following", label: "Users you follow" }, { type: "followers", label: "Your followers" }, ]; function countRules(settings) { return settings.filter( (setting) => setting.type !== "everybody" && setting.type !== "nobody", ).length; } function checkboxRowTemplate({ label, checked, disabled, testid, onToggle }) { return html` `; } function listRowTemplate({ list, checked, disabled, onToggle }) { return html` `; } // Rules that must survive a save but can't be shown as a normal row: unknown // rule types and list rules for lists the user doesn't have access to. They // stay checked-but-opaque and can only be explicitly removed. function preservedRowTemplate({ setting, hydratedLists, onRemove }) { let label; if (setting.type === "unknown") { label = "An unknown rule (added by another app)"; } else { const { rkey } = parseUri(setting.list); const listView = hydratedLists.find( (hydratedList) => hydratedList.uri === setting.list, ); label = listView ? `${listView.name} members` : `A list you don't have access to (${rkey})`; } return html`
${label}
`; } function listsSectionTemplate({ settings, curateLists, listsLoaded, listsError, atRuleCap, isSaving, listsOpen, onToggleListsOpen, onToggleList, }) { const selectedCount = settings.filter( (setting) => setting.type === "list", ).length; return html`
onToggleListsOpen(event.target.open)} > Select from your lists${selectedCount > 0 ? ` (${selectedCount} selected)` : ""} ${listsError ? html`
An error occurred while loading your lists.
` : !listsLoaded ? html`
Loading lists…
` : curateLists.length === 0 ? html`
You don't have any lists yet.
` : curateLists.map((list) => { const checked = settings.some( (setting) => setting.type === "list" && setting.list === list.uri, ); return listRowTemplate({ list, checked, disabled: isSaving || (!checked && atRuleCap), onToggle: () => onToggleList(list), }); })}
`; } function quotePostsRowTemplate({ quotesEnabled, isSaving, onToggleQuotes }) { return html` `; } function interactionSettingsFormTemplate({ settings, curateLists, listsLoaded, listsError, hydratedLists, quotesEnabled, isSaving, saveError, defaultsRowState, saveAsDefault, listsOpen, onToggleListsOpen, onToggleSaveAsDefault, onSelectEverybody, onSelectNobody, onToggleRule, onToggleList, onToggleQuotes, onRemoveSetting, onSave, }) { const isEverybody = settings.some((setting) => setting.type === "everybody"); const isNobody = settings.some((setting) => setting.type === "nobody"); const atRuleCap = countRules(settings) >= MAX_ALLOW_RULES; const curateListUris = new Set(curateLists.map((list) => list.uri)); const preservedSettings = settings.filter( (setting) => setting.type === "unknown" || (setting.type === "list" && listsLoaded && !curateListUris.has(setting.list)), ); return html`

Who can reply

${RULE_ROWS.map((row) => checkboxRowTemplate({ label: row.label, checked: settings.some((setting) => setting.type === row.type), disabled: isSaving || (atRuleCap && !settings.some((setting) => setting.type === row.type)), testid: `interaction-settings-${row.type}`, onToggle: () => onToggleRule(row.type), }), )} ${listsSectionTemplate({ settings, curateLists, listsLoaded, listsError, atRuleCap, isSaving, listsOpen, onToggleListsOpen, onToggleList, })} ${preservedSettings.map((setting) => preservedRowTemplate({ setting, hydratedLists, onRemove: () => onRemoveSetting(setting), }), )}
${quotePostsRowTemplate({ quotesEnabled, isSaving, onToggleQuotes })} ${defaultsRowState ? defaultsRowState === "matches" ? html`
These are your default settings
` : html` ` : null} ${saveError ? html`
Could not save changes: ${saveError}
` : null} `; } class PostInteractionSettingsDialog extends Component { connectedCallback() { if (this.initialized) { return; } this.setAttribute("data-dialog-wrapper", ""); this.scrollLock = null; const initialSettings = normalizeThreadgateAllowSettings( this.threadgateAllow, ); this._initialAllowJson = JSON.stringify( buildThreadgateAllowFromNormalized(initialSettings), ); const initialQuotesEnabled = !hasDisableEmbeddingRule( this.postgateEmbeddingRules, ); this._initialQuotesEnabled = initialQuotesEnabled; this.state = new ReactiveStore("post-interaction-settings-dialog"); this.state.$settings = new Signal.State(initialSettings); this.state.$quotesEnabled = new Signal.State(initialQuotesEnabled); this.state.$listsError = new Signal.State(null); this.state.$isSaving = new Signal.State(false); this.state.$saveError = new Signal.State(null); this.state.$saveAsDefault = new Signal.State(false); this.state.$listsOpen = new Signal.State(false); this._defaultAllowJson = this.defaultInteractionSettings ? JSON.stringify(this.defaultInteractionSettings.threadgateAllowRules) : null; this._defaultQuotesEnabled = this.defaultInteractionSettings ? !hasDisableEmbeddingRule( this.defaultInteractionSettings.postgateEmbeddingRules, ) : null; this.innerHTML = ""; this._disposeEffect = effect(() => { this.render(); }); this._loadLists(); this.initialized = true; } disconnectedCallback() { this._disposeEffect?.(); this._disposeEffect = null; this.scrollLock?.release(); this.scrollLock = null; } async _loadLists() { try { await this.dataLayer.requests.loadCurrentUserLists(); } catch (error) { console.error(error); this.state.$listsError.set(error.message || "Could not load lists"); } } _getCurateLists() { const currentUser = this.dataLayer.derived.$currentUser.get(); if (!currentUser) return null; const listData = this.dataLayer.derived.$actorLists.get(currentUser.did); if (!listData) return null; return listData.lists.filter( (list) => list.purpose === CURATE_LIST_PURPOSE, ); } _getSettings() { return untrack(() => this.state.$settings.get()); } _setSettings(settings) { this.state.$settings.set( settings.length === 0 ? [{ type: "everybody" }] : settings, ); } _selectEverybody() { this._setSettings([{ type: "everybody" }]); } _selectNobody() { this._setSettings([{ type: "nobody" }]); } _addRule(rule) { const settings = this._getSettings().filter( (setting) => setting.type !== "everybody" && setting.type !== "nobody", ); if (settings.length >= MAX_ALLOW_RULES) return; this._setSettings([...settings, rule]); } _toggleRule(type) { const settings = this._getSettings(); if (settings.some((setting) => setting.type === type)) { this._setSettings(settings.filter((setting) => setting.type !== type)); } else { this._addRule({ type }); } } _toggleList(list) { const settings = this._getSettings(); const existing = settings.find( (setting) => setting.type === "list" && setting.list === list.uri, ); if (existing) { this._setSettings(settings.filter((setting) => setting !== existing)); } else { this._addRule({ type: "list", list: list.uri }); } } _removeSetting(settingToRemove) { this._setSettings( this._getSettings().filter((setting) => setting !== settingToRemove), ); } _save() { const threadgateAllow = buildThreadgateAllowFromNormalized( this._getSettings(), ); const quotesEnabled = untrack(() => this.state.$quotesEnabled.get()); const postgateEmbeddingRules = buildPostgateEmbeddingRules(quotesEnabled); const threadgateDirty = JSON.stringify(threadgateAllow) !== this._initialAllowJson; const postgateDirty = quotesEnabled !== this._initialQuotesEnabled; const saveAsDefault = this._defaultAllowJson !== null && untrack(() => this.state.$saveAsDefault.get()) && (JSON.stringify(threadgateAllow) !== this._defaultAllowJson || quotesEnabled !== this._defaultQuotesEnabled); if (!threadgateDirty && !postgateDirty && !saveAsDefault) { this.close(); return; } this.state.$isSaving.set(true); this.state.$saveError.set(null); this.dispatchEvent( new CustomEvent("save-interaction-settings", { detail: { threadgateAllow, threadgateDirty, postgateEmbeddingRules, postgateDirty, saveAsDefault, successCallback: () => { this.state.$isSaving.set(false); this.close(); }, errorCallback: (errorMessage) => { this.state.$isSaving.set(false); this.state.$saveError.set(errorMessage || "Please try again."); }, }, }), ); } render() { const settings = this.state.$settings.get(); const quotesEnabled = this.state.$quotesEnabled.get(); const listsError = this.state.$listsError.get(); const isSaving = this.state.$isSaving.get(); const saveError = this.state.$saveError.get(); const saveAsDefault = this.state.$saveAsDefault.get(); const listsOpen = this.state.$listsOpen.get(); const curateLists = this._getCurateLists(); let defaultsRowState = null; if (this._defaultAllowJson !== null) { const currentAllowJson = JSON.stringify( buildThreadgateAllowFromNormalized(settings), ); const matchesDefault = currentAllowJson === this._defaultAllowJson && quotesEnabled === this._defaultQuotesEnabled; defaultsRowState = matchesDefault ? "matches" : "differs"; } render( html` { if (event.target === event.currentTarget) { this.close(); } }} @cancel=${(event) => { event.preventDefault(); this.close(); }} @close=${() => { this.scrollLock?.release(); this.scrollLock = null; this.dispatchEvent(new CustomEvent("dialog-closed")); }} >

Post interaction settings

Customize who can interact with this post.

${interactionSettingsFormTemplate({ settings, curateLists: curateLists ?? [], listsLoaded: curateLists !== null, listsError, hydratedLists: this.hydratedLists ?? [], quotesEnabled, isSaving, saveError, defaultsRowState, saveAsDefault, listsOpen, onToggleListsOpen: (open) => this.state.$listsOpen.set(open), onToggleSaveAsDefault: (checked) => this.state.$saveAsDefault.set(checked), onSelectEverybody: () => this._selectEverybody(), onSelectNobody: () => this._selectNobody(), onToggleRule: (type) => this._toggleRule(type), onToggleList: (list) => this._toggleList(list), onToggleQuotes: (checked) => this.state.$quotesEnabled.set(checked), onRemoveSetting: (setting) => this._removeSetting(setting), onSave: () => this._save(), })}
`, this, ); } open() { this.scrollLock ??= scrollLocks.acquire({ target: this }); const dialog = this.querySelector(".post-interaction-settings-dialog"); if (dialog?.open) return; dialog.showModal(); enableDragToDismiss(dialog, { onDismiss: () => this.close(), allowOppositeStretch: true, scrollContainer: this.querySelector( ".post-interaction-settings-dialog-body", ), ignoreTouchTarget: (element) => element.closest("button, label, summary") !== null, }); } close() { return closeWithAnimation( this.querySelector(".post-interaction-settings-dialog"), ); } } PostInteractionSettingsDialog.register();