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`
`,
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();