- ${notificationProfileNamesTemplate({ notificationGroup })}
+ ${notificationProfileNamesTemplate({
+ notificationGroup,
+ title: isRepost ? "Reposted your repost" : "Reposted your post",
+ })}
${isRepost ? "reposted your repost" : "reposted your post"}
· ${timeAgo}
`);
}
+ const moderationScopes = permissions.moderation ?? [];
+ if (moderationScopes.length > 0) {
+ sections.push(html`
+
+ `);
+ }
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: