From 2d79e71e706bcde70ae0112122f5ef32a11ab76f Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Sat, 29 Aug 2026 15:27:38 -0500 Subject: [PATCH] Add threadgate editing --- package.json | 2 +- src/css/style.css | 245 +++++++- src/js/api.js | 60 ++ src/js/components/post-composer.js | 256 +++++--- .../post-interaction-settings-dialog.js | 584 ++++++++++++++++++ src/js/dataHelpers.js | 69 ++- src/js/dataLayer/mutations.js | 143 ++++- src/js/dataLayer/patchStore.js | 28 + src/js/dataLayer/requests.js | 9 +- src/js/modals/whoCanReply.modal.js | 94 +++ src/js/navigation.js | 3 +- src/js/postComposerService.js | 15 + src/js/postCreator.js | 4 +- src/js/postInteractionHandler.js | 60 ++ src/js/preferences.js | 39 ++ src/js/templates/largePost.template.js | 19 +- src/js/templates/postActionBar.template.js | 15 + src/js/templates/smallPost.template.js | 2 + src/js/templates/whoCanReplyBadge.template.js | 108 +--- src/js/utils.js | 6 +- src/js/views/chatDetail.view.js | 14 +- src/js/views/lists.view.js | 5 +- src/js/views/settings/mutedWords.view.js | 5 +- tests/e2e/mockServer.js | 62 ++ tests/e2e/specs/flows/editThreadgate.test.js | 276 +++++++++ .../specs/components/post-composer.test.js | 287 ++++++++- .../post-interaction-settings-dialog.test.js | 536 ++++++++++++++++ tests/unit/specs/dataHelpers.test.js | 195 ++++-- tests/unit/specs/dataLayer/mutations.test.js | 482 ++++++++++++++- tests/unit/specs/dataLayer/patchStore.test.js | 105 ++++ .../specs/modals/whoCanReply.modal.test.js | 120 ++++ tests/unit/specs/postCreator.test.js | 16 + tests/unit/specs/preferences.test.js | 124 ++++ .../templates/largePost.template.test.js | 69 +++ .../whoCanReplyBadge.template.test.js | 148 +---- tests/unit/specs/utils.test.js | 16 + 36 files changed, 3762 insertions(+), 459 deletions(-) create mode 100644 src/js/components/post-interaction-settings-dialog.js create mode 100644 src/js/modals/whoCanReply.modal.js create mode 100644 tests/e2e/specs/flows/editThreadgate.test.js create mode 100644 tests/unit/specs/components/post-interaction-settings-dialog.test.js create mode 100644 tests/unit/specs/modals/whoCanReply.modal.test.js diff --git a/package.json b/package.json index 0a4709a2..70bd748f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.20.23", + "version": "0.20.24", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/css/style.css b/src/css/style.css index 47bedb02..899fc469 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -2093,6 +2093,10 @@ container-link { } } +.who-can-reply-badge-link { + color: var(--highlight-color); +} + .post-text { line-height: 1.3125; margin-top: -4px; @@ -6953,21 +6957,43 @@ sidebar-search { .post-composer-bottom-bar { display: flex; - justify-content: space-between; - align-items: center; + flex-direction: column; + align-items: stretch; flex-shrink: 0; margin-top: 16px; - padding: 8px 16px calc(var(--safe-area-inset-bottom) + 8px); + padding: 8px 0 calc(var(--safe-area-inset-bottom) + 8px); position: sticky; bottom: 0; background-color: light-dark(var(--white), var(--mid-gray)); } +.post-composer-bottom-bar-main { + display: flex; + justify-content: space-between; + align-items: center; + padding-right: 16px; + padding-left: 16px; +} + +.interaction-settings-section .pill-radio-group label, +.interaction-settings-section .interaction-settings-row { + min-height: 48px; +} + +.post-composer-interaction-settings-row { + display: flex; + padding-bottom: 12px; + padding-right: 16px; + padding-left: 16px; + margin-bottom: 8px; + position: relative; +} + /* workaround for iOS Safari subpixel rounding */ -.post-composer-bottom-bar::before { +.post-composer-interaction-settings-row::after { content: ""; position: absolute; - top: 0; + bottom: 0; left: 0; right: 0; height: 1px; @@ -6988,6 +7014,11 @@ sidebar-search { align-items: center; } +.composer-interaction-settings-button .icon { + width: 15px; + height: 15px; +} + .post-composer-post { position: relative; transition: opacity 0.15s; @@ -8390,7 +8421,7 @@ context-menu-item-group > context-menu-item > button { margin: 0; } -.muted-word-checkbox-row { +.checkbox-row { display: flex; align-items: center; gap: 8px; @@ -8404,11 +8435,11 @@ context-menu-item-group > context-menu-item > button { font-weight: 500; } -.muted-word-checkbox-row:has(input:checked) { +.checkbox-row:has(input:checked) { background: color-mix(in srgb, var(--highlight-color) 15%, transparent); } -.muted-word-checkbox-row input[type="checkbox"] { +.checkbox-row input[type="checkbox"] { accent-color: var(--highlight-color); background-color: transparent; margin: 0; @@ -10082,6 +10113,204 @@ toggle-switch { width: 20px; } +/* Post Interaction Settings Dialog */ + +.post-interaction-settings-dialog::before { + z-index: 1; +} + +.post-interaction-settings-dialog[open] { + display: flex; + flex-direction: column; +} + +@media (max-width: 799px) { + .post-interaction-settings-dialog { + max-height: calc(100dvh - var(--sheet-top)); + } +} + +@media (min-width: 800px) { + .post-interaction-settings-dialog { + max-width: 440px; + } +} + +.post-interaction-settings-dialog-content { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + padding: 24px 20px; + padding-top: 28px; + position: relative; +} + +@media (min-width: 800px) { + .post-interaction-settings-dialog-content { + padding-top: 24px; + } +} + +.post-interaction-settings-dialog-close { + display: none; + position: absolute; + top: 12px; + right: 12px; + background: none; + border: none; + cursor: pointer; + color: var(--text-color); + padding: 4px 8px; + opacity: 0.6; +} + +@media (min-width: 800px) { + .post-interaction-settings-dialog-close { + display: flex; + align-items: center; + } +} + +@media (hover: hover) { + .post-interaction-settings-dialog-close:hover { + opacity: 1; + } +} + +.post-interaction-settings-dialog-body { + display: flex; + flex-direction: column; + gap: 16px; +} + +.post-interaction-settings-dialog-header { + display: flex; + flex-direction: column; + gap: 4px; +} + +.post-interaction-settings-dialog-title { + font-size: 1.3rem; + font-weight: 700; + margin: 0; +} + +.post-interaction-settings-dialog-subtitle { + margin: 0; + color: var(--text-color-secondary); + font-size: 0.95rem; +} + +.interaction-settings-section-title { + font-size: 0.95rem; + font-weight: 600; + margin: 0 0 8px; +} + +.interaction-settings-rows { + display: flex; + flex-direction: column; +} + +.interaction-settings-row { + margin-bottom: 8px; +} + +.interaction-settings-row-disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.interaction-settings-list-avatar { + width: 24px; + height: 24px; + border-radius: 4px; + flex-shrink: 0; +} + +.interaction-settings-list-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.interaction-settings-preserved-row { + justify-content: space-between; + color: var(--text-color-secondary); +} + +.interaction-settings-lists-toggle { + padding: 8px 0; + color: var(--text-color); + font-size: 14px; + font-weight: 500; + cursor: pointer; +} + +.interaction-settings-lists-message { + color: var(--text-color-secondary); + font-size: 0.9rem; + padding: 8px 0; +} + +.interaction-settings-quote-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 12px 12px; + border-radius: var(--card-border-radius); + background: var(--post-hover-color); + font-size: 14px; + font-weight: 500; +} + +.interaction-settings-quote-label { + display: flex; + align-items: center; + gap: 8px; +} + +.interaction-settings-quote-label app-icon { + display: flex; + width: 18px; + height: 18px; +} + +.interaction-settings-default-note { + color: var(--text-color-muted); + font-size: 14px; +} + +.interaction-settings-save-default-row { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 14px; +} + +.interaction-settings-save-default-row input[type="checkbox"] { + accent-color: var(--highlight-color); + margin: 0; +} + +.interaction-settings-error { + color: var(--error-color); + font-size: 0.9rem; +} + +.interaction-settings-save { + width: 100%; + padding: 12px 20px; + text-align: center; +} + +.interaction-settings-save:disabled { + cursor: not-allowed; +} + .chat-header-buttons { display: flex; align-items: center; diff --git a/src/js/api.js b/src/js/api.js index 69051e56..39bfb943 100644 --- a/src/js/api.js +++ b/src/js/api.js @@ -30,6 +30,10 @@ export class ApiError extends Error { } } +export function isRecordNotFoundError(error) { + return error instanceof ApiError && error.data?.error === "RecordNotFound"; +} + class PublicSession { constructor() { this.serviceEndpoint = PUBLIC_SERVICE_ENDPOINT_URL; @@ -1369,6 +1373,62 @@ export class Api { return res.data; } + async getThreadgateRecord(rkey) { + const res = await this.request("com.atproto.repo.getRecord", { + query: { + repo: this.session.did, + collection: "app.bsky.feed.threadgate", + rkey, + }, + }); + return res.data; + } + + async putThreadgateRecord(rkey, record, swapRecord) { + const res = await this.request("com.atproto.repo.putRecord", { + method: "POST", + body: { + repo: this.session.did, + collection: "app.bsky.feed.threadgate", + rkey, + record: { + $type: "app.bsky.feed.threadgate", + ...record, + }, + swapRecord: swapRecord ?? null, + }, + }); + return res.data; + } + + async getPostgateRecord(rkey) { + const res = await this.request("com.atproto.repo.getRecord", { + query: { + repo: this.session.did, + collection: "app.bsky.feed.postgate", + rkey, + }, + }); + return res.data; + } + + async putPostgateRecord(rkey, record, swapRecord) { + const res = await this.request("com.atproto.repo.putRecord", { + method: "POST", + body: { + repo: this.session.did, + collection: "app.bsky.feed.postgate", + rkey, + record: { + $type: "app.bsky.feed.postgate", + ...record, + }, + swapRecord: swapRecord ?? null, + }, + }); + return res.data; + } + async createModerationReport({ reasonType, reason, subject, labelerDid }) { const body = { reasonType, diff --git a/src/js/components/post-composer.js b/src/js/components/post-composer.js index a4ffa583..a58bd04d 100644 --- a/src/js/components/post-composer.js +++ b/src/js/components/post-composer.js @@ -54,6 +54,9 @@ import { getDraftDeviceId, } from "/js/drafts.js"; import { ApiError } from "/js/api.js"; +import { globeIconTemplate } from "/js/templates/icons/globeIcon.template.js"; +import { usersIconTemplate } from "/js/templates/icons/usersIcon.template.js"; +import "/js/components/post-interaction-settings-dialog.js"; import "/js/components/rich-text-input.js"; import "/js/components/image-alt-text-dialog.js"; import "/js/components/emoji-picker-dialog.js"; @@ -423,10 +426,10 @@ class PostComposer extends Component { this._draftId = null; this._isDirty = false; this._originalLocalRefs = null; - // Pass through unsupported fields on drafts - this._draftPassthrough = null; this._sendAbortController = null; this._sendAttemptId = null; + this._interactionSettingsTouched = false; + this._defaultInteractionSettings = null; // postId -> AbortController for that post's in-flight video upload this._videoAbortControllers = new Map(); this.state = new ReactiveStore("postComposer"); @@ -446,6 +449,8 @@ class PostComposer extends Component { ); this._pendingDraftsEnabled = null; this.state.$isDraggingFiles = new Signal.State(false); + this.state.$threadgateAllow = new Signal.State(null); + this.state.$postgateEmbeddingRules = new Signal.State(null); this._dragAndDropObserver = null; this._disposers = [ effect(() => { @@ -573,6 +578,10 @@ class PostComposer extends Component { const activePost = posts[activePostIndex]; const isThread = posts.length > 1; const isDraggingFiles = this.state.$isDraggingFiles.get(); + const threadgateAllow = this.state.$threadgateAllow.get(); + const postgateEmbeddingRules = this.state.$postgateEmbeddingRules.get(); + const isInteractionLimited = + threadgateAllow !== null || postgateEmbeddingRules?.length > 0; const currentCharCount = graphemeCount(activePost.text); const charCountPercentage = Math.min( @@ -728,85 +737,107 @@ class PostComposer extends Component { )}
-
- this.handleMediaSelect(e)} - @cancel=${(e) => { - e.stopPropagation(); - }} - /> - - - this.handleGifSelected(e.detail.gif)} - > -
+ ${!this.replyTo + ? html`
+ +
` + : ""} +
+
+ this.handleMediaSelect(e)} + @cancel=${(e) => { + e.stopPropagation(); + }} + /> + - { - this.handleEmojiSelect(e.detail.emoji); - e.currentTarget.close(); - }} - > -
-
-
- ${canAddPost - ? html`` - : ""} -
- ${300 - currentCharCount} + { + this.handleEmojiSelect(e.detail.emoji); + e.currentTarget.close(); + }} + > +
+
+
+ ${canAddPost + ? html`` + : ""} +
-
-
+ ${300 - currentCharCount} +
+
+
@@ -1537,13 +1568,14 @@ class PostComposer extends Component { ? buildGifExternal(postState.gif) : postState.external, quotedRecord: postState.quotedRecord, - labels: postState.labels ?? this._draftPassthrough?.labels ?? null, + labels: postState.labels ?? null, })), replyTo: this.replyTo, replyRoot: this.replyRoot, - threadgateAllow: this._draftPassthrough?.threadgateAllow ?? null, - postgateEmbeddingRules: - this._draftPassthrough?.postgateEmbeddingRules ?? null, + threadgateAllow: untrack(() => this.state.$threadgateAllow.get()), + postgateEmbeddingRules: untrack(() => + this.state.$postgateEmbeddingRules.get(), + ), // Publishing a saved/restored draft consumes it draft: this._draftId ? { @@ -1597,9 +1629,10 @@ class PostComposer extends Component { unrestoredImages: postState.unrestoredImages, unrestoredVideo: postState.unrestoredVideo, })), - threadgateAllow: this._draftPassthrough?.threadgateAllow ?? null, - postgateEmbeddingRules: - this._draftPassthrough?.postgateEmbeddingRules ?? null, + threadgateAllow: untrack(() => this.state.$threadgateAllow.get()), + postgateEmbeddingRules: untrack(() => + this.state.$postgateEmbeddingRules.get(), + ), }; } @@ -1685,9 +1718,10 @@ class PostComposer extends Component { } this.state.$posts.set([this._createPostState()]); this.state.$activePostIndex.set(0); + this.state.$threadgateAllow.set(null); + this.state.$postgateEmbeddingRules.set(null); this._draftId = null; this._originalLocalRefs = null; - this._draftPassthrough = null; this.render(); this._syncInputsFromState(); this._isDirty = false; @@ -1752,6 +1786,53 @@ class PostComposer extends Component { }); } + setDefaultInteractionSettings(settings) { + this._defaultInteractionSettings = settings; + if (!this._interactionSettingsTouched && !this._draftId) { + this.state.$threadgateAllow.set(settings.threadgateAllowRules); + this.state.$postgateEmbeddingRules.set(settings.postgateEmbeddingRules); + } + } + + openInteractionSettingsDialog() { + const dialog = document.createElement("post-interaction-settings-dialog"); + dialog.stacked = true; + dialog.dataLayer = this.dataLayer; + dialog.threadgateAllow = untrack(() => this.state.$threadgateAllow.get()); + dialog.postgateEmbeddingRules = untrack(() => + this.state.$postgateEmbeddingRules.get(), + ); + if (this._defaultInteractionSettings) { + dialog.defaultInteractionSettings = this._defaultInteractionSettings; + } + dialog.addEventListener("save-interaction-settings", async (e) => { + this.state.$threadgateAllow.set(e.detail.threadgateAllow); + this.state.$postgateEmbeddingRules.set(e.detail.postgateEmbeddingRules); + this._interactionSettingsTouched = true; + this._isDirty = true; + if (e.detail.saveAsDefault) { + const newDefault = { + threadgateAllowRules: e.detail.threadgateAllow, + postgateEmbeddingRules: e.detail.postgateEmbeddingRules, + }; + try { + await this.dataLayer.mutations.updatePostInteractionSettings( + newDefault, + ); + this._defaultInteractionSettings = newDefault; + } catch (error) { + console.error("Failed to save default interaction settings", error); + } + } + e.detail.successCallback(); + }); + dialog.addEventListener("dialog-closed", () => { + dialog.remove(); + }); + document.body.appendChild(dialog); + dialog.open(); + } + openDraftsDialog() { const dialog = document.createElement("drafts-dialog"); dialog.dataLayer = this.dataLayer; @@ -1783,10 +1864,11 @@ class PostComposer extends Component { const draftPosts = draft.posts?.length > 0 ? draft.posts : [{ text: "" }]; const isOriginatingDevice = draft.deviceId === getDraftDeviceId(); this.clearComposer(); - this._draftPassthrough = { - threadgateAllow: draft.threadgateAllow ?? null, - postgateEmbeddingRules: draft.postgateEmbeddingRules ?? null, - }; + this._interactionSettingsTouched = true; + this.state.$threadgateAllow.set(draft.threadgateAllow ?? null); + this.state.$postgateEmbeddingRules.set( + draft.postgateEmbeddingRules ?? null, + ); this._draftId = draftView.id; this._originalLocalRefs = new Set(getLocalRefsFromDraft(draft)); diff --git a/src/js/components/post-interaction-settings-dialog.js b/src/js/components/post-interaction-settings-dialog.js new file mode 100644 index 00000000..a24d331a --- /dev/null +++ b/src/js/components/post-interaction-settings-dialog.js @@ -0,0 +1,584 @@ +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 { closeIconTemplate } from "/js/templates/icons/closeIcon.template.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, + onToggleList, +}) { + const selectedCount = settings.filter( + (setting) => setting.type === "list", + ).length; + return html` +
+ + 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` +
+ + onToggleQuotes(event.detail.checked)} + > +
+ `; +} + +function interactionSettingsFormTemplate({ + settings, + curateLists, + listsLoaded, + listsError, + hydratedLists, + quotesEnabled, + isSaving, + saveError, + defaultsRowState, + saveAsDefault, + 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, + 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._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 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, + 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(); diff --git a/src/js/dataHelpers.js b/src/js/dataHelpers.js index 0c73baa8..81b5151e 100644 --- a/src/js/dataHelpers.js +++ b/src/js/dataHelpers.js @@ -1,4 +1,4 @@ -import { unique } from "/js/utils.js"; +import { isNil, unique } from "/js/utils.js"; import { CDN_URL, FOLLOWING_FEED_URI, @@ -881,22 +881,20 @@ export function isPinnedPost(feedItem) { return feedItem.reason?.$type === "app.bsky.feed.defs#reasonPin"; } -export function getThreadgateAllowSettings(post) { - // - undefined allow -> { type: "everybody" } - // - empty allow array -> { type: "nobody" } - // - otherwise -> array of { type: "mention" | "followers" | "following" | "list" | "unknown", list? } - const threadgate = post?.threadgate; - if (!threadgate || !threadgate.record) { - return { type: "everybody" }; - } - const allow = threadgate.record.allow; - if (allow === undefined) { - return { type: "everybody" }; +// Normalize allow settings to array +// - null/undefined allow (or no record) -> [{ type: "everybody" }] +// - empty allow array -> [{ type: "nobody" }] +// - otherwise one entry per rule: +// { type: "mention" | "followers" | "following" } +// { type: "list", list: } +// { type: "unknown", raw: } +export function normalizeThreadgateAllowSettings(allow) { + if (isNil(allow)) { + return [{ type: "everybody" }]; } if (allow.length === 0) { - return { type: "nobody" }; + return [{ type: "nobody" }]; } - const lists = threadgate.lists || []; return allow.map((rule) => { switch (rule.$type) { case "app.bsky.feed.threadgate#mentionRule": @@ -906,16 +904,53 @@ export function getThreadgateAllowSettings(post) { case "app.bsky.feed.threadgate#followingRule": return { type: "following" }; case "app.bsky.feed.threadgate#listRule": + return { type: "list", list: rule.list }; + default: + return { type: "unknown", raw: rule }; + } + }); +} + +export function buildThreadgateAllowFromNormalized(settings) { + if (settings.some((setting) => setting.type === "everybody")) { + return null; + } + if (settings.some((setting) => setting.type === "nobody")) { + return []; + } + return settings.map((setting) => { + switch (setting.type) { + case "mention": + return { $type: "app.bsky.feed.threadgate#mentionRule" }; + case "followers": + return { $type: "app.bsky.feed.threadgate#followerRule" }; + case "following": + return { $type: "app.bsky.feed.threadgate#followingRule" }; + case "list": return { - type: "list", - list: lists.find((listView) => listView.uri === rule.list) ?? null, + $type: "app.bsky.feed.threadgate#listRule", + list: setting.list, }; + case "unknown": + return setting.raw; default: - return { type: "unknown" }; + throw new Error(`Unknown threadgate setting type: ${setting.type}`); } }); } +const DISABLE_EMBEDDING_RULE_TYPE = "app.bsky.feed.postgate#disableRule"; + +export function hasDisableEmbeddingRule(embeddingRules) { + return !!embeddingRules?.some( + (rule) => rule.$type === DISABLE_EMBEDDING_RULE_TYPE, + ); +} + +export function buildPostgateEmbeddingRules(quotesEnabled) { + return quotesEnabled ? null : [{ $type: DISABLE_EMBEDDING_RULE_TYPE }]; +} + // Adds a feed item to the beginning of a feed, preserving pinned post position. export function addFeedItemToFeed(feedItem, feed) { const newFeed = []; diff --git a/src/js/dataLayer/mutations.js b/src/js/dataLayer/mutations.js index 8681f6e2..96673906 100644 --- a/src/js/dataLayer/mutations.js +++ b/src/js/dataLayer/mutations.js @@ -1,5 +1,8 @@ +import { isRecordNotFoundError } from "/js/api.js"; import { parseUri, + buildUri, + hasDisableEmbeddingRule, createNotFoundPost, addFeedItemToFeed, pinPostInFeed, @@ -603,6 +606,18 @@ export class Mutations { await this.preferencesProvider.updatePreferences(newPreferences); } + async updatePostInteractionSettings({ + threadgateAllowRules, + postgateEmbeddingRules, + }) { + const preferences = await this.preferencesProvider.requirePreferences(); + const newPreferences = preferences.setPostInteractionSettings({ + threadgateAllowRules, + postgateEmbeddingRules, + }); + await this.preferencesProvider.updatePreferences(newPreferences); + } + async subscribeLabeler(profile, labelerInfo) { const patchId = this.patchStore.addPreferencePatch({ type: "subscribeLabeler", @@ -1017,7 +1032,9 @@ export class Mutations { } } - async createList({ currentUser, purpose, name, description, avatarBlob }) { + async createList({ purpose, name, description, avatarBlob }) { + const currentUser = this.dataStore.$currentUser.get(); + if (!currentUser) throw new Error("No current user"); const avatarRef = avatarBlob ? await this.api.uploadBlob(avatarBlob) : null; const record = { purpose, @@ -1277,6 +1294,118 @@ export class Mutations { } } + async updatePostgateEmbeddingRules(post, embeddingRules) { + const currentUser = this.dataStore.$currentUser.get(); + if (!currentUser) throw new Error("No current user"); + const { repo, rkey } = parseUri(post.uri); + if (currentUser.did !== repo) { + throw new Error("Only the post author can edit the postgate"); + } + const embeddingDisabled = hasDisableEmbeddingRule(embeddingRules); + const hasEmbeddingRules = embeddingRules?.length > 0; + + const patchId = this.patchStore.addPostPatch(post.uri, { + type: "setEmbeddingDisabled", + embeddingDisabled, + }); + try { + let priorPostgateRecord = null; + try { + priorPostgateRecord = await this.api.getPostgateRecord(rkey); + } catch (error) { + if (!isRecordNotFoundError(error)) throw error; + } + const doUpdate = priorPostgateRecord || hasEmbeddingRules; + if (doUpdate) { + const record = { + ...priorPostgateRecord?.value, + $type: "app.bsky.feed.postgate", + post: post.uri, + createdAt: getCurrentTimestamp(), + }; + if (hasEmbeddingRules) { + record.embeddingRules = embeddingRules; + } else { + delete record.embeddingRules; + } + await this.api.putPostgateRecord( + rkey, + record, + priorPostgateRecord?.cid ?? null, + ); + } + + const latestPost = this.dataStore.$posts.get(post.uri); + if (latestPost) { + this.dataStore.$posts.set(post.uri, { + ...latestPost, + viewer: { ...latestPost.viewer, embeddingDisabled }, + }); + } + } finally { + this.patchStore.removePostPatch(post.uri, patchId); + } + } + + async updateThreadgateAllow(post, threadgateAllow) { + const currentUser = this.dataStore.$currentUser.get(); + if (!currentUser) throw new Error("No current user"); + const { repo, rkey } = parseUri(post.uri); + if (currentUser.did !== repo) { + throw new Error("Only the thread author can edit the threadgate"); + } + + const patchId = this.patchStore.addPostPatch(post.uri, { + type: "setThreadgateAllow", + allow: threadgateAllow, + }); + try { + let priorThreadgateRecord = null; + try { + priorThreadgateRecord = await this.api.getThreadgateRecord(rkey); + } catch (error) { + if (!isRecordNotFoundError(error)) throw error; + } + const record = { + ...priorThreadgateRecord?.value, + $type: "app.bsky.feed.threadgate", + post: post.uri, + createdAt: getCurrentTimestamp(), + }; + if (threadgateAllow === null) { + delete record.allow; + } else { + record.allow = threadgateAllow; + } + const result = await this.api.putThreadgateRecord( + rkey, + record, + priorThreadgateRecord?.cid ?? null, + ); + const written = { record, cid: result.cid }; + + const latestPost = this.dataStore.$posts.get(post.uri); + if (latestPost) { + this.dataStore.$posts.set(post.uri, { + ...latestPost, + threadgate: { + uri: buildUri({ + repo, + collection: "app.bsky.feed.threadgate", + rkey, + }), + lists: [], + ...latestPost.threadgate, + cid: written.cid, + record: written.record, + }, + }); + } + } finally { + this.patchStore.removePostPatch(post.uri, patchId); + } + } + async createThread({ posts, replyTo, @@ -1486,12 +1615,14 @@ export class Mutations { } } - async addMessageReaction(convoId, messageId, emoji, currentUserDid) { + async addMessageReaction(convoId, messageId, emoji) { + const currentUser = this.dataStore.$currentUser.get(); + if (!currentUser) throw new Error("No current user"); const patchId = this.patchStore.addMessagePatch(messageId, { type: "addReaction", reaction: { createdAt: getCurrentTimestamp(), - sender: { did: currentUserDid }, + sender: { did: currentUser.did }, value: emoji, }, }); @@ -1522,10 +1653,12 @@ export class Mutations { } } - async removeMessageReaction(convoId, messageId, emoji, currentUserDid) { + async removeMessageReaction(convoId, messageId, emoji) { + const currentUser = this.dataStore.$currentUser.get(); + if (!currentUser) throw new Error("No current user"); const patchId = this.patchStore.addMessagePatch(messageId, { type: "removeReaction", - currentUserDid, + currentUserDid: currentUser.did, value: emoji, }); try { diff --git a/src/js/dataLayer/patchStore.js b/src/js/dataLayer/patchStore.js index 874afec5..4561e1b4 100644 --- a/src/js/dataLayer/patchStore.js +++ b/src/js/dataLayer/patchStore.js @@ -118,6 +118,34 @@ export class PatchStore extends ReactiveStore { isHidden: true, }, }; + case "setEmbeddingDisabled": + return { + ...post, + viewer: { + ...post.viewer, + embeddingDisabled: patchBody.embeddingDisabled, + }, + }; + case "setThreadgateAllow": { + const record = { + $type: "app.bsky.feed.threadgate", + post: post.uri, + ...post.threadgate?.record, + }; + if (patchBody.allow === null) { + delete record.allow; + } else { + record.allow = patchBody.allow; + } + return { + ...post, + threadgate: { + lists: [], + ...post.threadgate, + record, + }, + }; + } default: throw new Error("Unknown patch type", patchBody.type); } diff --git a/src/js/dataLayer/requests.js b/src/js/dataLayer/requests.js index 668ffafa..aa18b974 100644 --- a/src/js/dataLayer/requests.js +++ b/src/js/dataLayer/requests.js @@ -20,7 +20,7 @@ import { import { getLocalRefsFromDraft } from "/js/dataHelpers.js"; import { unique } from "/js/utils.js"; import { SignalMap, ComputedMap, ReactiveStore } from "/js/signals.js"; -import { ApiError } from "/js/api.js"; +import { ApiError, isRecordNotFoundError } from "/js/api.js"; import { FOLLOWING_FEED_URI } from "/js/config.js"; const CONVO_LOG_SYSTEM_MESSAGE_TYPES = new Set([ @@ -287,10 +287,7 @@ export class Requests { try { record = await this.api.getProfileRecord(); } catch (error) { - if ( - !(error instanceof ApiError) || - error.data?.error !== "RecordNotFound" - ) { + if (!isRecordNotFoundError(error)) { throw error; } } @@ -572,7 +569,7 @@ export class Requests { results.forEach((result, index) => { if (result.status === "fulfilled") return; const error = result.reason; - if (error instanceof ApiError && error.data?.error === "RecordNotFound") { + if (isRecordNotFoundError(error)) { const uri = missingPostUris[index]; this.dataStore.$unavailablePosts.set(uri, createUnavailablePost(uri)); } diff --git a/src/js/modals/whoCanReply.modal.js b/src/js/modals/whoCanReply.modal.js new file mode 100644 index 00000000..ce29e778 --- /dev/null +++ b/src/js/modals/whoCanReply.modal.js @@ -0,0 +1,94 @@ +import { html } from "/js/lib/lit-html.js"; +import { normalizeThreadgateAllowSettings } from "/js/dataHelpers.js"; +import { linkToProfile } from "/js/navigation.js"; +import { Modal } from "/js/modals/modal.js"; + +function ruleTemplate({ rule, author, lists }) { + if (rule.type === "mention") { + return html`mentioned users`; + } + if (rule.type === "followers") { + return html`users following + @${author.handle}`; + } + if (rule.type === "following") { + return html`users followed by + @${author.handle}`; + } + if (rule.type === "list") { + const listView = lists.find( + (hydratedList) => hydratedList.uri === rule.list, + ); + if (listView) { + return html`${listView.name} members`; + } + return html`list members`; + } + return html`unknown`; +} + +function threadgateRuleTemplate({ post }) { + const settings = normalizeThreadgateAllowSettings( + post?.threadgate?.record?.allow, + ); + if (settings.some((rule) => rule.type === "everybody")) { + return html`Everybody can reply to this post.`; + } + if (settings.some((rule) => rule.type === "nobody")) { + return html`Replies to this post are disabled.`; + } + if (settings.some((rule) => rule.type === "unknown")) { + return html`This post has an unknown type of threadgate on it. Your app may + be out of date.`; + } + const author = post.author; + const lists = post.threadgate?.lists ?? []; + const parts = []; + settings.forEach((rule, i) => { + if (i > 0) { + if (i === settings.length - 1) { + parts.push(html`, and `); + } else { + parts.push(html`, `); + } + } + parts.push(ruleTemplate({ rule, author, lists })); + }); + return html`Only ${parts} can reply.`; +} + +export class WhoCanReplyModal extends Modal { + get className() { + return "bottom-sheet text-modal"; + } + + get attributes() { + return { "data-testid": "who-can-reply-modal" }; + } + + render({ dismiss, props: { post } }) { + const embeddingDisabled = !!post?.viewer?.embeddingDisabled; + return html` + + `; + } +} diff --git a/src/js/navigation.js b/src/js/navigation.js index c8316b6f..3758b0e8 100644 --- a/src/js/navigation.js +++ b/src/js/navigation.js @@ -1,4 +1,5 @@ import { getRKey, parseUri, hasValidHandle } from "/js/dataHelpers.js"; +import { isNil } from "/js/utils.js"; function encodePathSegment(segment) { return encodeURIComponent(segment).replace(/%3A/g, ":").replace(/%40/g, "@"); @@ -112,7 +113,7 @@ export function getPermalinkForCommunityPlugin(pluginId) { export function linkToLogin({ query = {} } = {}) { const params = new URLSearchParams(); for (const [key, value] of Object.entries(query)) { - if (value === undefined || value === null) continue; + if (isNil(value)) continue; params.set(key, String(value)); } const { pathname, search, hash } = window.location; diff --git a/src/js/postComposerService.js b/src/js/postComposerService.js index aeead586..4919e772 100644 --- a/src/js/postComposerService.js +++ b/src/js/postComposerService.js @@ -110,6 +110,21 @@ export class PostComposerService { // can't wait on plugin init before opening this.currentPostComposer.open(); const composer = this.currentPostComposer; + if (!replyTo) { + // Set default interaction settings from preferences + this.dataLayer.preferencesProvider + .requirePreferences() + .then((preferences) => { + if (this.currentPostComposer === composer) { + composer.setDefaultInteractionSettings( + preferences.getPostInteractionSettings(), + ); + } + }) + .catch((error) => { + console.warn("Failed to load default interaction settings", error); + }); + } this.pluginService .getPostComposerInit({ kind: replyTo ? "reply" : quotedPost ? "quote" : "post", diff --git a/src/js/postCreator.js b/src/js/postCreator.js index b4a54662..414e8658 100644 --- a/src/js/postCreator.js +++ b/src/js/postCreator.js @@ -1,4 +1,4 @@ -import { getPostLangs, readFileAsDataUrl, wait } from "/js/utils.js"; +import { getPostLangs, isNil, readFileAsDataUrl, wait } from "/js/utils.js"; import { computeRecordCid, generateTid } from "/js/atproto.js"; import { ImageCompressor } from "/js/imageCompressor.js"; import { @@ -87,7 +87,7 @@ export class PostCreator { }); uris.push(uri); - if (i === 0 && threadgateAllow) { + if (i === 0 && !isNil(threadgateAllow)) { writes.push({ $type: "com.atproto.repo.applyWrites#create", collection: "app.bsky.feed.threadgate", diff --git a/src/js/postInteractionHandler.js b/src/js/postInteractionHandler.js index 1e639888..58fa0d0a 100644 --- a/src/js/postInteractionHandler.js +++ b/src/js/postInteractionHandler.js @@ -2,12 +2,72 @@ import { hapticsImpactMedium } from "/js/haptics.js"; import { showToast } from "/js/toasts.js"; import { confirmModal } from "/js/modals/confirm.modal.js"; import { trashCanIconTemplate } from "/js/templates/icons/trashCanIcon.template.js"; +import { buildPostgateEmbeddingRules } from "/js/dataHelpers.js"; +import "/js/components/post-interaction-settings-dialog.js"; export class PostInteractionHandler { constructor(dataLayer, postComposerService, reportService) { this.dataLayer = dataLayer; this.postComposerService = postComposerService; this.reportService = reportService; + this._interactionSettingsDialog = null; + } + + handleEditInteractionSettings(post) { + if (this._interactionSettingsDialog !== null) { + return; + } + const dialog = document.createElement("post-interaction-settings-dialog"); + this._interactionSettingsDialog = dialog; + dialog.dataLayer = this.dataLayer; + dialog.threadgateAllow = post.threadgate?.record?.allow ?? null; + dialog.postgateEmbeddingRules = buildPostgateEmbeddingRules( + !post.viewer?.embeddingDisabled, + ); + dialog.hydratedLists = post.threadgate?.lists ?? []; + dialog.addEventListener("save-interaction-settings", async (event) => { + const { + threadgateAllow, + threadgateDirty, + postgateEmbeddingRules, + postgateDirty, + successCallback, + errorCallback, + } = event.detail; + try { + const writes = []; + if (threadgateDirty) { + writes.push( + this.dataLayer.mutations.updateThreadgateAllow( + post, + threadgateAllow, + ), + ); + } + if (postgateDirty) { + writes.push( + this.dataLayer.mutations.updatePostgateEmbeddingRules( + post, + postgateEmbeddingRules, + ), + ); + } + await Promise.all(writes); + successCallback(); + } catch (error) { + console.error(error); + showToast("Failed to update interaction settings", { + style: "error", + }); + errorCallback(error.message || "An unexpected error occurred."); + } + }); + dialog.addEventListener("dialog-closed", () => { + dialog.remove(); + this._interactionSettingsDialog = null; + }); + document.body.appendChild(dialog); + dialog.open(); } async handleLike(post, doLike) { diff --git a/src/js/preferences.js b/src/js/preferences.js index 5130162f..9a42dc5e 100644 --- a/src/js/preferences.js +++ b/src/js/preferences.js @@ -572,6 +572,38 @@ export class Preferences { return clone; } + getPostInteractionSettings() { + const pref = Preferences.getPostInteractionSettingsPreference(this.obj); + return { + threadgateAllowRules: pref?.threadgateAllowRules ?? null, + postgateEmbeddingRules: pref?.postgateEmbeddingRules?.length + ? pref.postgateEmbeddingRules + : null, + }; + } + + setPostInteractionSettings({ threadgateAllowRules, postgateEmbeddingRules }) { + const clone = this.clone(); + let pref = Preferences.getPostInteractionSettingsPreference(clone.obj); + if (!pref) { + pref = { + $type: "app.bsky.actor.defs#postInteractionSettingsPref", + }; + clone.obj.push(pref); + } + if (threadgateAllowRules === null) { + delete pref.threadgateAllowRules; + } else { + pref.threadgateAllowRules = threadgateAllowRules; + } + if (postgateEmbeddingRules === null) { + delete pref.postgateEmbeddingRules; + } else { + pref.postgateEmbeddingRules = postgateEmbeddingRules; + } + return clone; + } + hasMutedWord({ text, facets, embed, languages, author }) { const mutedWordsPreference = Preferences.getMutedWordsPreference(this.obj); if (!mutedWordsPreference) { @@ -760,6 +792,13 @@ export class Preferences { ); } + static getPostInteractionSettingsPreference(obj) { + return Preferences.getPreferenceByType( + obj, + "app.bsky.actor.defs#postInteractionSettingsPref", + ); + } + static getSavedFeedsPreference(obj) { return Preferences.getPreferenceByType( obj, diff --git a/src/js/templates/largePost.template.js b/src/js/templates/largePost.template.js index 22cd55a6..84814dbf 100644 --- a/src/js/templates/largePost.template.js +++ b/src/js/templates/largePost.template.js @@ -20,6 +20,7 @@ import { postHeaderTextTemplate } from "/js/templates/postHeaderText.template.js import { authorBadgesTemplate } from "/js/templates/labelBadges.template.js"; import { blockedPostTemplate } from "/js/templates/blockedPost.template.js"; import { whoCanReplyBadgeTemplate } from "/js/templates/whoCanReplyBadge.template.js"; +import { WhoCanReplyModal } from "/js/modals/whoCanReply.modal.js"; import { notFoundPostTemplate } from "/js/templates/notFoundPost.template.js"; import { unavailablePostTemplate } from "/js/templates/unavailablePost.template.js"; import { @@ -161,6 +162,8 @@ export function largePostTemplate({ } const embed = post.embed ?? post.record?.embed; const postText = post.record.text || ""; + const isRootPost = !post.record?.reply; + const canEditInteractionSettings = isUserPost && isRootPost; const badgeLabels = post.badgeLabels ?? []; const contentLabel = post.contentLabel; // Instead of hiding, add the content label to the badge labels @@ -229,7 +232,19 @@ export function largePostTemplate({ })}
${formatFullTimestamp(post.indexedAt)} - ${whoCanReplyBadgeTemplate({ post })} + ${isRootPost + ? whoCanReplyBadgeTemplate({ + post, + linkStyle: canEditInteractionSettings, + onClick: (post) => { + if (canEditInteractionSettings) { + postInteractionHandler.handleEditInteractionSettings(post); + } else { + WhoCanReplyModal.open({ post }); + } + }, + }) + : null}
${showActions ? html` @@ -280,6 +295,8 @@ export function largePostTemplate({ }, onClickPin: (post, doPin) => postInteractionHandler.handlePinPost(post, doPin), + onClickEditInteractionSettings: (post) => + postInteractionHandler.handleEditInteractionSettings(post), onClickReport: (post) => postInteractionHandler.handleReport(post), pluginService, diff --git a/src/js/templates/postActionBar.template.js b/src/js/templates/postActionBar.template.js index 9eb5cc83..2875bd30 100644 --- a/src/js/templates/postActionBar.template.js +++ b/src/js/templates/postActionBar.template.js @@ -68,8 +68,10 @@ function postContextMenuTemplate({ onClickReport, onClickDelete, onClickPin, + onClickEditInteractionSettings, }) { const canPin = isUserPost && !post.record?.reply; + const canEditInteractionSettings = isUserPost && !post.record?.reply; const pluginGroups = [...groupBy(pluginItems, "pluginId").values()]; return html` ${isAuthenticated && canPin @@ -202,6 +204,17 @@ function postContextMenuTemplate({ ${isUserPost ? html` + ${canEditInteractionSettings + ? html` + onClickEditInteractionSettings(post)} + > + Edit interaction settings + + ` + : null} diff --git a/src/js/templates/smallPost.template.js b/src/js/templates/smallPost.template.js index f1f9dc06..8fb2493a 100644 --- a/src/js/templates/smallPost.template.js +++ b/src/js/templates/smallPost.template.js @@ -240,6 +240,8 @@ export function smallPostTemplate({ }, onClickPin: (post, doPin) => postInteractionHandler.handlePinPost(post, doPin), + onClickEditInteractionSettings: (post) => + postInteractionHandler.handleEditInteractionSettings(post), onClickReport: (post) => postInteractionHandler.handleReport(post), enableFeedFeedback, pluginService, diff --git a/src/js/templates/whoCanReplyBadge.template.js b/src/js/templates/whoCanReplyBadge.template.js index 38352980..6bf1d4e3 100644 --- a/src/js/templates/whoCanReplyBadge.template.js +++ b/src/js/templates/whoCanReplyBadge.template.js @@ -1,108 +1,19 @@ import { html } from "/js/lib/lit-html.js"; -import { getThreadgateAllowSettings } from "/js/dataHelpers.js"; -import { linkToProfile } from "/js/navigation.js"; -import { Modal } from "/js/modals/modal.js"; +import { normalizeThreadgateAllowSettings } from "/js/dataHelpers.js"; import { usersIconTemplate } from "/js/templates/icons/usersIcon.template.js"; import { globeIconTemplate } from "/js/templates/icons/globeIcon.template.js"; -function ruleTemplate({ rule, author }) { - if (rule.type === "mention") { - return html`mentioned users`; - } - if (rule.type === "followers") { - return html`users following - @${author.handle}`; - } - if (rule.type === "following") { - return html`users followed by - @${author.handle}`; - } - if (rule.type === "list") { - if (rule.list) { - return html`${rule.list.name} members`; - } - return html`list members`; - } - return html`unknown`; -} - -function threadgateRuleTemplate({ post }) { - const settings = getThreadgateAllowSettings(post); - if (!Array.isArray(settings)) { - if (settings.type === "everybody") { - return html`Everybody can reply to this post.`; - } - if (settings.type === "nobody") { - return html`Replies to this post are disabled.`; - } - } - if (Array.isArray(settings)) { - if (settings.some((rule) => rule.type === "unknown")) { - return html`This post has an unknown type of threadgate on it. Your app - may be out of date.`; - } - const author = post.author; - const parts = []; - settings.forEach((rule, i) => { - if (i > 0) { - if (i === settings.length - 1) { - parts.push(html`, and `); - } else { - parts.push(html`, `); - } - } - parts.push(ruleTemplate({ rule, author })); - }); - return html`Only ${parts} can reply.`; - } - return null; -} - -export class WhoCanReplyModal extends Modal { - get className() { - return "bottom-sheet text-modal"; - } - - get attributes() { - return { "data-testid": "who-can-reply-modal" }; - } - - render({ dismiss, props: { post } }) { - const embeddingDisabled = !!post?.viewer?.embeddingDisabled; - return html` - - `; - } -} - -export function whoCanReplyBadgeTemplate({ post }) { - const settings = getThreadgateAllowSettings(post); - const isEverybody = !Array.isArray(settings) && settings.type === "everybody"; +export function whoCanReplyBadgeTemplate({ post, linkStyle = false, onClick }) { + const settings = normalizeThreadgateAllowSettings( + post?.threadgate?.record?.allow, + ); + const isEverybody = settings.some((rule) => rule.type === "everybody"); let label; let icon; if (isEverybody) { label = "Everybody can reply"; icon = globeIconTemplate(); - } else if (!Array.isArray(settings) && settings.type === "nobody") { + } else if (settings.some((rule) => rule.type === "nobody")) { label = "Replies disabled"; icon = usersIconTemplate(); } else { @@ -112,11 +23,12 @@ export function whoCanReplyBadgeTemplate({ post }) { return html`
Options:
-