[READ-ONLY] Mirror of https://github.com/improsocial/impro An extensible Bluesky client for web impro.social
6

Configure Feed

Select the types of activity you want to include in your feed.

Update plugin moderation api and key feedback by feed uri

Grace Kind (Jul 20, 2026, 6:44 PM -0500) b6275583 100e85f2

+541 -44
+17 -12
impro-plugin/main.js
··· 149 149 } 150 150 // Like getProfile, but includes viewer relationship details not present 151 151 // on the basic profile view: viewer.following, viewer.followedBy, and 152 - // viewer.knownFollowers (a summary of mutual followers). Requires no 153 - // extra permission, but does a network round-trip if not already cached. 152 + // viewer.knownFollowers (a summary of mutual followers). 154 153 getDetailedProfile(did) { 155 154 return hostCall("getDetailedProfile", { did }); 156 155 } ··· 175 174 blockActor(did, block = true) { 176 175 return hostCall("blockActor", { did, block }); 177 176 } 178 - // feedContext/feedProxyUrl are optional and let the signal be attributed 179 - // back to the specific feed generator that served the post, matching 180 - // app.bsky.feed.sendInteractions semantics; omit them to just send a 181 - // general requestLess signal. 182 - sendShowLessInteraction( 183 - postUri, 184 - { feedContext = null, feedProxyUrl = null } = {}, 185 - ) { 186 - return hostCall("sendShowLessInteraction", { 177 + // Acts like the user clicking "Show less like this": sends the requestLess 178 + // feedback signal to the feed that served the post and collapses the post 179 + // behind a feedback message in feeds. 180 + showLessLikeThis(postUri, feedUri) { 181 + return hostCall("showLessLikeThis", { postUri, feedUri }); 182 + } 183 + showMoreLikeThis(postUri, feedUri) { 184 + return hostCall("showMoreLikeThis", { postUri, feedUri }); 185 + } 186 + // Low-level app.bsky.feed.sendInteractions with no UI side effects. event 187 + // must be an allowed app.bsky.feed.defs#interaction value (e.g. 188 + // "app.bsky.feed.defs#interactionSeen") 189 + sendInteraction(postUri, event, feedProxyUrl, { feedContext = null } = {}) { 190 + return hostCall("sendInteraction", { 187 191 postUri, 192 + event, 193 + feedProxyUrl, 188 194 feedContext, 189 - feedProxyUrl, 190 195 }); 191 196 } 192 197 }
+1 -1
impro-plugin/package.json
··· 1 1 { 2 2 "name": "@impro.social/impro-plugin", 3 - "version": "0.0.11", 3 + "version": "0.0.12", 4 4 "type": "module", 5 5 "main": "main.js", 6 6 "license": "0BSD",
+2 -2
src/js/dataLayer/dataStore.js
··· 11 11 this.$chatRecipientSearchResults = new Signal.State(null); 12 12 this.$searchTypeaheadResults = new Signal.State(null); 13 13 this.$feedSearchResults = new Signal.State(null); 14 - this.$showLessInteractions = new Signal.State([]); 15 - this.$showMoreInteractions = new Signal.State([]); 16 14 this.$notifications = new Signal.State(null); 17 15 this.$mentionNotifications = new Signal.State(null); 18 16 this.$pinnedItems = new Signal.State(null); ··· 31 29 this.$latestPostSearchRequestTimeTop = new Signal.State(null); 32 30 this.$latestPostSearchRequestTimeLatest = new Signal.State(null); 33 31 // Keyed signals 32 + this.$showLessInteractions = new SignalMap(); 33 + this.$showMoreInteractions = new SignalMap(); 34 34 this.$feeds = new SignalMap(); 35 35 this.$posts = new SignalMap(); 36 36 this.$postThreads = new SignalMap();
+2 -2
src/js/dataLayer/derived.js
··· 120 120 this.pluginService = pluginService; 121 121 this.isAuthenticated = isAuthenticated; 122 122 this.draftMediaStore = draftMediaStore; 123 - this.$showLessInteractions = new Signal.Computed(() => 124 - this.dataStore.$showLessInteractions.get(), 123 + this.$showLessInteractions = new ComputedMap( 124 + (feedUri) => this.dataStore.$showLessInteractions.get(feedUri) ?? [], 125 125 ); 126 126 this.$hydratedPosts = new ComputedMap((uri) => { 127 127 const post = this.patchStore.$patchedPosts.get(uri);
+12 -6
src/js/dataLayer/mutations.js
··· 329 329 } 330 330 } 331 331 332 - async sendShowLessInteraction(postURI, feedContext, feedProxyUrl) { 332 + async sendShowLessInteraction(postURI, feedUri, feedContext, feedProxyUrl) { 333 333 const showLessInteraction = { 334 334 item: postURI, 335 335 event: "app.bsky.feed.defs#requestLess", 336 336 ...(feedContext != null ? { feedContext } : {}), 337 337 }; 338 - this.dataStore.$showLessInteractions.set([ 339 - ...this.dataStore.$showLessInteractions.get(), 338 + this.dataStore.$showLessInteractions.set(feedUri, [ 339 + ...(this.dataStore.$showLessInteractions.get(feedUri) ?? []), 340 340 showLessInteraction, 341 341 ]); 342 + if (feedProxyUrl == null) { 343 + return; 344 + } 342 345 try { 343 346 await this.api.sendInteractions([showLessInteraction], feedProxyUrl); 344 347 } catch (error) { ··· 347 350 } 348 351 } 349 352 350 - async sendShowMoreInteraction(postURI, feedContext, feedProxyUrl) { 353 + async sendShowMoreInteraction(postURI, feedUri, feedContext, feedProxyUrl) { 351 354 const showMoreInteraction = { 352 355 item: postURI, 353 356 event: "app.bsky.feed.defs#requestMore", ··· 355 358 }; 356 359 // Note, we don't really need to store this interaction because we don't use it in the UI (yet). 357 360 // But, let's do it anyway for consistency. 358 - this.dataStore.$showMoreInteractions.set([ 359 - ...this.dataStore.$showMoreInteractions.get(), 361 + this.dataStore.$showMoreInteractions.set(feedUri, [ 362 + ...(this.dataStore.$showMoreInteractions.get(feedUri) ?? []), 360 363 showMoreInteraction, 361 364 ]); 365 + if (feedProxyUrl == null) { 366 + return; 367 + } 362 368 try { 363 369 await this.api.sendInteractions([showMoreInteraction], feedProxyUrl); 364 370 } catch (error) {
+88 -2
src/js/plugins/pluginService.js
··· 31 31 import { Signal, SignalMap, SignalSet, ReactiveStore } from "/js/signals.js"; 32 32 import { EventEmitter } from "/js/eventEmitter.js"; 33 33 import { PLUGIN_REGISTRY_URL } from "/js/config.js"; 34 + import { getFeedGeneratorProxyUrl } from "/js/dataHelpers.js"; 34 35 35 36 const DISABLE_PLUGINS_QUERY_PARAM = "disable-plugins"; 37 + 38 + function requireHostMethodArg(method, name, value) { 39 + if (!value) { 40 + throw new Error(`${method} requires a ${name}`); 41 + } 42 + } 43 + 44 + // The app.bsky.feed.defs#interaction events plugins may send. Mirrors 45 + // social-app's third-party feed policy for now - can expand later if needed 46 + const FEED_INTERACTION_EVENTS = new Set([ 47 + "app.bsky.feed.defs#requestLess", 48 + "app.bsky.feed.defs#requestMore", 49 + "app.bsky.feed.defs#interactionSeen", 50 + "app.bsky.feed.defs#interactionLike", 51 + "app.bsky.feed.defs#interactionRepost", 52 + "app.bsky.feed.defs#interactionReply", 53 + "app.bsky.feed.defs#interactionQuote", 54 + "app.bsky.feed.defs#interactionShare", 55 + ]); 36 56 export const PLUGIN_PREVIEW_QUERY_PARAM = "plugin-preview"; 37 57 38 58 export function arePluginsDisabledByQueryParam() { ··· 417 437 "muteActor", 418 438 async (plugin, { did, mute = true }) => { 419 439 this._requireModerationPermission(plugin, "mute"); 440 + requireHostMethodArg("muteActor", "did", did); 420 441 const profile = this._resolveProfileForMutation(did); 421 442 if (mute) await this._dataLayer.mutations.muteProfile(profile); 422 443 else await this._dataLayer.mutations.unmuteProfile(profile); ··· 427 448 "blockActor", 428 449 async (plugin, { did, block = true }) => { 429 450 this._requireModerationPermission(plugin, "block"); 451 + requireHostMethodArg("blockActor", "did", did); 430 452 const profile = this._resolveProfileForMutation(did); 431 453 if (block) await this._dataLayer.mutations.blockProfile(profile); 432 454 else await this._dataLayer.mutations.unblockProfile(profile); ··· 434 456 ); 435 457 436 458 this.pluginBridge.addHostMethod( 437 - "sendShowLessInteraction", 438 - async (plugin, { postUri, feedContext = null, feedProxyUrl = null }) => { 459 + "showLessLikeThis", 460 + async (plugin, { postUri, feedUri = null }) => { 439 461 this._requireModerationPermission(plugin, "feedback"); 462 + requireHostMethodArg("showLessLikeThis", "postUri", postUri); 463 + requireHostMethodArg("showLessLikeThis", "feedUri", feedUri); 464 + const { feedContext, feedProxyUrl } = this._resolveFeedAttribution( 465 + postUri, 466 + feedUri, 467 + ); 440 468 await this._dataLayer.mutations.sendShowLessInteraction( 441 469 postUri, 470 + feedUri, 442 471 feedContext, 443 472 feedProxyUrl, 444 473 ); 445 474 }, 446 475 ); 476 + 477 + this.pluginBridge.addHostMethod( 478 + "showMoreLikeThis", 479 + async (plugin, { postUri, feedUri = null }) => { 480 + this._requireModerationPermission(plugin, "feedback"); 481 + requireHostMethodArg("showMoreLikeThis", "postUri", postUri); 482 + requireHostMethodArg("showMoreLikeThis", "feedUri", feedUri); 483 + const { feedContext, feedProxyUrl } = this._resolveFeedAttribution( 484 + postUri, 485 + feedUri, 486 + ); 487 + await this._dataLayer.mutations.sendShowMoreInteraction( 488 + postUri, 489 + feedUri, 490 + feedContext, 491 + feedProxyUrl, 492 + ); 493 + }, 494 + ); 495 + 496 + this.pluginBridge.addHostMethod( 497 + "sendInteraction", 498 + async ( 499 + plugin, 500 + { postUri, event, feedProxyUrl = null, feedContext = null }, 501 + ) => { 502 + this._requireModerationPermission(plugin, "feedback"); 503 + requireHostMethodArg("sendInteraction", "postUri", postUri); 504 + requireHostMethodArg("sendInteraction", "feedProxyUrl", feedProxyUrl); 505 + if (!FEED_INTERACTION_EVENTS.has(event)) { 506 + throw new Error(`Unsupported feed interaction event "${event}"`); 507 + } 508 + await this._dataLayer.api.sendInteractions( 509 + [ 510 + { 511 + item: postUri, 512 + event, 513 + ...(feedContext != null ? { feedContext } : {}), 514 + }, 515 + ], 516 + feedProxyUrl, 517 + ); 518 + }, 519 + ); 520 + } 521 + 522 + _resolveFeedAttribution(postUri, feedUri) { 523 + if (!feedUri || !this._dataLayer) { 524 + return { feedContext: null, feedProxyUrl: null }; 525 + } 526 + const feed = this._dataLayer.dataStore.$feeds.get(feedUri); 527 + const feedItem = feed?.feed.find((item) => item.post.uri === postUri); 528 + const feedGenerator = this._dataLayer.derived.$feedGenerators.get(feedUri); 529 + return { 530 + feedContext: feedItem?.feedContext ?? null, 531 + feedProxyUrl: getFeedGeneratorProxyUrl(feedGenerator), 532 + }; 447 533 } 448 534 449 535 _requireModerationPermission(plugin, action) {
+1
src/js/templates/postActionBar.template.js
··· 233 233 const pluginItems = await props.pluginService.getPostContextMenuItems( 234 234 props.post, 235 235 { 236 + feedGenerator: props.feedGenerator ?? null, 236 237 feedContext: props.feedContext ?? null, 237 238 feedProxyUrl: getFeedGeneratorProxyUrl(props.feedGenerator), 238 239 },
+1 -1
src/js/views/feedDetail.view.js
··· 42 42 43 43 pageEffect(root, () => { 44 44 const showLessInteractions = 45 - dataLayer.derived.$showLessInteractions.get() ?? []; 45 + dataLayer.derived.$showLessInteractions.get(feedUri); 46 46 const hiddenPostUris = showLessInteractions.map( 47 47 (interaction) => interaction.item, 48 48 );
+5 -5
src/js/views/home.view.js
··· 116 116 async function handleShowLess(post, feedContext, feedGenerator) { 117 117 dataLayer.mutations.sendShowLessInteraction( 118 118 post.uri, 119 + feedGenerator.uri, 119 120 feedContext, 120 121 getFeedGeneratorProxyUrl(feedGenerator), 121 122 ); ··· 131 132 async function handleShowMore(post, feedContext, feedGenerator) { 132 133 dataLayer.mutations.sendShowMoreInteraction( 133 134 post.uri, 135 + feedGenerator.uri, 134 136 feedContext, 135 137 getFeedGeneratorProxyUrl(feedGenerator), 136 138 ); ··· 204 206 }); 205 207 206 208 pageEffect(root, () => { 207 - const showLessInteractions = 208 - dataLayer.derived.$showLessInteractions.get() ?? []; 209 - const hiddenPostUris = showLessInteractions.map( 210 - (interaction) => interaction.item, 211 - ); 212 209 const currentUser = dataLayer.derived.$currentUser.get(); 213 210 const pinnedItems = dataLayer.derived.$hydratedPinnedItems.get() ?? []; 214 211 // Map of feed items -> feedContexts for postSeenObserver ··· 262 259 ${pinnedItems.map((item) => { 263 260 const acceptsInteractions = 264 261 item.acceptsInteractions || item.uri === LOGGED_OUT_FEED_URI; 262 + const hiddenPostUris = dataLayer.derived.$showLessInteractions 263 + .get(item.uri) 264 + .map((interaction) => interaction.item); 265 265 const feed = dataLayer.derived.$hydratedFeeds.get(item.uri); 266 266 const feedRequestStatus = 267 267 dataLayer.requests.statusStore.$statuses.get(
+1 -1
src/js/views/listDetail.view.js
··· 101 101 102 102 pageEffect(root, () => { 103 103 const showLessInteractions = 104 - dataLayer.derived.$showLessInteractions.get() ?? []; 104 + dataLayer.derived.$showLessInteractions.get(listUri); 105 105 const hiddenPostUris = showLessInteractions.map( 106 106 (interaction) => interaction.item, 107 107 );
+92 -12
tests/unit/specs/dataLayer/mutations.test.js
··· 3001 3001 3002 3002 describe("sendShowLessInteraction", () => { 3003 3003 const postURI = "at://did:plc:author/app.bsky.feed.post/1"; 3004 + const feedUri = "at://did:plc:feedgen/app.bsky.feed.generator/cool"; 3004 3005 const feedContext = "ctx"; 3005 3006 const feedProxyUrl = "https://feed.example/xrpc"; 3006 3007 ··· 3022 3023 mockPreferencesProvider, 3023 3024 ); 3024 3025 3025 - await mutations.sendShowLessInteraction(postURI, feedContext, feedProxyUrl); 3026 + await mutations.sendShowLessInteraction( 3027 + postURI, 3028 + feedUri, 3029 + feedContext, 3030 + feedProxyUrl, 3031 + ); 3026 3032 3027 - const stored = dataStore.$showLessInteractions.get(); 3033 + const stored = dataStore.$showLessInteractions.get(feedUri); 3028 3034 assert.deepEqual(stored.length, 1); 3029 3035 assert.deepEqual(stored[0].item, postURI); 3030 3036 assert.deepEqual(stored[0].event, "app.bsky.feed.defs#requestLess"); ··· 3040 3046 const mockPreferencesProvider = { 3041 3047 requirePreferences: () => Preferences.createLoggedOutPreferences(), 3042 3048 }; 3043 - dataStore.$showLessInteractions.set([{ item: "existing", event: "x" }]); 3049 + dataStore.$showLessInteractions.set(feedUri, [ 3050 + { item: "existing", event: "x" }, 3051 + ]); 3044 3052 const mutations = makeMutations( 3045 3053 { sendInteractions: async () => {} }, 3046 3054 dataStore, ··· 3048 3056 mockPreferencesProvider, 3049 3057 ); 3050 3058 3051 - await mutations.sendShowLessInteraction(postURI, feedContext, feedProxyUrl); 3059 + await mutations.sendShowLessInteraction( 3060 + postURI, 3061 + feedUri, 3062 + feedContext, 3063 + feedProxyUrl, 3064 + ); 3052 3065 3053 - const stored = dataStore.$showLessInteractions.get(); 3066 + const stored = dataStore.$showLessInteractions.get(feedUri); 3054 3067 assert.deepEqual(stored.length, 2); 3055 3068 assert.deepEqual(stored[1].item, postURI); 3056 3069 }); 3057 3070 3071 + it("should key stored interactions by feed", async () => { 3072 + const dataStore = new DataStore(); 3073 + const patchStore = new PatchStore(dataStore); 3074 + const mockPreferencesProvider = { 3075 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 3076 + }; 3077 + const mutations = makeMutations( 3078 + { sendInteractions: async () => {} }, 3079 + dataStore, 3080 + patchStore, 3081 + mockPreferencesProvider, 3082 + ); 3083 + const otherFeedUri = "at://did:plc:feedgen/app.bsky.feed.generator/other"; 3084 + 3085 + await mutations.sendShowLessInteraction( 3086 + postURI, 3087 + feedUri, 3088 + feedContext, 3089 + feedProxyUrl, 3090 + ); 3091 + 3092 + assert.deepEqual(dataStore.$showLessInteractions.get(feedUri).length, 1); 3093 + assert.deepEqual(dataStore.$showLessInteractions.get(otherFeedUri), null); 3094 + }); 3095 + 3058 3096 it("should omit feedContext when null but keep an empty string", async () => { 3059 3097 const dataStore = new DataStore(); 3060 3098 const patchStore = new PatchStore(dataStore); ··· 3073 3111 mockPreferencesProvider, 3074 3112 ); 3075 3113 3076 - await mutations.sendShowLessInteraction(postURI, null, feedProxyUrl); 3077 - await mutations.sendShowLessInteraction(postURI, "", feedProxyUrl); 3114 + await mutations.sendShowLessInteraction( 3115 + postURI, 3116 + feedUri, 3117 + null, 3118 + feedProxyUrl, 3119 + ); 3120 + await mutations.sendShowLessInteraction(postURI, feedUri, "", feedProxyUrl); 3078 3121 3079 3122 assert.deepEqual(sentInteractions, [ 3080 3123 { item: postURI, event: "app.bsky.feed.defs#requestLess" }, ··· 3085 3128 }, 3086 3129 ]); 3087 3130 }); 3131 + 3132 + it("should store but not send when there is no feed proxy url", async () => { 3133 + const dataStore = new DataStore(); 3134 + const patchStore = new PatchStore(dataStore); 3135 + const mockPreferencesProvider = { 3136 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 3137 + }; 3138 + const sentInteractions = []; 3139 + const mutations = makeMutations( 3140 + { 3141 + sendInteractions: async (interactions) => { 3142 + sentInteractions.push(...interactions); 3143 + }, 3144 + }, 3145 + dataStore, 3146 + patchStore, 3147 + mockPreferencesProvider, 3148 + ); 3149 + 3150 + await mutations.sendShowLessInteraction(postURI, feedUri, null, null); 3151 + 3152 + assert.deepEqual(sentInteractions, []); 3153 + assert.deepEqual(dataStore.$showLessInteractions.get(feedUri).length, 1); 3154 + }); 3088 3155 }); 3089 3156 3090 3157 describe("sendShowMoreInteraction", () => { 3091 3158 const postURI = "at://did:plc:author/app.bsky.feed.post/1"; 3159 + const feedUri = "at://did:plc:feedgen/app.bsky.feed.generator/cool"; 3092 3160 const feedContext = "ctx"; 3093 3161 const feedProxyUrl = "https://feed.example/xrpc"; 3094 3162 ··· 3110 3178 mockPreferencesProvider, 3111 3179 ); 3112 3180 3113 - await mutations.sendShowMoreInteraction(postURI, feedContext, feedProxyUrl); 3181 + await mutations.sendShowMoreInteraction( 3182 + postURI, 3183 + feedUri, 3184 + feedContext, 3185 + feedProxyUrl, 3186 + ); 3114 3187 3115 - const stored = dataStore.$showMoreInteractions.get(); 3188 + const stored = dataStore.$showMoreInteractions.get(feedUri); 3116 3189 assert.deepEqual(stored.length, 1); 3117 3190 assert.deepEqual(stored[0].item, postURI); 3118 3191 assert.deepEqual(stored[0].event, "app.bsky.feed.defs#requestMore"); ··· 3127 3200 const mockPreferencesProvider = { 3128 3201 requirePreferences: () => Preferences.createLoggedOutPreferences(), 3129 3202 }; 3130 - dataStore.$showMoreInteractions.set([{ item: "existing", event: "x" }]); 3203 + dataStore.$showMoreInteractions.set(feedUri, [ 3204 + { item: "existing", event: "x" }, 3205 + ]); 3131 3206 const mutations = makeMutations( 3132 3207 { sendInteractions: async () => {} }, 3133 3208 dataStore, ··· 3135 3210 mockPreferencesProvider, 3136 3211 ); 3137 3212 3138 - await mutations.sendShowMoreInteraction(postURI, feedContext, feedProxyUrl); 3213 + await mutations.sendShowMoreInteraction( 3214 + postURI, 3215 + feedUri, 3216 + feedContext, 3217 + feedProxyUrl, 3218 + ); 3139 3219 3140 - const stored = dataStore.$showMoreInteractions.get(); 3220 + const stored = dataStore.$showMoreInteractions.get(feedUri); 3141 3221 assert.deepEqual(stored.length, 2); 3142 3222 assert.deepEqual(stored[1].item, postURI); 3143 3223 });
+229
tests/unit/specs/plugins/pluginService.test.js
··· 1361 1361 }); 1362 1362 }); 1363 1363 1364 + describe("feed feedback host methods", () => { 1365 + const feedbackPlugin = { 1366 + pluginId: "test-plugin", 1367 + permissions: { moderation: ["feedback"] }, 1368 + }; 1369 + const postUri = "at://did:plc:author/app.bsky.feed.post/1"; 1370 + const feedUri = "at://did:plc:feedgen/app.bsky.feed.generator/cool-feed"; 1371 + 1372 + function makeService({ feedItem = null, feedGenerator = null } = {}) { 1373 + const { provider } = makeProvider(); 1374 + const service = new PluginService(provider, null); 1375 + const calls = { showLess: [], showMore: [], sendInteractions: [] }; 1376 + service.setDataLayer({ 1377 + dataStore: { 1378 + $feeds: { 1379 + get: (uri) => 1380 + uri === feedUri && feedItem ? { feed: [feedItem] } : null, 1381 + }, 1382 + }, 1383 + derived: { 1384 + $feedGenerators: { 1385 + get: (uri) => (uri === feedUri ? feedGenerator : null), 1386 + }, 1387 + }, 1388 + mutations: { 1389 + sendShowLessInteraction: async (...args) => calls.showLess.push(args), 1390 + sendShowMoreInteraction: async (...args) => calls.showMore.push(args), 1391 + }, 1392 + api: { 1393 + sendInteractions: async (...args) => calls.sendInteractions.push(args), 1394 + }, 1395 + }); 1396 + return { service, calls }; 1397 + } 1398 + 1399 + function getHandler(service, name) { 1400 + return service.pluginBridge._hostCallHandlers.get(name); 1401 + } 1402 + 1403 + it("showLessLikeThis resolves feedContext and proxy from the feed", async () => { 1404 + const { service, calls } = makeService({ 1405 + feedItem: { post: { uri: postUri }, feedContext: "ctx" }, 1406 + feedGenerator: { uri: feedUri, did: "did:web:feed.example" }, 1407 + }); 1408 + await getHandler(service, "showLessLikeThis")(feedbackPlugin, { 1409 + postUri, 1410 + feedUri, 1411 + }); 1412 + assert.deepEqual(calls.showLess, [ 1413 + [postUri, feedUri, "ctx", "did:web:feed.example#bsky_fg"], 1414 + ]); 1415 + }); 1416 + 1417 + it("showLessLikeThis and showMoreLikeThis reject when feedUri is missing", async () => { 1418 + const { service, calls } = makeService(); 1419 + await assert.rejects( 1420 + getHandler(service, "showLessLikeThis")(feedbackPlugin, { postUri }), 1421 + /requires a feedUri/, 1422 + ); 1423 + await assert.rejects( 1424 + getHandler(service, "showMoreLikeThis")(feedbackPlugin, { postUri }), 1425 + /requires a feedUri/, 1426 + ); 1427 + assert.deepEqual(calls.showLess, []); 1428 + assert.deepEqual(calls.showMore, []); 1429 + }); 1430 + 1431 + it("all three methods reject when postUri is missing", async () => { 1432 + const { service, calls } = makeService(); 1433 + await assert.rejects( 1434 + getHandler(service, "showLessLikeThis")(feedbackPlugin, { feedUri }), 1435 + /requires a postUri/, 1436 + ); 1437 + await assert.rejects( 1438 + getHandler(service, "showMoreLikeThis")(feedbackPlugin, { feedUri }), 1439 + /requires a postUri/, 1440 + ); 1441 + await assert.rejects( 1442 + getHandler(service, "sendInteraction")(feedbackPlugin, { 1443 + event: "app.bsky.feed.defs#interactionSeen", 1444 + feedProxyUrl: "did:web:feed.example#bsky_fg", 1445 + }), 1446 + /requires a postUri/, 1447 + ); 1448 + assert.deepEqual(calls.showLess, []); 1449 + assert.deepEqual(calls.showMore, []); 1450 + assert.deepEqual(calls.sendInteractions, []); 1451 + }); 1452 + 1453 + it("muteActor and blockActor reject when did is missing", async () => { 1454 + const { service } = makeService(); 1455 + await assert.rejects( 1456 + getHandler(service, "muteActor")( 1457 + { pluginId: "test-plugin", permissions: { moderation: ["mute"] } }, 1458 + {}, 1459 + ), 1460 + /muteActor requires a did/, 1461 + ); 1462 + await assert.rejects( 1463 + getHandler(service, "blockActor")( 1464 + { pluginId: "test-plugin", permissions: { moderation: ["block"] } }, 1465 + {}, 1466 + ), 1467 + /blockActor requires a did/, 1468 + ); 1469 + }); 1470 + 1471 + it("showLessLikeThis with an uncached feed still resolves the generator proxy", async () => { 1472 + const { service, calls } = makeService({ 1473 + feedGenerator: { uri: feedUri, did: "did:web:feed.example" }, 1474 + }); 1475 + await getHandler(service, "showLessLikeThis")(feedbackPlugin, { 1476 + postUri, 1477 + feedUri, 1478 + }); 1479 + assert.deepEqual(calls.showLess, [ 1480 + [postUri, feedUri, null, "did:web:feed.example#bsky_fg"], 1481 + ]); 1482 + }); 1483 + 1484 + it("showMoreLikeThis resolves attribution the same way", async () => { 1485 + const { service, calls } = makeService({ 1486 + feedItem: { post: { uri: postUri }, feedContext: "ctx" }, 1487 + feedGenerator: { uri: feedUri, did: "did:web:feed.example" }, 1488 + }); 1489 + await getHandler(service, "showMoreLikeThis")(feedbackPlugin, { 1490 + postUri, 1491 + feedUri, 1492 + }); 1493 + assert.deepEqual(calls.showMore, [ 1494 + [postUri, feedUri, "ctx", "did:web:feed.example#bsky_fg"], 1495 + ]); 1496 + }); 1497 + 1498 + it("sendInteraction sends a known event with caller-supplied routing and context", async () => { 1499 + const { service, calls } = makeService(); 1500 + await getHandler(service, "sendInteraction")(feedbackPlugin, { 1501 + postUri, 1502 + event: "app.bsky.feed.defs#interactionSeen", 1503 + feedProxyUrl: "did:web:feed.example#bsky_fg", 1504 + feedContext: "plugin-ctx", 1505 + }); 1506 + assert.deepEqual(calls.sendInteractions, [ 1507 + [ 1508 + [ 1509 + { 1510 + item: postUri, 1511 + event: "app.bsky.feed.defs#interactionSeen", 1512 + feedContext: "plugin-ctx", 1513 + }, 1514 + ], 1515 + "did:web:feed.example#bsky_fg", 1516 + ], 1517 + ]); 1518 + }); 1519 + 1520 + it("sendInteraction omits feedContext when the caller does not supply one", async () => { 1521 + const { service, calls } = makeService(); 1522 + await getHandler(service, "sendInteraction")(feedbackPlugin, { 1523 + postUri, 1524 + event: "app.bsky.feed.defs#interactionShare", 1525 + feedProxyUrl: "did:web:feed.example#bsky_fg", 1526 + }); 1527 + assert.deepEqual(calls.sendInteractions, [ 1528 + [ 1529 + [{ item: postUri, event: "app.bsky.feed.defs#interactionShare" }], 1530 + "did:web:feed.example#bsky_fg", 1531 + ], 1532 + ]); 1533 + }); 1534 + 1535 + it("sendInteraction rejects when feedProxyUrl is missing", async () => { 1536 + const { service, calls } = makeService(); 1537 + await assert.rejects( 1538 + getHandler(service, "sendInteraction")(feedbackPlugin, { 1539 + postUri, 1540 + event: "app.bsky.feed.defs#interactionSeen", 1541 + }), 1542 + /requires a feedProxyUrl/, 1543 + ); 1544 + assert.deepEqual(calls.sendInteractions, []); 1545 + }); 1546 + 1547 + it("sendInteraction rejects unknown and disallowed events", async () => { 1548 + const { service, calls } = makeService(); 1549 + for (const event of [ 1550 + "app.bsky.feed.defs#madeUp", 1551 + "app.bsky.feed.defs#clickthroughItem", 1552 + "app.bsky.feed.defs#clickthroughAuthor", 1553 + "app.bsky.feed.defs#clickthroughReposter", 1554 + "app.bsky.feed.defs#clickthroughEmbed", 1555 + ]) { 1556 + await assert.rejects( 1557 + getHandler(service, "sendInteraction")(feedbackPlugin, { 1558 + postUri, 1559 + event, 1560 + feedProxyUrl: "did:web:feed.example#bsky_fg", 1561 + }), 1562 + /Unsupported feed interaction event/, 1563 + ); 1564 + } 1565 + assert.deepEqual(calls.sendInteractions, []); 1566 + }); 1567 + 1568 + it("all three methods require the feedback moderation permission", async () => { 1569 + const { service, calls } = makeService(); 1570 + const noPermissionPlugin = { 1571 + pluginId: "test-plugin", 1572 + permissions: { moderation: ["mute"] }, 1573 + }; 1574 + for (const name of [ 1575 + "showLessLikeThis", 1576 + "showMoreLikeThis", 1577 + "sendInteraction", 1578 + ]) { 1579 + await assert.rejects( 1580 + getHandler(service, name)(noPermissionPlugin, { 1581 + postUri, 1582 + event: "app.bsky.feed.defs#interactionSeen", 1583 + }), 1584 + /"feedback" moderation permission/, 1585 + ); 1586 + } 1587 + assert.deepEqual(calls.showLess, []); 1588 + assert.deepEqual(calls.showMore, []); 1589 + assert.deepEqual(calls.sendInteractions, []); 1590 + }); 1591 + }); 1592 + 1364 1593 describe("getRecord host method", () => { 1365 1594 function makeServiceWithRealBridge() { 1366 1595 const { provider } = makeProvider();
+29
tests/unit/specs/plugins/pluginWorker.test.js
··· 335 335 assert.deepEqual(sent.args[0], "at://example/feed"); 336 336 }); 337 337 338 + it("app.moderation feedback methods post hostCalls with postUri and feedUri", async () => { 339 + clearMessages(); 340 + const plugin = new Plugin(); 341 + const postUri = "at://example/post/1"; 342 + const feedUri = "at://example/feed/cool"; 343 + 344 + plugin.app.moderation.showLessLikeThis(postUri, feedUri); 345 + assert.deepEqual(lastMessage().method, "showLessLikeThis"); 346 + assert.deepEqual(lastMessage().args[0], { postUri, feedUri }); 347 + 348 + plugin.app.moderation.showMoreLikeThis(postUri, feedUri); 349 + assert.deepEqual(lastMessage().method, "showMoreLikeThis"); 350 + assert.deepEqual(lastMessage().args[0], { postUri, feedUri }); 351 + 352 + plugin.app.moderation.sendInteraction( 353 + postUri, 354 + "app.bsky.feed.defs#interactionSeen", 355 + "did:web:feed.example#bsky_fg", 356 + { feedContext: "ctx" }, 357 + ); 358 + assert.deepEqual(lastMessage().method, "sendInteraction"); 359 + assert.deepEqual(lastMessage().args[0], { 360 + postUri, 361 + event: "app.bsky.feed.defs#interactionSeen", 362 + feedProxyUrl: "did:web:feed.example#bsky_fg", 363 + feedContext: "ctx", 364 + }); 365 + }); 366 + 338 367 it("app.data.getPost posts a hostCall and resolves with the host result", async () => { 339 368 clearMessages(); 340 369 const plugin = new Plugin();
+61
tests/unit/specs/templates/postActionBar.template.test.js
··· 410 410 return document.body.querySelector("context-menu.post-context-menu"); 411 411 } 412 412 413 + it("should pass the post and feed meta to getPostContextMenuItems", async () => { 414 + const menuCalls = []; 415 + const pluginService = { 416 + getPostContextMenuItems: async (...args) => { 417 + menuCalls.push(args); 418 + return []; 419 + }, 420 + }; 421 + const feedGenerator = { 422 + uri: "at://did:plc:feedgen/app.bsky.feed.generator/cool", 423 + did: "did:web:feed.example", 424 + }; 425 + const result = postActionBarTemplate({ 426 + post, 427 + isAuthenticated: true, 428 + currentUser: { did: "did:plc:test" }, 429 + feedContext: "ctx", 430 + feedGenerator, 431 + pluginService, 432 + }); 433 + const container = document.createElement("div"); 434 + document.body.appendChild(container); 435 + render(result, container); 436 + await openPostContextMenu(container); 437 + assert.deepEqual(menuCalls, [ 438 + [ 439 + post, 440 + { 441 + feedGenerator, 442 + feedContext: "ctx", 443 + feedProxyUrl: "did:web:feed.example#bsky_fg", 444 + }, 445 + ], 446 + ]); 447 + container.remove(); 448 + }); 449 + 450 + it("should pass null feed meta when the post is not in a feed", async () => { 451 + const menuCalls = []; 452 + const pluginService = { 453 + getPostContextMenuItems: async (...args) => { 454 + menuCalls.push(args); 455 + return []; 456 + }, 457 + }; 458 + const result = postActionBarTemplate({ 459 + post, 460 + isAuthenticated: true, 461 + currentUser: { did: "did:plc:test" }, 462 + pluginService, 463 + }); 464 + const container = document.createElement("div"); 465 + document.body.appendChild(container); 466 + render(result, container); 467 + await openPostContextMenu(container); 468 + assert.deepEqual(menuCalls, [ 469 + [post, { feedGenerator: null, feedContext: null, feedProxyUrl: null }], 470 + ]); 471 + container.remove(); 472 + }); 473 + 413 474 it("should render one context-menu-item-group per plugin", async () => { 414 475 const pluginService = makePluginService([ 415 476 { pluginId: "plugin-a", title: "A1", invoke: () => {} },