diff --git a/src/css/style.css b/src/css/style.css index 3dfe0412..ea88a9e8 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -1864,6 +1864,13 @@ container-link { gap: 4px; } +@media (hover: hover) { + .small-post .repost-label a:hover, + .reply-to-author a:hover { + text-decoration: underline; + } +} + .small-post .repost-label .icon { height: 17px; width: 17px; @@ -3307,6 +3314,22 @@ image-carousel { font-weight: 600; } +.notification-others-button { + background: none; + border: none; + padding: 0; + margin: 0; + font: inherit; + font-weight: 600; + cursor: pointer; +} + +@media (hover: hover) { + .notification-others-button:hover { + text-decoration: underline; + } +} + .notification-time { color: var(--text-color-muted); font-weight: normal; @@ -10103,6 +10126,12 @@ p.setting-item-desc { } } +.bottom-sheet.profile-list-modal .profile-list-modal-body { + max-height: 60vh; + overflow-y: auto; + margin: 0 -24px; +} + .bottom-sheet.welcome-modal { width: 90%; max-width: 410px; diff --git a/src/js/api.js b/src/js/api.js index 23f1fadd..a8a6478b 100644 --- a/src/js/api.js +++ b/src/js/api.js @@ -632,11 +632,14 @@ export class Api { } async sendInteractions(interactions, feedProxyUrl) { + // Without an explicit feed generator to proxy to (e.g. a manual + // moderation action with no feed context), fall back to the main + // AppView, which implements sendInteractions; the PDS itself doesn't. await this.request(`app.bsky.feed.sendInteractions`, { method: "POST", body: { interactions }, headers: { - "atproto-proxy": feedProxyUrl, + "atproto-proxy": feedProxyUrl || this.bskyAppViewServiceDid, }, parseJson: false, // third-party feed might not return JSON }); diff --git a/src/js/dataHelpers.js b/src/js/dataHelpers.js index 5651b90f..99a235e8 100644 --- a/src/js/dataHelpers.js +++ b/src/js/dataHelpers.js @@ -51,6 +51,17 @@ export function valueForPinnedItem(item) { return item.type === "timeline" ? FOLLOWING_FEED_URI : item.data.uri; } +// The atproto-proxy value that routes app.bsky.feed.sendInteractions (and +// post-seen tracking) to a specific feed generator's own service, so +// algorithmic feeds actually receive the signal instead of it silently +// going nowhere useful. +export function getFeedGeneratorProxyUrl(feedGenerator) { + if (!feedGenerator?.did) { + return null; + } + return `${feedGenerator.did}#bsky_fg`; +} + export function getRKey(record) { return record.uri.split("/").pop(); } diff --git a/src/js/dataLayer/mutations.js b/src/js/dataLayer/mutations.js index dabd3e88..42941a74 100644 --- a/src/js/dataLayer/mutations.js +++ b/src/js/dataLayer/mutations.js @@ -333,7 +333,7 @@ export class Mutations { const showLessInteraction = { item: postURI, event: "app.bsky.feed.defs#requestLess", - feedContext, + ...(feedContext ? { feedContext } : {}), }; this.dataStore.$showLessInteractions.set([ ...this.dataStore.$showLessInteractions.get(), @@ -351,7 +351,7 @@ export class Mutations { const showMoreInteraction = { item: postURI, event: "app.bsky.feed.defs#requestMore", - feedContext, + ...(feedContext ? { feedContext } : {}), }; // Note, we don't really need to store this interaction because we don't use it in the UI (yet). // But, let's do it anyway for consistency. diff --git a/src/js/modals/profileList.modal.js b/src/js/modals/profileList.modal.js new file mode 100644 index 00000000..da14167a --- /dev/null +++ b/src/js/modals/profileList.modal.js @@ -0,0 +1,52 @@ +import { html } from "/js/lib/lit-html.js"; +import { Modal } from "/js/modals/modal.js"; +import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; + +class ProfileListModal extends Modal { + get className() { + return "bottom-sheet text-modal profile-list-modal"; + } + + get attributes() { + return { "data-testid": "profile-list-modal" }; + } + + render({ + props: { + title, + profiles, + isAuthenticated, + currentUserDid, + profileInteractionHandler, + }, + }) { + return html` + + `; + } +} + +// Shows a fixed, already-fetched list of profiles (e.g. everyone in a +// notification group, or a full known-followers list) in a scrollable +// modal. Unlike postLikes/postReposts, this doesn't paginate against the +// network — pass the complete list you already have. +export async function profileListModal(profiles, options = {}) { + return ProfileListModal.open({ profiles, ...options }); +} diff --git a/src/js/templates/postActionBar.template.js b/src/js/templates/postActionBar.template.js index 48fc0c21..582f7b4a 100644 --- a/src/js/templates/postActionBar.template.js +++ b/src/js/templates/postActionBar.template.js @@ -14,7 +14,11 @@ import { repostIconTemplate } from "/js/templates/icons/repostIcon.template.js"; import { replyIconTemplate } from "/js/templates/icons/replyIcon.template.js"; import { heartIconTemplate } from "/js/templates/icons/heartIcon.template.js"; import { bookmarkIconTemplate } from "/js/templates/icons/bookmarkIcon.template.js"; -import { getRKey, canReplyToPost } from "/js/dataHelpers.js"; +import { + getRKey, + canReplyToPost, + getFeedGeneratorProxyUrl, +} from "/js/dataHelpers.js"; import { richTextToString } from "/js/facetHelpers.js"; import { SignInModal } from "/js/modals/signIn.modal.js"; import "/js/components/context-menu.js"; @@ -53,6 +57,7 @@ function postContextMenuTemplate({ isUserPost, isPinnedToProfile, enableFeedFeedback, + feedContext, pluginItems, onClickShowMore, onClickShowLess, @@ -118,13 +123,13 @@ function postContextMenuTemplate({ onClickShowMore(post)} + @click=${() => onClickShowMore(post, feedContext)} > Show more like this onClickShowLess(post)} + @click=${() => onClickShowLess(post, feedContext)} > Show less like this @@ -227,6 +232,10 @@ function postContextMenuTemplate({ async function openPostContextMenu(event, props) { const pluginItems = await props.pluginService.getPostContextMenuItems( props.post, + { + feedContext: props.feedContext ?? null, + feedProxyUrl: getFeedGeneratorProxyUrl(props.feedGenerator), + }, ); const menu = document.createElement("context-menu"); menu.classList.add("post-context-menu"); @@ -245,6 +254,8 @@ export function postActionBarTemplate({ isAuthenticated, currentUser, isUserPost, + feedContext = null, + feedGenerator = null, onClickReply = noop, onClickRepost = noop, onClickQuotePost = noop, @@ -408,6 +419,8 @@ export function postActionBarTemplate({ isUserPost, isPinnedToProfile, enableFeedFeedback, + feedContext, + feedGenerator, pluginService, onClickShowMore, onClickShowLess, diff --git a/src/js/templates/postFeed.template.js b/src/js/templates/postFeed.template.js index 2084d701..978104c0 100644 --- a/src/js/templates/postFeed.template.js +++ b/src/js/templates/postFeed.template.js @@ -110,6 +110,7 @@ function feedItemTemplate({ feedItem, currentUser, isAuthenticated, + feedGenerator, hiddenPostUris, postInteractionHandler, onClickShowLess, @@ -156,6 +157,8 @@ function feedItemTemplate({ currentUser, isAuthenticated, isPinned, + feedContext, + feedGenerator, hiddenPostUris, isUserPost: currentUser?.did === post.author?.did, replyContext: showReplyContext ? "reply" : null, @@ -233,6 +236,7 @@ export function postFeedTemplate({ feedItem, currentUser, isAuthenticated, + feedGenerator, hiddenPostUris, postInteractionHandler, onClickShowLess, diff --git a/src/js/templates/postHeaderText.template.js b/src/js/templates/postHeaderText.template.js index b18de55b..50b45973 100644 --- a/src/js/templates/postHeaderText.template.js +++ b/src/js/templates/postHeaderText.template.js @@ -26,9 +26,16 @@ export function postHeaderTextTemplate({ profile: author, })}${automatedAccountBadgeTemplate({ profile: author })} ${includeHandle - ? html`@${author.handle}` + ? enableProfileLink + ? html`@${author.handle}` + : html`@${author.handle}` : ""} ${includeTime ? html`·${getDisplayName(repostAuthor)}`} ` : ""} ${postHeaderTextTemplate({ @@ -145,7 +150,9 @@ export function smallPostTemplate({ ${replyToAuthor ? replyToAuthor.did === currentUser?.did ? " you" - : html` ${getDisplayName(replyToAuthor)}` + : html` ${getDisplayName(replyToAuthor)}` : " user"} ` : ""} @@ -191,6 +198,8 @@ export function smallPostTemplate({ isUserPost, isAuthenticated, currentUser, + feedContext, + feedGenerator, onClickReply: () => { window.router.go(linkToPost(post)); }, diff --git a/src/js/views/home.view.js b/src/js/views/home.view.js index 80712eda..10e9fde2 100644 --- a/src/js/views/home.view.js +++ b/src/js/views/home.view.js @@ -16,6 +16,7 @@ import { bindToPage, pageEffect } from "/js/router.js"; import { showToast } from "/js/toasts.js"; import { Signal, ReactiveStore } from "/js/signals.js"; import { WelcomeModal } from "/js/modals/welcome.modal.js"; +import { getFeedGeneratorProxyUrl } from "/js/dataHelpers.js"; class HomeView extends View { async render({ @@ -74,13 +75,6 @@ class HomeView extends View { }); } - function getProxyUrl(feedGenerator) { - if (!feedGenerator.did) { - return null; - } - return `${feedGenerator.did}#bsky_fg`; - } - const postSeenObservers = new Map(); // Initialize post seen observers for feeds with proxy URLs @@ -96,7 +90,7 @@ class HomeView extends View { } postSeenObservers.clear(); for (const item of interactableItems) { - const proxyUrl = getProxyUrl(item); + const proxyUrl = getFeedGeneratorProxyUrl(item); if (proxyUrl) { postSeenObservers.set(item.uri, new PostSeenObserver(api, proxyUrl)); } @@ -123,7 +117,7 @@ class HomeView extends View { dataLayer.mutations.sendShowLessInteraction( post.uri, feedContext, - getProxyUrl(feedGenerator), + getFeedGeneratorProxyUrl(feedGenerator), ); // Scroll to keep the feedback message in view (it might be hidden by the header, but that's okay) const feedFeedbackMessageElement = document.querySelector( @@ -138,7 +132,7 @@ class HomeView extends View { dataLayer.mutations.sendShowMoreInteraction( post.uri, feedContext, - getProxyUrl(feedGenerator), + getFeedGeneratorProxyUrl(feedGenerator), ); showToast("Feedback sent to feed operator"); } diff --git a/src/js/views/notifications.view.js b/src/js/views/notifications.view.js index c8cf7107..a40c5117 100644 --- a/src/js/views/notifications.view.js +++ b/src/js/views/notifications.view.js @@ -28,6 +28,7 @@ import { getTimestampFromRkey } from "/js/atproto.js"; import { notificationsIconTemplate } from "/js/templates/icons/notificationsIcon.template.js"; import { verifiedCheckIconTemplate } from "/js/templates/icons/verifiedCheckIcon.template.js"; import { contactsIconTemplate } from "/js/templates/icons/contactsIcon.template.js"; +import { profileListModal } from "/js/modals/profileList.modal.js"; import "/js/components/tab-bar.js"; import { NOTIFICATIONS_PAGE_SIZE } from "/js/config.js"; import "/js/components/infinite-scroll-container.js"; @@ -242,7 +243,7 @@ class NotificationsView extends View { `; } - function notificationProfileNamesTemplate({ notificationGroup }) { + function notificationProfileNamesTemplate({ notificationGroup, title }) { const { notifications } = notificationGroup; const firstNotif = notifications[0]; const displayName = getDisplayName(firstNotif.author); @@ -255,9 +256,26 @@ class NotificationsView extends View { })}${otherCount > 0 ? html` and - ${otherCount} ${otherCount === 1 ? "other" : "others"} { + event.stopPropagation(); + profileListModal( + notifications.map((notif) => notif.author), + { + title, + isAuthenticated, + currentUserDid: dataLayer.derived.$currentUser.get()?.did, + profileInteractionHandler: + interactionHandlers.profileInteractionHandler, + }, + ); + }} + > + ${otherCount} ${otherCount === 1 ? "other" : "others"} + ` : ""} `; @@ -276,7 +294,10 @@ class NotificationsView extends View {
${notificationAvatarsTemplate({ notifications })}
- ${notificationProfileNamesTemplate({ notificationGroup })} + ${notificationProfileNamesTemplate({ + notificationGroup, + title: "Followed you", + })} ${notificationGroup.type === "follow-back" ? "followed you back" : "followed you"} @@ -337,8 +358,11 @@ class NotificationsView extends View {
${notificationAvatarsTemplate({ notifications })}
- ${notificationProfileNamesTemplate({ notificationGroup })} liked - ${isRepost ? "your repost" : "your post"} + ${notificationProfileNamesTemplate({ + notificationGroup, + title: isRepost ? "Liked your repost" : "Liked your post", + })} + liked ${isRepost ? "your repost" : "your post"} · ${timeAgo}
${postPreviewTemplate({ post: likedPost })} @@ -366,7 +390,10 @@ class NotificationsView extends View {
${notificationAvatarsTemplate({ notifications })}
- ${notificationProfileNamesTemplate({ notificationGroup })} + ${notificationProfileNamesTemplate({ + notificationGroup, + title: isRepost ? "Reposted your repost" : "Reposted your post", + })} ${isRepost ? "reposted your repost" : "reposted your post"} · ${timeAgo}
@@ -419,8 +446,11 @@ class NotificationsView extends View {
${notificationAvatarsTemplate({ notifications })}
- ${notificationProfileNamesTemplate({ notificationGroup })} liked - your custom feed + ${notificationProfileNamesTemplate({ + notificationGroup, + title: "Liked your custom feed", + })} + liked your custom feed · ${timeAgo}
@@ -448,8 +478,11 @@ class NotificationsView extends View {
${notificationAvatarsTemplate({ notifications })}
- ${notificationProfileNamesTemplate({ notificationGroup })} signed - up with your starter pack + ${notificationProfileNamesTemplate({ + notificationGroup, + title: "Joined via your starter pack", + })} + signed up with your starter pack · ${timeAgo}
@@ -471,7 +504,10 @@ class NotificationsView extends View {
${notificationAvatarsTemplate({ notifications })}
- ${notificationProfileNamesTemplate({ notificationGroup })} + ${notificationProfileNamesTemplate({ + notificationGroup, + title: "Verified you", + })} verified you · ${timeAgo}
@@ -495,9 +531,12 @@ class NotificationsView extends View {
${notificationAvatarsTemplate({ notifications })}
- ${notificationProfileNamesTemplate({ notificationGroup })} removed - their ${otherCount > 0 ? "verifications" : "verification"} from - your account + ${notificationProfileNamesTemplate({ + notificationGroup, + title: "Removed verification", + })} + removed their ${otherCount > 0 ? "verifications" : "verification"} + from your account · ${timeAgo}
-- 2.51.2 From 0ee92b82a83defb2a05fbfc70c551353b3d31755 Mon Sep 17 00:00:00 2001 From: Nameless 7778777 <7778777@7778777.online> Date: Mon, 20 Jul 2026 18:15:53 +0200 Subject: [PATCH 2/9] Add plugin API for detailed profiles, known followers, and gated moderation actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the plugin host bridge so a plugin can: - Read a profile's follow-relationship details and known/mutual followers via app.data.getDetailedProfile(did), and the full known-followers list (beyond the capped summary) via app.data.getKnownFollowers(did). - Mute, block, or send feed feedback ("show more/less like this") on the user's behalf via app.moderation.muteActor/blockActor/ sendShowLessInteraction, gated behind a new permissions.moderation manifest scope (mute/block/feedback) the user grants at install time, matching the existing permissions.fetch pattern. - The post-context-menu event now also passes {feedContext, feedProxyUrl} as a third argument, so a plugin can attribute a feed-feedback signal to the specific feed generator a post came from. None of the above previously had any plugin-facing hostCall — plugins were read-only (getPost/getProfile/getRecord) with no path to moderation actions at all. Co-Authored-By: Claude Sonnet 5 --- impro-plugin/main.js | 40 ++++++++++++++ plugins.md | 2 + src/js/plugins/pluginModal.js | 20 +++++++ src/js/plugins/pluginPermissions.js | 17 ++++++ src/js/plugins/pluginService.js | 86 +++++++++++++++++++++++++++-- 5 files changed, 161 insertions(+), 4 deletions(-) diff --git a/impro-plugin/main.js b/impro-plugin/main.js index cddbe103..8085fcef 100644 --- a/impro-plugin/main.js +++ b/impro-plugin/main.js @@ -147,15 +147,55 @@ class PluginData { getProfile(did) { return hostCall("getProfile", { did }); } + // Like getProfile, but includes viewer relationship details not present + // on the basic profile view: viewer.following, viewer.followedBy, and + // viewer.knownFollowers (a summary of mutual followers). Requires no + // extra permission, but does a network round-trip if not already cached. + getDetailedProfile(did) { + return hostCall("getDetailedProfile", { did }); + } + // The full known-followers list for did (the summary on + // getDetailedProfile's viewer.knownFollowers is capped to a handful). + getKnownFollowers(did) { + return hostCall("getKnownFollowers", { did }); + } getRecord(repo, collection, rkey) { return hostCall("getRecord", { repo, collection, rkey }); } } +// Moderation actions on behalf of the signed-in user. Each method requires +// the corresponding scope ("mute", "block", "feedback") to be declared in +// the plugin manifest's `permissions.moderation` array, which the user must +// grant at install time. +class PluginModeration { + muteActor(did, mute = true) { + return hostCall("muteActor", { did, mute }); + } + blockActor(did, block = true) { + return hostCall("blockActor", { did, block }); + } + // feedContext/feedProxyUrl are optional and let the signal be attributed + // back to the specific feed generator that served the post, matching + // app.bsky.feed.sendInteractions semantics; omit them to just send a + // general requestLess signal. + sendShowLessInteraction( + postUri, + { feedContext = null, feedProxyUrl = null } = {}, + ) { + return hostCall("sendShowLessInteraction", { + postUri, + feedContext, + feedProxyUrl, + }); + } +} + class App { constructor() { this.currentUser = null; this.data = new PluginData(); + this.moderation = new PluginModeration(); } on(event, listener) { addEventListener(event, listener); diff --git a/plugins.md b/plugins.md index 9ae03a2b..17335fcf 100644 --- a/plugins.md +++ b/plugins.md @@ -38,6 +38,8 @@ Plugins are currently in **beta** as the API surface is being expanded. However, - Add custom feed filters - Transform rich text in posts - Make whitelisted network requests (requires permissions) +- Read a detailed profile (including whether the viewer follows/is followed by the account, and known/mutual followers) via `app.data.getDetailedProfile(did)` +- Mute, block, or send feed feedback ("show more/less like this") on the user's behalf via `app.moderation`, gated behind explicit `permissions.moderation` scopes (`mute`, `block`, `feedback`) the user must grant at install time ### Plugins CANNOT: diff --git a/src/js/plugins/pluginModal.js b/src/js/plugins/pluginModal.js index b2db7cb1..59730141 100644 --- a/src/js/plugins/pluginModal.js +++ b/src/js/plugins/pluginModal.js @@ -71,6 +71,13 @@ export function hidePluginModal({ pluginId, modalId }) { } } +const MODERATION_ACTION_LABELS = { + mute: "Mute and unmute accounts on your behalf", + block: "Block and unblock accounts on your behalf", + feedback: + 'Send feed feedback (e.g. "show fewer/more like this") on your behalf', +}; + function permissionsListTemplate({ permissions }) { const sections = []; const fetchPatterns = permissions.fetch ?? []; @@ -86,6 +93,19 @@ function permissionsListTemplate({ permissions }) {
`); } + const moderationScopes = permissions.moderation ?? []; + if (moderationScopes.length > 0) { + sections.push(html` +
+
    + ${moderationScopes.map( + (scope) => + html`
  • ${MODERATION_ACTION_LABELS[scope] ?? scope}
  • `, + )} +
+
+ `); + } return sections; } diff --git a/src/js/plugins/pluginPermissions.js b/src/js/plugins/pluginPermissions.js index c0c50558..7a6e864c 100644 --- a/src/js/plugins/pluginPermissions.js +++ b/src/js/plugins/pluginPermissions.js @@ -1,5 +1,7 @@ import { unique } from "/js/utils.js"; +const MODERATION_ACTIONS = ["mute", "block", "feedback"]; + export function getPermissionsFromManifest(manifest) { return parsePermissions(manifest.permissions ?? {}); } @@ -15,9 +17,24 @@ export function parsePermissions(permissions) { ); if (fetchPatterns.length > 0) parsed.fetch = fetchPatterns; } + if (permissions.moderation) { + const moderationArray = Array.isArray(permissions.moderation) + ? permissions.moderation + : [permissions.moderation]; + const moderationScopes = unique( + moderationArray.filter((entry) => MODERATION_ACTIONS.includes(entry)), + ); + if (moderationScopes.length > 0) parsed.moderation = moderationScopes; + } return parsed; } +// action is one of "mute", "block", "feedback" (the "show fewer/more like +// this" feed-interaction signal) +export function isModerationActionAllowed(action, permissions) { + return (permissions.moderation ?? []).includes(action); +} + export function diffPermissions(current, next) { const diff = {}; let hasAny = false; diff --git a/src/js/plugins/pluginService.js b/src/js/plugins/pluginService.js index 490a20c1..1e8c844d 100644 --- a/src/js/plugins/pluginService.js +++ b/src/js/plugins/pluginService.js @@ -21,6 +21,7 @@ import { getPermissionsFromManifest, diffPermissions, isEmptyPermissions, + isModerationActionAllowed, } from "/js/plugins/pluginPermissions.js"; import { compareVersions, groupBy, isDev, sortBy } from "/js/utils.js"; import { @@ -373,10 +374,37 @@ export class PluginService extends ReactiveStore { return this._dataLayer?.derived.$hydratedProfiles.get(did) ?? null; }); + this.pluginBridge.addHostMethod( + "getDetailedProfile", + async (plugin, { did }) => { + if (!this._dataLayer) return null; + let profile = + this._dataLayer.derived.$hydratedDetailedProfiles.get(did); + if (!profile) { + await this._dataLayer.requests.loadDetailedProfile(did); + profile = this._dataLayer.derived.$hydratedDetailedProfiles.get(did); + } + return profile ?? null; + }, + ); + this.pluginBridge.addHostMethod("getRecord", (plugin, args) => this.slingshot.getRecord(args), ); + // Full known-followers list for did (the profile.viewer.knownFollowers + // included on getProfile/getDetailedProfile is capped to a handful). + // The AppView doesn't currently paginate this endpoint in practice, so + // this is a single round-trip, not a cursor loop. + this.pluginBridge.addHostMethod( + "getKnownFollowers", + async (plugin, { did }) => { + if (!this._dataLayer) return null; + await this._dataLayer.requests.loadKnownFollowers(did); + return this._dataLayer.derived.$knownFollowers.get(did) ?? null; + }, + ); + this.pluginBridge.addHostMethod("getCurrentUser", () => { if (!this.session) return null; return { @@ -384,6 +412,55 @@ export class PluginService extends ReactiveStore { handle: this.session.handle, }; }); + + this.pluginBridge.addHostMethod( + "muteActor", + async (plugin, { did, mute = true }) => { + this._requireModerationPermission(plugin, "mute"); + const profile = this._resolveProfileForMutation(did); + if (mute) await this._dataLayer.mutations.muteProfile(profile); + else await this._dataLayer.mutations.unmuteProfile(profile); + }, + ); + + this.pluginBridge.addHostMethod( + "blockActor", + async (plugin, { did, block = true }) => { + this._requireModerationPermission(plugin, "block"); + const profile = this._resolveProfileForMutation(did); + if (block) await this._dataLayer.mutations.blockProfile(profile); + else await this._dataLayer.mutations.unblockProfile(profile); + }, + ); + + this.pluginBridge.addHostMethod( + "sendShowLessInteraction", + async (plugin, { postUri, feedContext = null, feedProxyUrl = null }) => { + this._requireModerationPermission(plugin, "feedback"); + await this._dataLayer.mutations.sendShowLessInteraction( + postUri, + feedContext, + feedProxyUrl, + ); + }, + ); + } + + _requireModerationPermission(plugin, action) { + if (!isModerationActionAllowed(action, plugin.permissions)) { + throw new Error( + `"${plugin.pluginId}" does not have "${action}" moderation permission`, + ); + } + if (!this._dataLayer) throw new Error("Not signed in"); + } + + _resolveProfileForMutation(did) { + if (!this._dataLayer) return { did }; + return ( + this._dataLayer.derived.$hydratedDetailedProfiles.get(did) ?? + this._dataLayer.derived.$hydratedProfiles.get(did) ?? { did } + ); } async loadEnabledPlugins() { @@ -807,8 +884,8 @@ export class PluginService extends ReactiveStore { return this.$settingTabs.get(pluginId); } - async getPostContextMenuItems(post) { - return this._collectContextMenuItems("post-context-menu", post); + async getPostContextMenuItems(post, meta = null) { + return this._collectContextMenuItems("post-context-menu", post, meta); } async getProfileContextMenuItems(profile) { @@ -853,13 +930,14 @@ export class PluginService extends ReactiveStore { return { text, cursor }; } - async _collectContextMenuItems(event, target) { + async _collectContextMenuItems(event, target, meta = null) { const listeners = this.registries.eventListeners.get(event); if (!listeners || listeners.size === 0) return []; const results = await Promise.all( [...listeners].map(async ([pluginId, handler]) => { try { - const items = await handler(target); + const items = + meta != null ? await handler(target, meta) : await handler(target); return (items ?? []).map((item) => ({ pluginId, icon: item.icon, -- 2.51.2 From 100e85f259c88f14870b4062f7114f298e43681c Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Mon, 20 Jul 2026 17:34:20 -0500 Subject: [PATCH 3/9] Handle empty string feedContexts --- src/js/dataLayer/mutations.js | 4 +-- src/js/postSeenObserver.js | 2 +- src/js/templates/postFeed.template.js | 1 - src/js/views/home.view.js | 15 ++++++++- tests/unit/specs/dataLayer/mutations.test.js | 31 +++++++++++++++++++ tests/unit/specs/postSeenObserver.test.js | 10 ++++++ .../specs/templates/postFeed.template.test.js | 12 ------- 7 files changed, 58 insertions(+), 17 deletions(-) diff --git a/src/js/dataLayer/mutations.js b/src/js/dataLayer/mutations.js index 42941a74..98194be5 100644 --- a/src/js/dataLayer/mutations.js +++ b/src/js/dataLayer/mutations.js @@ -333,7 +333,7 @@ export class Mutations { const showLessInteraction = { item: postURI, event: "app.bsky.feed.defs#requestLess", - ...(feedContext ? { feedContext } : {}), + ...(feedContext != null ? { feedContext } : {}), }; this.dataStore.$showLessInteractions.set([ ...this.dataStore.$showLessInteractions.get(), @@ -351,7 +351,7 @@ export class Mutations { const showMoreInteraction = { item: postURI, event: "app.bsky.feed.defs#requestMore", - ...(feedContext ? { feedContext } : {}), + ...(feedContext != null ? { feedContext } : {}), }; // Note, we don't really need to store this interaction because we don't use it in the UI (yet). // But, let's do it anyway for consistency. diff --git a/src/js/postSeenObserver.js b/src/js/postSeenObserver.js index b0e85cf7..cc4d6a52 100644 --- a/src/js/postSeenObserver.js +++ b/src/js/postSeenObserver.js @@ -115,7 +115,7 @@ export class PostSeenObserver { await this.interactionsDispatch.sendInteraction({ item: postUri, event: "app.bsky.feed.defs#interactionSeen", - feedContext, + ...(feedContext != null ? { feedContext } : {}), }); this.seenPosts.add(postUri); } catch (error) { diff --git a/src/js/templates/postFeed.template.js b/src/js/templates/postFeed.template.js index 978104c0..ecfd44d6 100644 --- a/src/js/templates/postFeed.template.js +++ b/src/js/templates/postFeed.template.js @@ -226,7 +226,6 @@ export function postFeedTemplate({ const content = html`
diff --git a/src/js/views/home.view.js b/src/js/views/home.view.js index 10e9fde2..f62a71e7 100644 --- a/src/js/views/home.view.js +++ b/src/js/views/home.view.js @@ -211,6 +211,17 @@ class HomeView extends View { ); const currentUser = dataLayer.derived.$currentUser.get(); const pinnedItems = dataLayer.derived.$hydratedPinnedItems.get() ?? []; + // Map of feed items -> feedContexts for postSeenObserver + const feedContextsByFeedUri = new Map( + pinnedItems.map((item) => [ + item.uri, + new Map( + (dataLayer.derived.$hydratedFeeds.get(item.uri)?.feed ?? []).map( + (feedItem) => [feedItem.post.uri, feedItem.feedContext ?? null], + ), + ), + ]), + ); const currentFeedUri = state.$currentFeedUri.get(); const currentFeedRequestStatus = dataLayer.requests.statusStore.$statuses.get( @@ -291,10 +302,12 @@ class HomeView extends View { ); const feedItems = document.querySelectorAll(".feed-item"); feedItems.forEach((feedItem) => { - const { feedGeneratorUri, feedContext, postUri } = feedItem.dataset; + const { feedGeneratorUri, postUri } = feedItem.dataset; if (feedGeneratorUri) { const postSeenObserver = postSeenObservers.get(feedGeneratorUri); if (postSeenObserver) { + const feedContext = + feedContextsByFeedUri.get(feedGeneratorUri)?.get(postUri) ?? null; postSeenObserver.register(feedItem, postUri, feedContext); } } diff --git a/tests/unit/specs/dataLayer/mutations.test.js b/tests/unit/specs/dataLayer/mutations.test.js index 9fd5e3ea..05a888cd 100644 --- a/tests/unit/specs/dataLayer/mutations.test.js +++ b/tests/unit/specs/dataLayer/mutations.test.js @@ -3054,6 +3054,37 @@ describe("sendShowLessInteraction", () => { assert.deepEqual(stored.length, 2); assert.deepEqual(stored[1].item, postURI); }); + + it("should omit feedContext when null but keep an empty string", async () => { + const dataStore = new DataStore(); + const patchStore = new PatchStore(dataStore); + const mockPreferencesProvider = { + requirePreferences: () => Preferences.createLoggedOutPreferences(), + }; + const sentInteractions = []; + const mutations = makeMutations( + { + sendInteractions: async (interactions) => { + sentInteractions.push(...interactions); + }, + }, + dataStore, + patchStore, + mockPreferencesProvider, + ); + + await mutations.sendShowLessInteraction(postURI, null, feedProxyUrl); + await mutations.sendShowLessInteraction(postURI, "", feedProxyUrl); + + assert.deepEqual(sentInteractions, [ + { item: postURI, event: "app.bsky.feed.defs#requestLess" }, + { + item: postURI, + event: "app.bsky.feed.defs#requestLess", + feedContext: "", + }, + ]); + }); }); describe("sendShowMoreInteraction", () => { diff --git a/tests/unit/specs/postSeenObserver.test.js b/tests/unit/specs/postSeenObserver.test.js index f5648767..1ee9baca 100644 --- a/tests/unit/specs/postSeenObserver.test.js +++ b/tests/unit/specs/postSeenObserver.test.js @@ -115,6 +115,16 @@ describe("PostSeenObserver - seen tracking", () => { assert(observer.seenPosts.has(postUriA)); }); + it("omits feedContext from the interaction when it is null", async () => { + const { element } = createTrackedElement(); + observer.register(element, postUriA, null); + await flushTimers(); + assert.deepEqual(sendInteractions.mock.callCount(), 1); + assert.deepEqual(sendInteractions.mock.calls[0].arguments[0], [ + { item: postUriA, event: "app.bsky.feed.defs#interactionSeen" }, + ]); + }); + it("does not send an interaction for an off-screen post", async () => { const { element } = createTrackedElement({ top: 2000, bottom: 2100 }); observer.register(element, postUriA, null); diff --git a/tests/unit/specs/templates/postFeed.template.test.js b/tests/unit/specs/templates/postFeed.template.test.js index 729a1bda..661a0419 100644 --- a/tests/unit/specs/templates/postFeed.template.test.js +++ b/tests/unit/specs/templates/postFeed.template.test.js @@ -115,18 +115,6 @@ describe("postFeedTemplate - feed with posts", () => { const feedItem = container.querySelector("[data-testid='feed-item']"); assert(feedItem.getAttribute("data-post-uri") !== null); }); - - it("should set data-feed-context attribute on feed items", () => { - const result = postFeedTemplate({ - feed: { feed: feed.slice(0, 1), cursor: null }, - currentUser: mockUser, - postInteractionHandler, - }); - const container = document.createElement("div"); - render(result, container); - const feedItem = container.querySelector("[data-testid='feed-item']"); - assert(feedItem.hasAttribute("data-feed-context")); - }); }); describe("postFeedTemplate - pagination", () => { -- 2.51.2 From b62755832c37cd7c8448384a4acfd21407fece03 Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Mon, 20 Jul 2026 18:44:40 -0500 Subject: [PATCH 4/9] Update plugin moderation api and key feedback by feed uri --- impro-plugin/main.js | 29 ++- impro-plugin/package.json | 2 +- src/js/dataLayer/dataStore.js | 4 +- src/js/dataLayer/derived.js | 4 +- src/js/dataLayer/mutations.js | 18 +- src/js/plugins/pluginService.js | 90 ++++++- src/js/templates/postActionBar.template.js | 1 + src/js/views/feedDetail.view.js | 2 +- src/js/views/home.view.js | 10 +- src/js/views/listDetail.view.js | 2 +- tests/unit/specs/dataLayer/mutations.test.js | 104 +++++++- .../unit/specs/plugins/pluginService.test.js | 229 ++++++++++++++++++ tests/unit/specs/plugins/pluginWorker.test.js | 29 +++ .../templates/postActionBar.template.test.js | 61 +++++ 14 files changed, 541 insertions(+), 44 deletions(-) diff --git a/impro-plugin/main.js b/impro-plugin/main.js index 8085fcef..19fa3334 100644 --- a/impro-plugin/main.js +++ b/impro-plugin/main.js @@ -149,8 +149,7 @@ class PluginData { } // Like getProfile, but includes viewer relationship details not present // on the basic profile view: viewer.following, viewer.followedBy, and - // viewer.knownFollowers (a summary of mutual followers). Requires no - // extra permission, but does a network round-trip if not already cached. + // viewer.knownFollowers (a summary of mutual followers). getDetailedProfile(did) { return hostCall("getDetailedProfile", { did }); } @@ -175,18 +174,24 @@ class PluginModeration { blockActor(did, block = true) { return hostCall("blockActor", { did, block }); } - // feedContext/feedProxyUrl are optional and let the signal be attributed - // back to the specific feed generator that served the post, matching - // app.bsky.feed.sendInteractions semantics; omit them to just send a - // general requestLess signal. - sendShowLessInteraction( - postUri, - { feedContext = null, feedProxyUrl = null } = {}, - ) { - return hostCall("sendShowLessInteraction", { + // Acts like the user clicking "Show less like this": sends the requestLess + // feedback signal to the feed that served the post and collapses the post + // behind a feedback message in feeds. + showLessLikeThis(postUri, feedUri) { + return hostCall("showLessLikeThis", { postUri, feedUri }); + } + showMoreLikeThis(postUri, feedUri) { + return hostCall("showMoreLikeThis", { postUri, feedUri }); + } + // Low-level app.bsky.feed.sendInteractions with no UI side effects. event + // must be an allowed app.bsky.feed.defs#interaction value (e.g. + // "app.bsky.feed.defs#interactionSeen") + sendInteraction(postUri, event, feedProxyUrl, { feedContext = null } = {}) { + return hostCall("sendInteraction", { postUri, - feedContext, + event, feedProxyUrl, + feedContext, }); } } diff --git a/impro-plugin/package.json b/impro-plugin/package.json index b3ee8893..21d558a9 100644 --- a/impro-plugin/package.json +++ b/impro-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@impro.social/impro-plugin", - "version": "0.0.11", + "version": "0.0.12", "type": "module", "main": "main.js", "license": "0BSD", diff --git a/src/js/dataLayer/dataStore.js b/src/js/dataLayer/dataStore.js index 3046bc2a..6a27a133 100644 --- a/src/js/dataLayer/dataStore.js +++ b/src/js/dataLayer/dataStore.js @@ -11,8 +11,6 @@ export class DataStore extends ReactiveStore { this.$chatRecipientSearchResults = new Signal.State(null); this.$searchTypeaheadResults = new Signal.State(null); this.$feedSearchResults = new Signal.State(null); - this.$showLessInteractions = new Signal.State([]); - this.$showMoreInteractions = new Signal.State([]); this.$notifications = new Signal.State(null); this.$mentionNotifications = new Signal.State(null); this.$pinnedItems = new Signal.State(null); @@ -31,6 +29,8 @@ export class DataStore extends ReactiveStore { this.$latestPostSearchRequestTimeTop = new Signal.State(null); this.$latestPostSearchRequestTimeLatest = new Signal.State(null); // Keyed signals + this.$showLessInteractions = new SignalMap(); + this.$showMoreInteractions = new SignalMap(); this.$feeds = new SignalMap(); this.$posts = new SignalMap(); this.$postThreads = new SignalMap(); diff --git a/src/js/dataLayer/derived.js b/src/js/dataLayer/derived.js index a2e1a465..2d6dee51 100644 --- a/src/js/dataLayer/derived.js +++ b/src/js/dataLayer/derived.js @@ -120,8 +120,8 @@ export class Derived extends ReactiveStore { this.pluginService = pluginService; this.isAuthenticated = isAuthenticated; this.draftMediaStore = draftMediaStore; - this.$showLessInteractions = new Signal.Computed(() => - this.dataStore.$showLessInteractions.get(), + this.$showLessInteractions = new ComputedMap( + (feedUri) => this.dataStore.$showLessInteractions.get(feedUri) ?? [], ); this.$hydratedPosts = new ComputedMap((uri) => { const post = this.patchStore.$patchedPosts.get(uri); diff --git a/src/js/dataLayer/mutations.js b/src/js/dataLayer/mutations.js index 98194be5..9bdcd9e2 100644 --- a/src/js/dataLayer/mutations.js +++ b/src/js/dataLayer/mutations.js @@ -329,16 +329,19 @@ export class Mutations { } } - async sendShowLessInteraction(postURI, feedContext, feedProxyUrl) { + async sendShowLessInteraction(postURI, feedUri, feedContext, feedProxyUrl) { const showLessInteraction = { item: postURI, event: "app.bsky.feed.defs#requestLess", ...(feedContext != null ? { feedContext } : {}), }; - this.dataStore.$showLessInteractions.set([ - ...this.dataStore.$showLessInteractions.get(), + this.dataStore.$showLessInteractions.set(feedUri, [ + ...(this.dataStore.$showLessInteractions.get(feedUri) ?? []), showLessInteraction, ]); + if (feedProxyUrl == null) { + return; + } try { await this.api.sendInteractions([showLessInteraction], feedProxyUrl); } catch (error) { @@ -347,7 +350,7 @@ export class Mutations { } } - async sendShowMoreInteraction(postURI, feedContext, feedProxyUrl) { + async sendShowMoreInteraction(postURI, feedUri, feedContext, feedProxyUrl) { const showMoreInteraction = { item: postURI, event: "app.bsky.feed.defs#requestMore", @@ -355,10 +358,13 @@ export class Mutations { }; // Note, we don't really need to store this interaction because we don't use it in the UI (yet). // But, let's do it anyway for consistency. - this.dataStore.$showMoreInteractions.set([ - ...this.dataStore.$showMoreInteractions.get(), + this.dataStore.$showMoreInteractions.set(feedUri, [ + ...(this.dataStore.$showMoreInteractions.get(feedUri) ?? []), showMoreInteraction, ]); + if (feedProxyUrl == null) { + return; + } try { await this.api.sendInteractions([showMoreInteraction], feedProxyUrl); } catch (error) { diff --git a/src/js/plugins/pluginService.js b/src/js/plugins/pluginService.js index 1e8c844d..5cd15b7b 100644 --- a/src/js/plugins/pluginService.js +++ b/src/js/plugins/pluginService.js @@ -31,8 +31,28 @@ import { import { Signal, SignalMap, SignalSet, ReactiveStore } from "/js/signals.js"; import { EventEmitter } from "/js/eventEmitter.js"; import { PLUGIN_REGISTRY_URL } from "/js/config.js"; +import { getFeedGeneratorProxyUrl } from "/js/dataHelpers.js"; const DISABLE_PLUGINS_QUERY_PARAM = "disable-plugins"; + +function requireHostMethodArg(method, name, value) { + if (!value) { + throw new Error(`${method} requires a ${name}`); + } +} + +// The app.bsky.feed.defs#interaction events plugins may send. Mirrors +// social-app's third-party feed policy for now - can expand later if needed +const FEED_INTERACTION_EVENTS = new Set([ + "app.bsky.feed.defs#requestLess", + "app.bsky.feed.defs#requestMore", + "app.bsky.feed.defs#interactionSeen", + "app.bsky.feed.defs#interactionLike", + "app.bsky.feed.defs#interactionRepost", + "app.bsky.feed.defs#interactionReply", + "app.bsky.feed.defs#interactionQuote", + "app.bsky.feed.defs#interactionShare", +]); export const PLUGIN_PREVIEW_QUERY_PARAM = "plugin-preview"; export function arePluginsDisabledByQueryParam() { @@ -417,6 +437,7 @@ export class PluginService extends ReactiveStore { "muteActor", async (plugin, { did, mute = true }) => { this._requireModerationPermission(plugin, "mute"); + requireHostMethodArg("muteActor", "did", did); const profile = this._resolveProfileForMutation(did); if (mute) await this._dataLayer.mutations.muteProfile(profile); else await this._dataLayer.mutations.unmuteProfile(profile); @@ -427,6 +448,7 @@ export class PluginService extends ReactiveStore { "blockActor", async (plugin, { did, block = true }) => { this._requireModerationPermission(plugin, "block"); + requireHostMethodArg("blockActor", "did", did); const profile = this._resolveProfileForMutation(did); if (block) await this._dataLayer.mutations.blockProfile(profile); else await this._dataLayer.mutations.unblockProfile(profile); @@ -434,16 +456,80 @@ export class PluginService extends ReactiveStore { ); this.pluginBridge.addHostMethod( - "sendShowLessInteraction", - async (plugin, { postUri, feedContext = null, feedProxyUrl = null }) => { + "showLessLikeThis", + async (plugin, { postUri, feedUri = null }) => { this._requireModerationPermission(plugin, "feedback"); + requireHostMethodArg("showLessLikeThis", "postUri", postUri); + requireHostMethodArg("showLessLikeThis", "feedUri", feedUri); + const { feedContext, feedProxyUrl } = this._resolveFeedAttribution( + postUri, + feedUri, + ); await this._dataLayer.mutations.sendShowLessInteraction( postUri, + feedUri, + feedContext, + feedProxyUrl, + ); + }, + ); + + this.pluginBridge.addHostMethod( + "showMoreLikeThis", + async (plugin, { postUri, feedUri = null }) => { + this._requireModerationPermission(plugin, "feedback"); + requireHostMethodArg("showMoreLikeThis", "postUri", postUri); + requireHostMethodArg("showMoreLikeThis", "feedUri", feedUri); + const { feedContext, feedProxyUrl } = this._resolveFeedAttribution( + postUri, + feedUri, + ); + await this._dataLayer.mutations.sendShowMoreInteraction( + postUri, + feedUri, feedContext, feedProxyUrl, ); }, ); + + this.pluginBridge.addHostMethod( + "sendInteraction", + async ( + plugin, + { postUri, event, feedProxyUrl = null, feedContext = null }, + ) => { + this._requireModerationPermission(plugin, "feedback"); + requireHostMethodArg("sendInteraction", "postUri", postUri); + requireHostMethodArg("sendInteraction", "feedProxyUrl", feedProxyUrl); + if (!FEED_INTERACTION_EVENTS.has(event)) { + throw new Error(`Unsupported feed interaction event "${event}"`); + } + await this._dataLayer.api.sendInteractions( + [ + { + item: postUri, + event, + ...(feedContext != null ? { feedContext } : {}), + }, + ], + feedProxyUrl, + ); + }, + ); + } + + _resolveFeedAttribution(postUri, feedUri) { + if (!feedUri || !this._dataLayer) { + return { feedContext: null, feedProxyUrl: null }; + } + const feed = this._dataLayer.dataStore.$feeds.get(feedUri); + const feedItem = feed?.feed.find((item) => item.post.uri === postUri); + const feedGenerator = this._dataLayer.derived.$feedGenerators.get(feedUri); + return { + feedContext: feedItem?.feedContext ?? null, + feedProxyUrl: getFeedGeneratorProxyUrl(feedGenerator), + }; } _requireModerationPermission(plugin, action) { diff --git a/src/js/templates/postActionBar.template.js b/src/js/templates/postActionBar.template.js index 582f7b4a..8ec84785 100644 --- a/src/js/templates/postActionBar.template.js +++ b/src/js/templates/postActionBar.template.js @@ -233,6 +233,7 @@ async function openPostContextMenu(event, props) { const pluginItems = await props.pluginService.getPostContextMenuItems( props.post, { + feedGenerator: props.feedGenerator ?? null, feedContext: props.feedContext ?? null, feedProxyUrl: getFeedGeneratorProxyUrl(props.feedGenerator), }, diff --git a/src/js/views/feedDetail.view.js b/src/js/views/feedDetail.view.js index eb7edc1d..4063e2d3 100644 --- a/src/js/views/feedDetail.view.js +++ b/src/js/views/feedDetail.view.js @@ -42,7 +42,7 @@ class FeedDetailView extends View { pageEffect(root, () => { const showLessInteractions = - dataLayer.derived.$showLessInteractions.get() ?? []; + dataLayer.derived.$showLessInteractions.get(feedUri); const hiddenPostUris = showLessInteractions.map( (interaction) => interaction.item, ); diff --git a/src/js/views/home.view.js b/src/js/views/home.view.js index f62a71e7..a4f05287 100644 --- a/src/js/views/home.view.js +++ b/src/js/views/home.view.js @@ -116,6 +116,7 @@ class HomeView extends View { async function handleShowLess(post, feedContext, feedGenerator) { dataLayer.mutations.sendShowLessInteraction( post.uri, + feedGenerator.uri, feedContext, getFeedGeneratorProxyUrl(feedGenerator), ); @@ -131,6 +132,7 @@ class HomeView extends View { async function handleShowMore(post, feedContext, feedGenerator) { dataLayer.mutations.sendShowMoreInteraction( post.uri, + feedGenerator.uri, feedContext, getFeedGeneratorProxyUrl(feedGenerator), ); @@ -204,11 +206,6 @@ class HomeView extends View { }); pageEffect(root, () => { - const showLessInteractions = - dataLayer.derived.$showLessInteractions.get() ?? []; - const hiddenPostUris = showLessInteractions.map( - (interaction) => interaction.item, - ); const currentUser = dataLayer.derived.$currentUser.get(); const pinnedItems = dataLayer.derived.$hydratedPinnedItems.get() ?? []; // Map of feed items -> feedContexts for postSeenObserver @@ -262,6 +259,9 @@ class HomeView extends View { ${pinnedItems.map((item) => { const acceptsInteractions = item.acceptsInteractions || item.uri === LOGGED_OUT_FEED_URI; + const hiddenPostUris = dataLayer.derived.$showLessInteractions + .get(item.uri) + .map((interaction) => interaction.item); const feed = dataLayer.derived.$hydratedFeeds.get(item.uri); const feedRequestStatus = dataLayer.requests.statusStore.$statuses.get( diff --git a/src/js/views/listDetail.view.js b/src/js/views/listDetail.view.js index c2491545..afa0e2c8 100644 --- a/src/js/views/listDetail.view.js +++ b/src/js/views/listDetail.view.js @@ -101,7 +101,7 @@ class ListDetailView extends View { pageEffect(root, () => { const showLessInteractions = - dataLayer.derived.$showLessInteractions.get() ?? []; + dataLayer.derived.$showLessInteractions.get(listUri); const hiddenPostUris = showLessInteractions.map( (interaction) => interaction.item, ); diff --git a/tests/unit/specs/dataLayer/mutations.test.js b/tests/unit/specs/dataLayer/mutations.test.js index 05a888cd..983fb7e1 100644 --- a/tests/unit/specs/dataLayer/mutations.test.js +++ b/tests/unit/specs/dataLayer/mutations.test.js @@ -3001,6 +3001,7 @@ describe("removeMessageReaction", () => { describe("sendShowLessInteraction", () => { const postURI = "at://did:plc:author/app.bsky.feed.post/1"; + const feedUri = "at://did:plc:feedgen/app.bsky.feed.generator/cool"; const feedContext = "ctx"; const feedProxyUrl = "https://feed.example/xrpc"; @@ -3022,9 +3023,14 @@ describe("sendShowLessInteraction", () => { mockPreferencesProvider, ); - await mutations.sendShowLessInteraction(postURI, feedContext, feedProxyUrl); + await mutations.sendShowLessInteraction( + postURI, + feedUri, + feedContext, + feedProxyUrl, + ); - const stored = dataStore.$showLessInteractions.get(); + const stored = dataStore.$showLessInteractions.get(feedUri); assert.deepEqual(stored.length, 1); assert.deepEqual(stored[0].item, postURI); assert.deepEqual(stored[0].event, "app.bsky.feed.defs#requestLess"); @@ -3040,7 +3046,9 @@ describe("sendShowLessInteraction", () => { const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; - dataStore.$showLessInteractions.set([{ item: "existing", event: "x" }]); + dataStore.$showLessInteractions.set(feedUri, [ + { item: "existing", event: "x" }, + ]); const mutations = makeMutations( { sendInteractions: async () => {} }, dataStore, @@ -3048,13 +3056,43 @@ describe("sendShowLessInteraction", () => { mockPreferencesProvider, ); - await mutations.sendShowLessInteraction(postURI, feedContext, feedProxyUrl); + await mutations.sendShowLessInteraction( + postURI, + feedUri, + feedContext, + feedProxyUrl, + ); - const stored = dataStore.$showLessInteractions.get(); + const stored = dataStore.$showLessInteractions.get(feedUri); assert.deepEqual(stored.length, 2); assert.deepEqual(stored[1].item, postURI); }); + it("should key stored interactions by feed", async () => { + const dataStore = new DataStore(); + const patchStore = new PatchStore(dataStore); + const mockPreferencesProvider = { + requirePreferences: () => Preferences.createLoggedOutPreferences(), + }; + const mutations = makeMutations( + { sendInteractions: async () => {} }, + dataStore, + patchStore, + mockPreferencesProvider, + ); + const otherFeedUri = "at://did:plc:feedgen/app.bsky.feed.generator/other"; + + await mutations.sendShowLessInteraction( + postURI, + feedUri, + feedContext, + feedProxyUrl, + ); + + assert.deepEqual(dataStore.$showLessInteractions.get(feedUri).length, 1); + assert.deepEqual(dataStore.$showLessInteractions.get(otherFeedUri), null); + }); + it("should omit feedContext when null but keep an empty string", async () => { const dataStore = new DataStore(); const patchStore = new PatchStore(dataStore); @@ -3073,8 +3111,13 @@ describe("sendShowLessInteraction", () => { mockPreferencesProvider, ); - await mutations.sendShowLessInteraction(postURI, null, feedProxyUrl); - await mutations.sendShowLessInteraction(postURI, "", feedProxyUrl); + await mutations.sendShowLessInteraction( + postURI, + feedUri, + null, + feedProxyUrl, + ); + await mutations.sendShowLessInteraction(postURI, feedUri, "", feedProxyUrl); assert.deepEqual(sentInteractions, [ { item: postURI, event: "app.bsky.feed.defs#requestLess" }, @@ -3085,10 +3128,35 @@ describe("sendShowLessInteraction", () => { }, ]); }); + + it("should store but not send when there is no feed proxy url", async () => { + const dataStore = new DataStore(); + const patchStore = new PatchStore(dataStore); + const mockPreferencesProvider = { + requirePreferences: () => Preferences.createLoggedOutPreferences(), + }; + const sentInteractions = []; + const mutations = makeMutations( + { + sendInteractions: async (interactions) => { + sentInteractions.push(...interactions); + }, + }, + dataStore, + patchStore, + mockPreferencesProvider, + ); + + await mutations.sendShowLessInteraction(postURI, feedUri, null, null); + + assert.deepEqual(sentInteractions, []); + assert.deepEqual(dataStore.$showLessInteractions.get(feedUri).length, 1); + }); }); describe("sendShowMoreInteraction", () => { const postURI = "at://did:plc:author/app.bsky.feed.post/1"; + const feedUri = "at://did:plc:feedgen/app.bsky.feed.generator/cool"; const feedContext = "ctx"; const feedProxyUrl = "https://feed.example/xrpc"; @@ -3110,9 +3178,14 @@ describe("sendShowMoreInteraction", () => { mockPreferencesProvider, ); - await mutations.sendShowMoreInteraction(postURI, feedContext, feedProxyUrl); + await mutations.sendShowMoreInteraction( + postURI, + feedUri, + feedContext, + feedProxyUrl, + ); - const stored = dataStore.$showMoreInteractions.get(); + const stored = dataStore.$showMoreInteractions.get(feedUri); assert.deepEqual(stored.length, 1); assert.deepEqual(stored[0].item, postURI); assert.deepEqual(stored[0].event, "app.bsky.feed.defs#requestMore"); @@ -3127,7 +3200,9 @@ describe("sendShowMoreInteraction", () => { const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; - dataStore.$showMoreInteractions.set([{ item: "existing", event: "x" }]); + dataStore.$showMoreInteractions.set(feedUri, [ + { item: "existing", event: "x" }, + ]); const mutations = makeMutations( { sendInteractions: async () => {} }, dataStore, @@ -3135,9 +3210,14 @@ describe("sendShowMoreInteraction", () => { mockPreferencesProvider, ); - await mutations.sendShowMoreInteraction(postURI, feedContext, feedProxyUrl); + await mutations.sendShowMoreInteraction( + postURI, + feedUri, + feedContext, + feedProxyUrl, + ); - const stored = dataStore.$showMoreInteractions.get(); + const stored = dataStore.$showMoreInteractions.get(feedUri); assert.deepEqual(stored.length, 2); assert.deepEqual(stored[1].item, postURI); }); diff --git a/tests/unit/specs/plugins/pluginService.test.js b/tests/unit/specs/plugins/pluginService.test.js index 04b0e948..bb11c6ed 100644 --- a/tests/unit/specs/plugins/pluginService.test.js +++ b/tests/unit/specs/plugins/pluginService.test.js @@ -1361,6 +1361,235 @@ describe("app.data host methods", () => { }); }); +describe("feed feedback host methods", () => { + const feedbackPlugin = { + pluginId: "test-plugin", + permissions: { moderation: ["feedback"] }, + }; + const postUri = "at://did:plc:author/app.bsky.feed.post/1"; + const feedUri = "at://did:plc:feedgen/app.bsky.feed.generator/cool-feed"; + + function makeService({ feedItem = null, feedGenerator = null } = {}) { + const { provider } = makeProvider(); + const service = new PluginService(provider, null); + const calls = { showLess: [], showMore: [], sendInteractions: [] }; + service.setDataLayer({ + dataStore: { + $feeds: { + get: (uri) => + uri === feedUri && feedItem ? { feed: [feedItem] } : null, + }, + }, + derived: { + $feedGenerators: { + get: (uri) => (uri === feedUri ? feedGenerator : null), + }, + }, + mutations: { + sendShowLessInteraction: async (...args) => calls.showLess.push(args), + sendShowMoreInteraction: async (...args) => calls.showMore.push(args), + }, + api: { + sendInteractions: async (...args) => calls.sendInteractions.push(args), + }, + }); + return { service, calls }; + } + + function getHandler(service, name) { + return service.pluginBridge._hostCallHandlers.get(name); + } + + it("showLessLikeThis resolves feedContext and proxy from the feed", async () => { + const { service, calls } = makeService({ + feedItem: { post: { uri: postUri }, feedContext: "ctx" }, + feedGenerator: { uri: feedUri, did: "did:web:feed.example" }, + }); + await getHandler(service, "showLessLikeThis")(feedbackPlugin, { + postUri, + feedUri, + }); + assert.deepEqual(calls.showLess, [ + [postUri, feedUri, "ctx", "did:web:feed.example#bsky_fg"], + ]); + }); + + it("showLessLikeThis and showMoreLikeThis reject when feedUri is missing", async () => { + const { service, calls } = makeService(); + await assert.rejects( + getHandler(service, "showLessLikeThis")(feedbackPlugin, { postUri }), + /requires a feedUri/, + ); + await assert.rejects( + getHandler(service, "showMoreLikeThis")(feedbackPlugin, { postUri }), + /requires a feedUri/, + ); + assert.deepEqual(calls.showLess, []); + assert.deepEqual(calls.showMore, []); + }); + + it("all three methods reject when postUri is missing", async () => { + const { service, calls } = makeService(); + await assert.rejects( + getHandler(service, "showLessLikeThis")(feedbackPlugin, { feedUri }), + /requires a postUri/, + ); + await assert.rejects( + getHandler(service, "showMoreLikeThis")(feedbackPlugin, { feedUri }), + /requires a postUri/, + ); + await assert.rejects( + getHandler(service, "sendInteraction")(feedbackPlugin, { + event: "app.bsky.feed.defs#interactionSeen", + feedProxyUrl: "did:web:feed.example#bsky_fg", + }), + /requires a postUri/, + ); + assert.deepEqual(calls.showLess, []); + assert.deepEqual(calls.showMore, []); + assert.deepEqual(calls.sendInteractions, []); + }); + + it("muteActor and blockActor reject when did is missing", async () => { + const { service } = makeService(); + await assert.rejects( + getHandler(service, "muteActor")( + { pluginId: "test-plugin", permissions: { moderation: ["mute"] } }, + {}, + ), + /muteActor requires a did/, + ); + await assert.rejects( + getHandler(service, "blockActor")( + { pluginId: "test-plugin", permissions: { moderation: ["block"] } }, + {}, + ), + /blockActor requires a did/, + ); + }); + + it("showLessLikeThis with an uncached feed still resolves the generator proxy", async () => { + const { service, calls } = makeService({ + feedGenerator: { uri: feedUri, did: "did:web:feed.example" }, + }); + await getHandler(service, "showLessLikeThis")(feedbackPlugin, { + postUri, + feedUri, + }); + assert.deepEqual(calls.showLess, [ + [postUri, feedUri, null, "did:web:feed.example#bsky_fg"], + ]); + }); + + it("showMoreLikeThis resolves attribution the same way", async () => { + const { service, calls } = makeService({ + feedItem: { post: { uri: postUri }, feedContext: "ctx" }, + feedGenerator: { uri: feedUri, did: "did:web:feed.example" }, + }); + await getHandler(service, "showMoreLikeThis")(feedbackPlugin, { + postUri, + feedUri, + }); + assert.deepEqual(calls.showMore, [ + [postUri, feedUri, "ctx", "did:web:feed.example#bsky_fg"], + ]); + }); + + it("sendInteraction sends a known event with caller-supplied routing and context", async () => { + const { service, calls } = makeService(); + await getHandler(service, "sendInteraction")(feedbackPlugin, { + postUri, + event: "app.bsky.feed.defs#interactionSeen", + feedProxyUrl: "did:web:feed.example#bsky_fg", + feedContext: "plugin-ctx", + }); + assert.deepEqual(calls.sendInteractions, [ + [ + [ + { + item: postUri, + event: "app.bsky.feed.defs#interactionSeen", + feedContext: "plugin-ctx", + }, + ], + "did:web:feed.example#bsky_fg", + ], + ]); + }); + + it("sendInteraction omits feedContext when the caller does not supply one", async () => { + const { service, calls } = makeService(); + await getHandler(service, "sendInteraction")(feedbackPlugin, { + postUri, + event: "app.bsky.feed.defs#interactionShare", + feedProxyUrl: "did:web:feed.example#bsky_fg", + }); + assert.deepEqual(calls.sendInteractions, [ + [ + [{ item: postUri, event: "app.bsky.feed.defs#interactionShare" }], + "did:web:feed.example#bsky_fg", + ], + ]); + }); + + it("sendInteraction rejects when feedProxyUrl is missing", async () => { + const { service, calls } = makeService(); + await assert.rejects( + getHandler(service, "sendInteraction")(feedbackPlugin, { + postUri, + event: "app.bsky.feed.defs#interactionSeen", + }), + /requires a feedProxyUrl/, + ); + assert.deepEqual(calls.sendInteractions, []); + }); + + it("sendInteraction rejects unknown and disallowed events", async () => { + const { service, calls } = makeService(); + for (const event of [ + "app.bsky.feed.defs#madeUp", + "app.bsky.feed.defs#clickthroughItem", + "app.bsky.feed.defs#clickthroughAuthor", + "app.bsky.feed.defs#clickthroughReposter", + "app.bsky.feed.defs#clickthroughEmbed", + ]) { + await assert.rejects( + getHandler(service, "sendInteraction")(feedbackPlugin, { + postUri, + event, + feedProxyUrl: "did:web:feed.example#bsky_fg", + }), + /Unsupported feed interaction event/, + ); + } + assert.deepEqual(calls.sendInteractions, []); + }); + + it("all three methods require the feedback moderation permission", async () => { + const { service, calls } = makeService(); + const noPermissionPlugin = { + pluginId: "test-plugin", + permissions: { moderation: ["mute"] }, + }; + for (const name of [ + "showLessLikeThis", + "showMoreLikeThis", + "sendInteraction", + ]) { + await assert.rejects( + getHandler(service, name)(noPermissionPlugin, { + postUri, + event: "app.bsky.feed.defs#interactionSeen", + }), + /"feedback" moderation permission/, + ); + } + assert.deepEqual(calls.showLess, []); + assert.deepEqual(calls.showMore, []); + assert.deepEqual(calls.sendInteractions, []); + }); +}); + describe("getRecord host method", () => { function makeServiceWithRealBridge() { const { provider } = makeProvider(); diff --git a/tests/unit/specs/plugins/pluginWorker.test.js b/tests/unit/specs/plugins/pluginWorker.test.js index 66123766..9ea3d1e4 100644 --- a/tests/unit/specs/plugins/pluginWorker.test.js +++ b/tests/unit/specs/plugins/pluginWorker.test.js @@ -335,6 +335,35 @@ describe("hostCall round-trip", () => { assert.deepEqual(sent.args[0], "at://example/feed"); }); + it("app.moderation feedback methods post hostCalls with postUri and feedUri", async () => { + clearMessages(); + const plugin = new Plugin(); + const postUri = "at://example/post/1"; + const feedUri = "at://example/feed/cool"; + + plugin.app.moderation.showLessLikeThis(postUri, feedUri); + assert.deepEqual(lastMessage().method, "showLessLikeThis"); + assert.deepEqual(lastMessage().args[0], { postUri, feedUri }); + + plugin.app.moderation.showMoreLikeThis(postUri, feedUri); + assert.deepEqual(lastMessage().method, "showMoreLikeThis"); + assert.deepEqual(lastMessage().args[0], { postUri, feedUri }); + + plugin.app.moderation.sendInteraction( + postUri, + "app.bsky.feed.defs#interactionSeen", + "did:web:feed.example#bsky_fg", + { feedContext: "ctx" }, + ); + assert.deepEqual(lastMessage().method, "sendInteraction"); + assert.deepEqual(lastMessage().args[0], { + postUri, + event: "app.bsky.feed.defs#interactionSeen", + feedProxyUrl: "did:web:feed.example#bsky_fg", + feedContext: "ctx", + }); + }); + it("app.data.getPost posts a hostCall and resolves with the host result", async () => { clearMessages(); const plugin = new Plugin(); diff --git a/tests/unit/specs/templates/postActionBar.template.test.js b/tests/unit/specs/templates/postActionBar.template.test.js index 196725ea..6f19a072 100644 --- a/tests/unit/specs/templates/postActionBar.template.test.js +++ b/tests/unit/specs/templates/postActionBar.template.test.js @@ -410,6 +410,67 @@ describe("postActionBarTemplate - plugin context menu items", () => { return document.body.querySelector("context-menu.post-context-menu"); } + it("should pass the post and feed meta to getPostContextMenuItems", async () => { + const menuCalls = []; + const pluginService = { + getPostContextMenuItems: async (...args) => { + menuCalls.push(args); + return []; + }, + }; + const feedGenerator = { + uri: "at://did:plc:feedgen/app.bsky.feed.generator/cool", + did: "did:web:feed.example", + }; + const result = postActionBarTemplate({ + post, + isAuthenticated: true, + currentUser: { did: "did:plc:test" }, + feedContext: "ctx", + feedGenerator, + pluginService, + }); + const container = document.createElement("div"); + document.body.appendChild(container); + render(result, container); + await openPostContextMenu(container); + assert.deepEqual(menuCalls, [ + [ + post, + { + feedGenerator, + feedContext: "ctx", + feedProxyUrl: "did:web:feed.example#bsky_fg", + }, + ], + ]); + container.remove(); + }); + + it("should pass null feed meta when the post is not in a feed", async () => { + const menuCalls = []; + const pluginService = { + getPostContextMenuItems: async (...args) => { + menuCalls.push(args); + return []; + }, + }; + const result = postActionBarTemplate({ + post, + isAuthenticated: true, + currentUser: { did: "did:plc:test" }, + pluginService, + }); + const container = document.createElement("div"); + document.body.appendChild(container); + render(result, container); + await openPostContextMenu(container); + assert.deepEqual(menuCalls, [ + [post, { feedGenerator: null, feedContext: null, feedProxyUrl: null }], + ]); + container.remove(); + }); + it("should render one context-menu-item-group per plugin", async () => { const pluginService = makePluginService([ { pluginId: "plugin-a", title: "A1", invoke: () => {} }, -- 2.51.2 From 6b6f0a66e73e4f4b603cda5931ea4b156435d00c Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Mon, 20 Jul 2026 19:20:59 -0500 Subject: [PATCH 5/9] Require feed generator for sendInteractions --- src/js/api.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/js/api.js b/src/js/api.js index a8a6478b..65cd5fb3 100644 --- a/src/js/api.js +++ b/src/js/api.js @@ -632,14 +632,16 @@ export class Api { } async sendInteractions(interactions, feedProxyUrl) { - // Without an explicit feed generator to proxy to (e.g. a manual - // moderation action with no feed context), fall back to the main - // AppView, which implements sendInteractions; the PDS itself doesn't. + // Interactions are only useful to the feed generator that served the + // posts, so callers must route to one. + if (!feedProxyUrl) { + throw new Error("sendInteractions requires a feedProxyUrl"); + } await this.request(`app.bsky.feed.sendInteractions`, { method: "POST", body: { interactions }, headers: { - "atproto-proxy": feedProxyUrl || this.bskyAppViewServiceDid, + "atproto-proxy": feedProxyUrl, }, parseJson: false, // third-party feed might not return JSON }); -- 2.51.2 From bdda5d7267818fa6a502f0772080f306efb3c1ed Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Mon, 20 Jul 2026 19:26:39 -0500 Subject: [PATCH 6/9] Switch all data loaders to declarative --- src/js/dataLayer/declarative.js | 12 ++ src/js/plugins/pluginService.js | 46 ++++--- tests/unit/specs/api.test.js | 11 ++ .../unit/specs/dataLayer/declarative.test.js | 63 ++++++++++ .../unit/specs/plugins/pluginService.test.js | 119 +++++++++++++++--- 5 files changed, 218 insertions(+), 33 deletions(-) diff --git a/src/js/dataLayer/declarative.js b/src/js/dataLayer/declarative.js index dea96bbd..ab92da42 100644 --- a/src/js/dataLayer/declarative.js +++ b/src/js/dataLayer/declarative.js @@ -28,6 +28,18 @@ export class Declarative { return profile; } + async ensureKnownFollowers(profileDid) { + let knownFollowers = this.derived.$knownFollowers.get(profileDid); + if (!knownFollowers) { + await this.requests.loadKnownFollowers(profileDid); + knownFollowers = this.derived.$knownFollowers.get(profileDid); + } + if (!knownFollowers) { + throw new Error("Known followers not found"); + } + return knownFollowers; + } + async ensureProfileFollows(profileDid) { let profileFollows = this.derived.$profileFollows.get(profileDid); if (!profileFollows) { diff --git a/src/js/plugins/pluginService.js b/src/js/plugins/pluginService.js index 5cd15b7b..6dae2682 100644 --- a/src/js/plugins/pluginService.js +++ b/src/js/plugins/pluginService.js @@ -386,32 +386,39 @@ export class PluginService extends ReactiveStore { return pluginFetch(plugin, url, init); }); - this.pluginBridge.addHostMethod("getPost", (plugin, { uri }) => { - return this._dataLayer?.derived.$hydratedPosts.get(uri) ?? null; + this.pluginBridge.addHostMethod("getPost", async (plugin, { uri }) => { + if (!this._dataLayer) return null; + try { + return await this._dataLayer.declarative.ensurePost(uri); + } catch { + return null; + } }); - this.pluginBridge.addHostMethod("getProfile", (plugin, { did }) => { - return this._dataLayer?.derived.$hydratedProfiles.get(did) ?? null; + this.pluginBridge.addHostMethod("getProfile", async (plugin, { did }) => { + if (!this._dataLayer) return null; + const profile = this._dataLayer.derived.$hydratedProfiles.get(did); + if (profile) return profile; + try { + await this._dataLayer.declarative.ensureDetailedProfile(did); + } catch { + return null; + } + return this._dataLayer.derived.$hydratedProfiles.get(did) ?? null; }); this.pluginBridge.addHostMethod( "getDetailedProfile", async (plugin, { did }) => { if (!this._dataLayer) return null; - let profile = - this._dataLayer.derived.$hydratedDetailedProfiles.get(did); - if (!profile) { - await this._dataLayer.requests.loadDetailedProfile(did); - profile = this._dataLayer.derived.$hydratedDetailedProfiles.get(did); + try { + return await this._dataLayer.declarative.ensureDetailedProfile(did); + } catch { + return null; } - return profile ?? null; }, ); - this.pluginBridge.addHostMethod("getRecord", (plugin, args) => - this.slingshot.getRecord(args), - ); - // Full known-followers list for did (the profile.viewer.knownFollowers // included on getProfile/getDetailedProfile is capped to a handful). // The AppView doesn't currently paginate this endpoint in practice, so @@ -420,11 +427,18 @@ export class PluginService extends ReactiveStore { "getKnownFollowers", async (plugin, { did }) => { if (!this._dataLayer) return null; - await this._dataLayer.requests.loadKnownFollowers(did); - return this._dataLayer.derived.$knownFollowers.get(did) ?? null; + try { + return await this._dataLayer.declarative.ensureKnownFollowers(did); + } catch { + return null; + } }, ); + this.pluginBridge.addHostMethod("getRecord", (plugin, args) => + this.slingshot.getRecord(args), + ); + this.pluginBridge.addHostMethod("getCurrentUser", () => { if (!this.session) return null; return { diff --git a/tests/unit/specs/api.test.js b/tests/unit/specs/api.test.js index a79a636c..b9215aef 100644 --- a/tests/unit/specs/api.test.js +++ b/tests/unit/specs/api.test.js @@ -786,6 +786,17 @@ describe("sendInteractions", () => { "did:web:feed.example.com#feed_proxy", ); }); + + it("should reject when no feed proxy url is given", async () => { + const session = createMockSession({}); + const api = new Api(session); + + await assert.rejects( + api.sendInteractions([{ uri: "post1", event: "view" }], null), + /requires a feedProxyUrl/, + ); + assert.deepEqual(session.getLastFetchOptions(), null); + }); }); describe("getAuthorFeed", () => { diff --git a/tests/unit/specs/dataLayer/declarative.test.js b/tests/unit/specs/dataLayer/declarative.test.js index 6c0289db..7b9b9ab2 100644 --- a/tests/unit/specs/dataLayer/declarative.test.js +++ b/tests/unit/specs/dataLayer/declarative.test.js @@ -10,6 +10,7 @@ function createMockDerived(data = {}) { return { $currentUser: sig(() => data.currentUser ?? null), $hydratedDetailedProfiles: mapSig((did) => data.profiles?.[did] ?? null), + $knownFollowers: mapSig((did) => data.knownFollowers?.[did] ?? null), $hydratedPostThreads: mapSig((uri) => data.postThreads?.[uri] ?? null), $hydratedPosts: mapSig((uri) => data.posts?.[uri] ?? null), $feedGenerators: mapSig((uri) => data.feedGenerators?.[uri] ?? null), @@ -25,6 +26,7 @@ function createMockRequests(loadResults = {}) { loadCurrentUser: async () => loadResults.currentUser, loadDetailedProfile: async (did) => loadResults.profiles?.[did], loadDetailedProfiles: async () => {}, + loadKnownFollowers: async () => {}, loadPostThread: async (uri) => loadResults.postThreads?.[uri], loadPost: async (uri) => loadResults.posts?.[uri], loadPosts: async () => {}, @@ -153,6 +155,67 @@ describe("ensureDetailedProfile", () => { }); }); +describe("ensureKnownFollowers", () => { + it("should return existing known followers without loading", async () => { + const profileDid = "did:test:profile"; + const knownFollowers = { followers: [{ did: "did:test:follower" }] }; + let loadCalled = false; + + const derived = createMockDerived({ + knownFollowers: { [profileDid]: knownFollowers }, + }); + const requests = { + loadKnownFollowers: async () => { + loadCalled = true; + }, + }; + + const declarative = new Declarative(derived, requests); + const result = await declarative.ensureKnownFollowers(profileDid); + + assert.deepEqual(result, knownFollowers); + assert.deepEqual(loadCalled, false); + }); + + it("should load known followers when not in cache", async () => { + const profileDid = "did:test:profile"; + const knownFollowers = { followers: [{ did: "did:test:follower" }] }; + let callCount = 0; + + const derived = { + $knownFollowers: mapSig(() => { + callCount++; + return callCount > 1 ? knownFollowers : null; + }), + }; + const requests = { + loadKnownFollowers: async () => {}, + }; + + const declarative = new Declarative(derived, requests); + const result = await declarative.ensureKnownFollowers(profileDid); + + assert.deepEqual(result, knownFollowers); + }); + + it("should throw when known followers not found after loading", async () => { + const derived = createMockDerived({}); + const requests = createMockRequests({}); + + const declarative = new Declarative(derived, requests); + + let error = null; + try { + await declarative.ensureKnownFollowers("did:nonexistent"); + } catch (e) { + error = e; + } + + assert(error !== null); + assert.deepEqual(error.message, "Known followers not found"); + }); +}); + describe("ensureDetailedProfiles", () => { it("returns cached profiles in input order without loading", async () => { const profileA = { did: "did:test:a", handle: "a.test" }; diff --git a/tests/unit/specs/plugins/pluginService.test.js b/tests/unit/specs/plugins/pluginService.test.js index bb11c6ed..8a10750f 100644 --- a/tests/unit/specs/plugins/pluginService.test.js +++ b/tests/unit/specs/plugins/pluginService.test.js @@ -1301,59 +1301,144 @@ describe("app.data host methods", () => { return { map, calls }; } - it("getPost host method returns the hydrated post from derived", async () => { + it("getProfile host method returns the hydrated profile from derived", async () => { const service = makeServiceWithRealBridge(); - const posts = makeStubComputedMap((uri) => ({ - uri, - record: { text: "cached" }, + const profiles = makeStubComputedMap((did) => ({ + did, + handle: "alice.test", })); service.setDataLayer({ derived: { - $hydratedPosts: posts.map, + $hydratedPosts: makeStubComputedMap(() => null).map, + $hydratedProfiles: profiles.map, + }, + }); + const handler = service.pluginBridge._hostCallHandlers.get("getProfile"); + const result = await handler(null, { did: "did:plc:abc" }); + assert.deepEqual(profiles.calls, ["did:plc:abc"]); + assert.deepEqual(result, { did: "did:plc:abc", handle: "alice.test" }); + }); + + it("getPost returns null when dataLayer has not been set", async () => { + const service = makeServiceWithRealBridge(); + const handler = service.pluginBridge._hostCallHandlers.get("getPost"); + const result = await handler(null, { uri: "at://example" }); + assert.deepEqual(result, null); + }); + + it("getPost fetches the post on a cache miss", async () => { + const service = makeServiceWithRealBridge(); + const ensureCalls = []; + service.setDataLayer({ + derived: { + $hydratedPosts: makeStubComputedMap(() => null).map, $hydratedProfiles: makeStubComputedMap(() => null).map, }, + declarative: { + ensurePost: async (uri) => { + ensureCalls.push(uri); + return { uri, record: { text: "fetched" } }; + }, + }, }); const handler = service.pluginBridge._hostCallHandlers.get("getPost"); const result = await handler(null, { uri: "at://example/post/1" }); - assert.deepEqual(posts.calls, ["at://example/post/1"]); + assert.deepEqual(ensureCalls, ["at://example/post/1"]); assert.deepEqual(result, { uri: "at://example/post/1", - record: { text: "cached" }, + record: { text: "fetched" }, }); }); - it("getProfile host method returns the hydrated profile from derived", async () => { + it("getPost returns null when the post cannot be loaded", async () => { const service = makeServiceWithRealBridge(); - const profiles = makeStubComputedMap((did) => ({ - did, - handle: "alice.test", - })); + service.setDataLayer({ + derived: { + $hydratedPosts: makeStubComputedMap(() => null).map, + $hydratedProfiles: makeStubComputedMap(() => null).map, + }, + declarative: { + ensurePost: async () => { + throw new Error("Post not found"); + }, + }, + }); + const handler = service.pluginBridge._hostCallHandlers.get("getPost"); + const result = await handler(null, { uri: "at://example/post/gone" }); + assert.deepEqual(result, null); + }); + + it("getProfile fetches on a cache miss and returns the basic hydrated profile", async () => { + const service = makeServiceWithRealBridge(); + let loaded = false; + const profiles = makeStubComputedMap((did) => + loaded ? { did, handle: "alice.test" } : null, + ); + const ensureCalls = []; service.setDataLayer({ derived: { $hydratedPosts: makeStubComputedMap(() => null).map, $hydratedProfiles: profiles.map, }, + declarative: { + ensureDetailedProfile: async (did) => { + ensureCalls.push(did); + loaded = true; + }, + }, }); const handler = service.pluginBridge._hostCallHandlers.get("getProfile"); const result = await handler(null, { did: "did:plc:abc" }); - assert.deepEqual(profiles.calls, ["did:plc:abc"]); + assert.deepEqual(ensureCalls, ["did:plc:abc"]); assert.deepEqual(result, { did: "did:plc:abc", handle: "alice.test" }); }); - it("getPost returns null when dataLayer has not been set", async () => { + it("getKnownFollowers resolves via the declarative layer", async () => { const service = makeServiceWithRealBridge(); - const handler = service.pluginBridge._hostCallHandlers.get("getPost"); - const result = await handler(null, { uri: "at://example" }); + const knownFollowers = { followers: [{ did: "did:plc:follower" }] }; + const ensureCalls = []; + service.setDataLayer({ + declarative: { + ensureKnownFollowers: async (did) => { + ensureCalls.push(did); + return knownFollowers; + }, + }, + }); + const handler = + service.pluginBridge._hostCallHandlers.get("getKnownFollowers"); + const result = await handler(null, { did: "did:plc:abc" }); + assert.deepEqual(ensureCalls, ["did:plc:abc"]); + assert.deepEqual(result, knownFollowers); + }); + + it("getKnownFollowers returns null when the list cannot be loaded", async () => { + const service = makeServiceWithRealBridge(); + service.setDataLayer({ + declarative: { + ensureKnownFollowers: async () => { + throw new Error("Known followers not found"); + }, + }, + }); + const handler = + service.pluginBridge._hostCallHandlers.get("getKnownFollowers"); + const result = await handler(null, { did: "did:plc:missing" }); assert.deepEqual(result, null); }); - it("getProfile returns null when the hydrated profile signal is empty", async () => { + it("getProfile returns null when the profile cannot be loaded", async () => { const service = makeServiceWithRealBridge(); service.setDataLayer({ derived: { $hydratedPosts: makeStubComputedMap(() => null).map, $hydratedProfiles: makeStubComputedMap(() => null).map, }, + declarative: { + ensureDetailedProfile: async () => { + throw new Error("Profile not found"); + }, + }, }); const handler = service.pluginBridge._hostCallHandlers.get("getProfile"); const result = await handler(null, { did: "did:plc:missing" }); -- 2.51.2 From 07e3311ac3137e2a8143ce03241c6850eba8f2ed Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Mon, 20 Jul 2026 20:04:58 -0500 Subject: [PATCH 7/9] Make permission namespacing more generic, add unblock / unmute --- impro-plugin/main.js | 60 +++--- src/js/plugins/pluginModal.js | 13 +- src/js/plugins/pluginPermissions.js | 24 +-- src/js/plugins/pluginService.js | 66 ++----- .../specs/plugins/pluginPermissions.test.js | 34 ++++ .../unit/specs/plugins/pluginService.test.js | 183 +++++++++--------- tests/unit/specs/plugins/pluginWorker.test.js | 42 ++-- 7 files changed, 209 insertions(+), 213 deletions(-) diff --git a/impro-plugin/main.js b/impro-plugin/main.js index 19fa3334..be749daa 100644 --- a/impro-plugin/main.js +++ b/impro-plugin/main.js @@ -163,44 +163,10 @@ class PluginData { } } -// Moderation actions on behalf of the signed-in user. Each method requires -// the corresponding scope ("mute", "block", "feedback") to be declared in -// the plugin manifest's `permissions.moderation` array, which the user must -// grant at install time. -class PluginModeration { - muteActor(did, mute = true) { - return hostCall("muteActor", { did, mute }); - } - blockActor(did, block = true) { - return hostCall("blockActor", { did, block }); - } - // Acts like the user clicking "Show less like this": sends the requestLess - // feedback signal to the feed that served the post and collapses the post - // behind a feedback message in feeds. - showLessLikeThis(postUri, feedUri) { - return hostCall("showLessLikeThis", { postUri, feedUri }); - } - showMoreLikeThis(postUri, feedUri) { - return hostCall("showMoreLikeThis", { postUri, feedUri }); - } - // Low-level app.bsky.feed.sendInteractions with no UI side effects. event - // must be an allowed app.bsky.feed.defs#interaction value (e.g. - // "app.bsky.feed.defs#interactionSeen") - sendInteraction(postUri, event, feedProxyUrl, { feedContext = null } = {}) { - return hostCall("sendInteraction", { - postUri, - event, - feedProxyUrl, - feedContext, - }); - } -} - class App { constructor() { this.currentUser = null; this.data = new PluginData(); - this.moderation = new PluginModeration(); } on(event, listener) { addEventListener(event, listener); @@ -209,6 +175,32 @@ class App { refreshFeedFilters(feedURI = null) { return hostCall("refreshFeedFilters", feedURI); } + + // Actions on behalf of the signed-in user. Each method requires the + // corresponding scope ("mute", "block", "feedFeedback") to be declared in + // the plugin manifest's `permissions.actions` array, which the user must + // grant at install time. + muteActor(did) { + return hostCall("muteActor", { did, mute: true }); + } + unmuteActor(did) { + return hostCall("muteActor", { did, mute: false }); + } + blockActor(did) { + return hostCall("blockActor", { did, block: true }); + } + unblockActor(did) { + return hostCall("blockActor", { did, block: false }); + } + // Acts like the user clicking "Show less like this": sends the requestLess + // feedback signal to the feed that served the post and collapses the post + // behind a feedback message in feeds. + showLessLikeThis(postUri, feedUri) { + return hostCall("showLessLikeThis", { postUri, feedUri }); + } + showMoreLikeThis(postUri, feedUri) { + return hostCall("showMoreLikeThis", { postUri, feedUri }); + } } export async function fetch(url, init = {}) { diff --git a/src/js/plugins/pluginModal.js b/src/js/plugins/pluginModal.js index 59730141..373506c8 100644 --- a/src/js/plugins/pluginModal.js +++ b/src/js/plugins/pluginModal.js @@ -71,10 +71,10 @@ export function hidePluginModal({ pluginId, modalId }) { } } -const MODERATION_ACTION_LABELS = { +const ACTION_LABELS = { mute: "Mute and unmute accounts on your behalf", block: "Block and unblock accounts on your behalf", - feedback: + feedFeedback: 'Send feed feedback (e.g. "show fewer/more like this") on your behalf', }; @@ -93,14 +93,13 @@ function permissionsListTemplate({ permissions }) {
`); } - const moderationScopes = permissions.moderation ?? []; - if (moderationScopes.length > 0) { + const actionScopes = permissions.actions ?? []; + if (actionScopes.length > 0) { sections.push(html`
    - ${moderationScopes.map( - (scope) => - html`
  • ${MODERATION_ACTION_LABELS[scope] ?? scope}
  • `, + ${actionScopes.map( + (scope) => html`
  • ${ACTION_LABELS[scope] ?? scope}
  • `, )}
diff --git a/src/js/plugins/pluginPermissions.js b/src/js/plugins/pluginPermissions.js index 7a6e864c..7c9b8e94 100644 --- a/src/js/plugins/pluginPermissions.js +++ b/src/js/plugins/pluginPermissions.js @@ -1,6 +1,6 @@ import { unique } from "/js/utils.js"; -const MODERATION_ACTIONS = ["mute", "block", "feedback"]; +const ACTION_SCOPES = ["mute", "block", "feedFeedback"]; export function getPermissionsFromManifest(manifest) { return parsePermissions(manifest.permissions ?? {}); @@ -17,22 +17,22 @@ export function parsePermissions(permissions) { ); if (fetchPatterns.length > 0) parsed.fetch = fetchPatterns; } - if (permissions.moderation) { - const moderationArray = Array.isArray(permissions.moderation) - ? permissions.moderation - : [permissions.moderation]; - const moderationScopes = unique( - moderationArray.filter((entry) => MODERATION_ACTIONS.includes(entry)), + if (permissions.actions) { + const actionsArray = Array.isArray(permissions.actions) + ? permissions.actions + : [permissions.actions]; + const actionScopes = unique( + actionsArray.filter((entry) => ACTION_SCOPES.includes(entry)), ); - if (moderationScopes.length > 0) parsed.moderation = moderationScopes; + if (actionScopes.length > 0) parsed.actions = actionScopes; } return parsed; } -// action is one of "mute", "block", "feedback" (the "show fewer/more like -// this" feed-interaction signal) -export function isModerationActionAllowed(action, permissions) { - return (permissions.moderation ?? []).includes(action); +// action is one of "mute", "block", "feedFeedback" (the "show fewer/more +// like this" feed-interaction signal) +export function isActionAllowed(action, permissions) { + return (permissions.actions ?? []).includes(action); } export function diffPermissions(current, next) { diff --git a/src/js/plugins/pluginService.js b/src/js/plugins/pluginService.js index 6dae2682..176f293e 100644 --- a/src/js/plugins/pluginService.js +++ b/src/js/plugins/pluginService.js @@ -21,7 +21,7 @@ import { getPermissionsFromManifest, diffPermissions, isEmptyPermissions, - isModerationActionAllowed, + isActionAllowed, } from "/js/plugins/pluginPermissions.js"; import { compareVersions, groupBy, isDev, sortBy } from "/js/utils.js"; import { @@ -41,18 +41,6 @@ function requireHostMethodArg(method, name, value) { } } -// The app.bsky.feed.defs#interaction events plugins may send. Mirrors -// social-app's third-party feed policy for now - can expand later if needed -const FEED_INTERACTION_EVENTS = new Set([ - "app.bsky.feed.defs#requestLess", - "app.bsky.feed.defs#requestMore", - "app.bsky.feed.defs#interactionSeen", - "app.bsky.feed.defs#interactionLike", - "app.bsky.feed.defs#interactionRepost", - "app.bsky.feed.defs#interactionReply", - "app.bsky.feed.defs#interactionQuote", - "app.bsky.feed.defs#interactionShare", -]); export const PLUGIN_PREVIEW_QUERY_PARAM = "plugin-preview"; export function arePluginsDisabledByQueryParam() { @@ -450,7 +438,8 @@ export class PluginService extends ReactiveStore { this.pluginBridge.addHostMethod( "muteActor", async (plugin, { did, mute = true }) => { - this._requireModerationPermission(plugin, "mute"); + this._requireSignedIn(); + this._requireActionPermission(plugin, "mute"); requireHostMethodArg("muteActor", "did", did); const profile = this._resolveProfileForMutation(did); if (mute) await this._dataLayer.mutations.muteProfile(profile); @@ -461,7 +450,8 @@ export class PluginService extends ReactiveStore { this.pluginBridge.addHostMethod( "blockActor", async (plugin, { did, block = true }) => { - this._requireModerationPermission(plugin, "block"); + this._requireSignedIn(); + this._requireActionPermission(plugin, "block"); requireHostMethodArg("blockActor", "did", did); const profile = this._resolveProfileForMutation(did); if (block) await this._dataLayer.mutations.blockProfile(profile); @@ -472,7 +462,8 @@ export class PluginService extends ReactiveStore { this.pluginBridge.addHostMethod( "showLessLikeThis", async (plugin, { postUri, feedUri = null }) => { - this._requireModerationPermission(plugin, "feedback"); + this._requireSignedIn(); + this._requireActionPermission(plugin, "feedFeedback"); requireHostMethodArg("showLessLikeThis", "postUri", postUri); requireHostMethodArg("showLessLikeThis", "feedUri", feedUri); const { feedContext, feedProxyUrl } = this._resolveFeedAttribution( @@ -491,7 +482,8 @@ export class PluginService extends ReactiveStore { this.pluginBridge.addHostMethod( "showMoreLikeThis", async (plugin, { postUri, feedUri = null }) => { - this._requireModerationPermission(plugin, "feedback"); + this._requireSignedIn(); + this._requireActionPermission(plugin, "feedFeedback"); requireHostMethodArg("showMoreLikeThis", "postUri", postUri); requireHostMethodArg("showMoreLikeThis", "feedUri", feedUri); const { feedContext, feedProxyUrl } = this._resolveFeedAttribution( @@ -506,37 +498,9 @@ export class PluginService extends ReactiveStore { ); }, ); - - this.pluginBridge.addHostMethod( - "sendInteraction", - async ( - plugin, - { postUri, event, feedProxyUrl = null, feedContext = null }, - ) => { - this._requireModerationPermission(plugin, "feedback"); - requireHostMethodArg("sendInteraction", "postUri", postUri); - requireHostMethodArg("sendInteraction", "feedProxyUrl", feedProxyUrl); - if (!FEED_INTERACTION_EVENTS.has(event)) { - throw new Error(`Unsupported feed interaction event "${event}"`); - } - await this._dataLayer.api.sendInteractions( - [ - { - item: postUri, - event, - ...(feedContext != null ? { feedContext } : {}), - }, - ], - feedProxyUrl, - ); - }, - ); } _resolveFeedAttribution(postUri, feedUri) { - if (!feedUri || !this._dataLayer) { - return { feedContext: null, feedProxyUrl: null }; - } const feed = this._dataLayer.dataStore.$feeds.get(feedUri); const feedItem = feed?.feed.find((item) => item.post.uri === postUri); const feedGenerator = this._dataLayer.derived.$feedGenerators.get(feedUri); @@ -546,17 +510,19 @@ export class PluginService extends ReactiveStore { }; } - _requireModerationPermission(plugin, action) { - if (!isModerationActionAllowed(action, plugin.permissions)) { + _requireSignedIn() { + if (!this._dataLayer) throw new Error("Not signed in"); + } + + _requireActionPermission(plugin, action) { + if (!isActionAllowed(action, plugin.permissions)) { throw new Error( - `"${plugin.pluginId}" does not have "${action}" moderation permission`, + `"${plugin.pluginId}" does not have "${action}" action permission`, ); } - if (!this._dataLayer) throw new Error("Not signed in"); } _resolveProfileForMutation(did) { - if (!this._dataLayer) return { did }; return ( this._dataLayer.derived.$hydratedDetailedProfiles.get(did) ?? this._dataLayer.derived.$hydratedProfiles.get(did) ?? { did } diff --git a/tests/unit/specs/plugins/pluginPermissions.test.js b/tests/unit/specs/plugins/pluginPermissions.test.js index 89e13095..d7223142 100644 --- a/tests/unit/specs/plugins/pluginPermissions.test.js +++ b/tests/unit/specs/plugins/pluginPermissions.test.js @@ -5,6 +5,7 @@ import { diffPermissions, isEmptyPermissions, isFetchAllowed, + isActionAllowed, } from "/js/plugins/pluginPermissions.js"; describe("parsePermissions", () => { @@ -40,6 +41,39 @@ describe("parsePermissions", () => { { fetch: ["https://a.com/*", "https://b.com/*"] }, ); }); + + it("parses known action scopes and drops unknown ones", () => { + assert.deepEqual( + parsePermissions({ + actions: ["mute", "block", "feedFeedback", "deleteEverything"], + }), + { actions: ["mute", "block", "feedFeedback"] }, + ); + }); + + it("wraps a string actions value into an array", () => { + assert.deepEqual(parsePermissions({ actions: "mute" }), { + actions: ["mute"], + }); + }); + + it("omits the actions key when no valid scopes remain", () => { + assert.deepEqual(parsePermissions({ actions: [] }), {}); + assert.deepEqual(parsePermissions({ actions: ["feedback"] }), {}); + }); +}); + +describe("isActionAllowed", () => { + it("allows only granted action scopes", () => { + const permissions = { actions: ["mute", "feedFeedback"] }; + assert(isActionAllowed("mute", permissions)); + assert(isActionAllowed("feedFeedback", permissions)); + assert(!isActionAllowed("block", permissions)); + }); + + it("denies everything when the actions key is missing", () => { + assert(!isActionAllowed("mute", {})); + }); }); describe("diffPermissions", () => { diff --git a/tests/unit/specs/plugins/pluginService.test.js b/tests/unit/specs/plugins/pluginService.test.js index 8a10750f..57311bc2 100644 --- a/tests/unit/specs/plugins/pluginService.test.js +++ b/tests/unit/specs/plugins/pluginService.test.js @@ -1446,18 +1446,29 @@ describe("app.data host methods", () => { }); }); -describe("feed feedback host methods", () => { +describe("action host methods", () => { const feedbackPlugin = { pluginId: "test-plugin", - permissions: { moderation: ["feedback"] }, + permissions: { actions: ["feedFeedback"] }, }; const postUri = "at://did:plc:author/app.bsky.feed.post/1"; const feedUri = "at://did:plc:feedgen/app.bsky.feed.generator/cool-feed"; - function makeService({ feedItem = null, feedGenerator = null } = {}) { + function makeService({ + feedItem = null, + feedGenerator = null, + hydratedProfiles = {}, + } = {}) { const { provider } = makeProvider(); const service = new PluginService(provider, null); - const calls = { showLess: [], showMore: [], sendInteractions: [] }; + const calls = { + showLess: [], + showMore: [], + mute: [], + unmute: [], + block: [], + unblock: [], + }; service.setDataLayer({ dataStore: { $feeds: { @@ -1469,13 +1480,16 @@ describe("feed feedback host methods", () => { $feedGenerators: { get: (uri) => (uri === feedUri ? feedGenerator : null), }, + $hydratedDetailedProfiles: { get: () => null }, + $hydratedProfiles: { get: (did) => hydratedProfiles[did] ?? null }, }, mutations: { sendShowLessInteraction: async (...args) => calls.showLess.push(args), sendShowMoreInteraction: async (...args) => calls.showMore.push(args), - }, - api: { - sendInteractions: async (...args) => calls.sendInteractions.push(args), + muteProfile: async (profile) => calls.mute.push(profile), + unmuteProfile: async (profile) => calls.unmute.push(profile), + blockProfile: async (profile) => calls.block.push(profile), + unblockProfile: async (profile) => calls.unblock.push(profile), }, }); return { service, calls }; @@ -1513,7 +1527,7 @@ describe("feed feedback host methods", () => { assert.deepEqual(calls.showMore, []); }); - it("all three methods reject when postUri is missing", async () => { + it("both methods reject when postUri is missing", async () => { const { service, calls } = makeService(); await assert.rejects( getHandler(service, "showLessLikeThis")(feedbackPlugin, { feedUri }), @@ -1523,30 +1537,22 @@ describe("feed feedback host methods", () => { getHandler(service, "showMoreLikeThis")(feedbackPlugin, { feedUri }), /requires a postUri/, ); - await assert.rejects( - getHandler(service, "sendInteraction")(feedbackPlugin, { - event: "app.bsky.feed.defs#interactionSeen", - feedProxyUrl: "did:web:feed.example#bsky_fg", - }), - /requires a postUri/, - ); assert.deepEqual(calls.showLess, []); assert.deepEqual(calls.showMore, []); - assert.deepEqual(calls.sendInteractions, []); }); it("muteActor and blockActor reject when did is missing", async () => { const { service } = makeService(); await assert.rejects( getHandler(service, "muteActor")( - { pluginId: "test-plugin", permissions: { moderation: ["mute"] } }, + { pluginId: "test-plugin", permissions: { actions: ["mute"] } }, {}, ), /muteActor requires a did/, ); await assert.rejects( getHandler(service, "blockActor")( - { pluginId: "test-plugin", permissions: { moderation: ["block"] } }, + { pluginId: "test-plugin", permissions: { actions: ["block"] } }, {}, ), /blockActor requires a did/, @@ -1580,98 +1586,89 @@ describe("feed feedback host methods", () => { ]); }); - it("sendInteraction sends a known event with caller-supplied routing and context", async () => { + it("both methods require the feedFeedback action permission", async () => { const { service, calls } = makeService(); - await getHandler(service, "sendInteraction")(feedbackPlugin, { - postUri, - event: "app.bsky.feed.defs#interactionSeen", - feedProxyUrl: "did:web:feed.example#bsky_fg", - feedContext: "plugin-ctx", - }); - assert.deepEqual(calls.sendInteractions, [ - [ - [ - { - item: postUri, - event: "app.bsky.feed.defs#interactionSeen", - feedContext: "plugin-ctx", - }, - ], - "did:web:feed.example#bsky_fg", - ], - ]); + const noPermissionPlugin = { + pluginId: "test-plugin", + permissions: { actions: ["mute"] }, + }; + for (const name of ["showLessLikeThis", "showMoreLikeThis"]) { + await assert.rejects( + getHandler(service, name)(noPermissionPlugin, { postUri, feedUri }), + /"feedFeedback" action permission/, + ); + } + assert.deepEqual(calls.showLess, []); + assert.deepEqual(calls.showMore, []); }); - it("sendInteraction omits feedContext when the caller does not supply one", async () => { - const { service, calls } = makeService(); - await getHandler(service, "sendInteraction")(feedbackPlugin, { - postUri, - event: "app.bsky.feed.defs#interactionShare", - feedProxyUrl: "did:web:feed.example#bsky_fg", + it("muteActor routes the mute flag to muteProfile and unmuteProfile", async () => { + const did = "did:plc:target"; + const profile = { did, handle: "target.example" }; + const { service, calls } = makeService({ + hydratedProfiles: { [did]: profile }, }); - assert.deepEqual(calls.sendInteractions, [ - [ - [{ item: postUri, event: "app.bsky.feed.defs#interactionShare" }], - "did:web:feed.example#bsky_fg", - ], - ]); + const mutePlugin = { + pluginId: "test-plugin", + permissions: { actions: ["mute"] }, + }; + await getHandler(service, "muteActor")(mutePlugin, { did, mute: true }); + await getHandler(service, "muteActor")(mutePlugin, { did, mute: false }); + assert.deepEqual(calls.mute, [profile]); + assert.deepEqual(calls.unmute, [profile]); }); - it("sendInteraction rejects when feedProxyUrl is missing", async () => { + it("blockActor routes the block flag to blockProfile and unblockProfile", async () => { + const did = "did:plc:target"; const { service, calls } = makeService(); - await assert.rejects( - getHandler(service, "sendInteraction")(feedbackPlugin, { - postUri, - event: "app.bsky.feed.defs#interactionSeen", - }), - /requires a feedProxyUrl/, - ); - assert.deepEqual(calls.sendInteractions, []); + const blockPlugin = { + pluginId: "test-plugin", + permissions: { actions: ["block"] }, + }; + await getHandler(service, "blockActor")(blockPlugin, { did, block: true }); + await getHandler(service, "blockActor")(blockPlugin, { did, block: false }); + assert.deepEqual(calls.block, [{ did }]); + assert.deepEqual(calls.unblock, [{ did }]); }); - it("sendInteraction rejects unknown and disallowed events", async () => { + it("muteActor and blockActor require their action permissions", async () => { const { service, calls } = makeService(); - for (const event of [ - "app.bsky.feed.defs#madeUp", - "app.bsky.feed.defs#clickthroughItem", - "app.bsky.feed.defs#clickthroughAuthor", - "app.bsky.feed.defs#clickthroughReposter", - "app.bsky.feed.defs#clickthroughEmbed", - ]) { - await assert.rejects( - getHandler(service, "sendInteraction")(feedbackPlugin, { - postUri, - event, - feedProxyUrl: "did:web:feed.example#bsky_fg", - }), - /Unsupported feed interaction event/, - ); - } - assert.deepEqual(calls.sendInteractions, []); + const noPermissionPlugin = { + pluginId: "test-plugin", + permissions: { actions: ["feedFeedback"] }, + }; + const did = "did:plc:target"; + await assert.rejects( + getHandler(service, "muteActor")(noPermissionPlugin, { did }), + /"mute" action permission/, + ); + await assert.rejects( + getHandler(service, "blockActor")(noPermissionPlugin, { did }), + /"block" action permission/, + ); + assert.deepEqual(calls.mute, []); + assert.deepEqual(calls.block, []); }); - it("all three methods require the feedback moderation permission", async () => { - const { service, calls } = makeService(); - const noPermissionPlugin = { + it("all action methods reject when signed out", async () => { + const { provider } = makeProvider(); + const service = new PluginService(provider, null); + const allActionsPlugin = { pluginId: "test-plugin", - permissions: { moderation: ["mute"] }, + permissions: { actions: ["mute", "block", "feedFeedback"] }, }; - for (const name of [ - "showLessLikeThis", - "showMoreLikeThis", - "sendInteraction", - ]) { + const argsByMethod = { + muteActor: { did: "did:plc:target" }, + blockActor: { did: "did:plc:target" }, + showLessLikeThis: { postUri, feedUri }, + showMoreLikeThis: { postUri, feedUri }, + }; + for (const [name, args] of Object.entries(argsByMethod)) { await assert.rejects( - getHandler(service, name)(noPermissionPlugin, { - postUri, - event: "app.bsky.feed.defs#interactionSeen", - }), - /"feedback" moderation permission/, + getHandler(service, name)(allActionsPlugin, args), + /Not signed in/, ); } - assert.deepEqual(calls.showLess, []); - assert.deepEqual(calls.showMore, []); - assert.deepEqual(calls.sendInteractions, []); }); }); diff --git a/tests/unit/specs/plugins/pluginWorker.test.js b/tests/unit/specs/plugins/pluginWorker.test.js index 9ea3d1e4..f6a83353 100644 --- a/tests/unit/specs/plugins/pluginWorker.test.js +++ b/tests/unit/specs/plugins/pluginWorker.test.js @@ -335,33 +335,41 @@ describe("hostCall round-trip", () => { assert.deepEqual(sent.args[0], "at://example/feed"); }); - it("app.moderation feedback methods post hostCalls with postUri and feedUri", async () => { + it("app mute and block methods post hostCalls with the direction flag", async () => { + clearMessages(); + const plugin = new Plugin(); + const did = "did:plc:target"; + + plugin.app.muteActor(did); + assert.deepEqual(lastMessage().method, "muteActor"); + assert.deepEqual(lastMessage().args[0], { did, mute: true }); + + plugin.app.unmuteActor(did); + assert.deepEqual(lastMessage().method, "muteActor"); + assert.deepEqual(lastMessage().args[0], { did, mute: false }); + + plugin.app.blockActor(did); + assert.deepEqual(lastMessage().method, "blockActor"); + assert.deepEqual(lastMessage().args[0], { did, block: true }); + + plugin.app.unblockActor(did); + assert.deepEqual(lastMessage().method, "blockActor"); + assert.deepEqual(lastMessage().args[0], { did, block: false }); + }); + + it("app feedback methods post hostCalls with postUri and feedUri", async () => { clearMessages(); const plugin = new Plugin(); const postUri = "at://example/post/1"; const feedUri = "at://example/feed/cool"; - plugin.app.moderation.showLessLikeThis(postUri, feedUri); + plugin.app.showLessLikeThis(postUri, feedUri); assert.deepEqual(lastMessage().method, "showLessLikeThis"); assert.deepEqual(lastMessage().args[0], { postUri, feedUri }); - plugin.app.moderation.showMoreLikeThis(postUri, feedUri); + plugin.app.showMoreLikeThis(postUri, feedUri); assert.deepEqual(lastMessage().method, "showMoreLikeThis"); assert.deepEqual(lastMessage().args[0], { postUri, feedUri }); - - plugin.app.moderation.sendInteraction( - postUri, - "app.bsky.feed.defs#interactionSeen", - "did:web:feed.example#bsky_fg", - { feedContext: "ctx" }, - ); - assert.deepEqual(lastMessage().method, "sendInteraction"); - assert.deepEqual(lastMessage().args[0], { - postUri, - event: "app.bsky.feed.defs#interactionSeen", - feedProxyUrl: "did:web:feed.example#bsky_fg", - feedContext: "ctx", - }); }); it("app.data.getPost posts a hostCall and resolves with the host result", async () => { -- 2.51.2 From 53ec41b7e17f15e6646be6714ce4cb332a34a87e Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Mon, 20 Jul 2026 20:09:09 -0500 Subject: [PATCH 8/9] Update doc --- plugins.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins.md b/plugins.md index 17335fcf..8f677081 100644 --- a/plugins.md +++ b/plugins.md @@ -38,8 +38,8 @@ Plugins are currently in **beta** as the API surface is being expanded. However, - Add custom feed filters - Transform rich text in posts - Make whitelisted network requests (requires permissions) -- Read a detailed profile (including whether the viewer follows/is followed by the account, and known/mutual followers) via `app.data.getDetailedProfile(did)` -- Mute, block, or send feed feedback ("show more/less like this") on the user's behalf via `app.moderation`, gated behind explicit `permissions.moderation` scopes (`mute`, `block`, `feedback`) the user must grant at install time +- Read appview data with the current user as the viewer (profiles, posts, etc.) +- Mute, block, or send feed feedback ("show more/less like this") on the user's behalf ### Plugins CANNOT: -- 2.51.2 From 323e1dd0e4516774e7f746f04f01aac798bced23 Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Mon, 20 Jul 2026 20:16:31 -0500 Subject: [PATCH 9/9] Update doc --- plugins.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins.md b/plugins.md index 8f677081..ef733bd3 100644 --- a/plugins.md +++ b/plugins.md @@ -39,7 +39,7 @@ Plugins are currently in **beta** as the API surface is being expanded. However, - Transform rich text in posts - Make whitelisted network requests (requires permissions) - Read appview data with the current user as the viewer (profiles, posts, etc.) -- Mute, block, or send feed feedback ("show more/less like this") on the user's behalf +- Mute, block, or send feed feedback ("show more/less like this") on the user's behalf (requires permissions) ### Plugins CANNOT: