[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.

Merge pull request #16 from 77787777778777/moderation-plugin

Plugin API for profile relationships & moderation actions, plus attribution-link/notification fixes

authored by

kindgracekind and committed by
GitHub
(Jul 21, 2026, 11:57 AM -0500) 4d658840 142371cd

+1144 -105
+37
impro-plugin/main.js
··· 147 147 getProfile(did) { 148 148 return hostCall("getProfile", { did }); 149 149 } 150 + // Like getProfile, but includes viewer relationship details not present 151 + // on the basic profile view: viewer.following, viewer.followedBy, and 152 + // viewer.knownFollowers (a summary of mutual followers). 153 + getDetailedProfile(did) { 154 + return hostCall("getDetailedProfile", { did }); 155 + } 156 + // The full known-followers list for did (the summary on 157 + // getDetailedProfile's viewer.knownFollowers is capped to a handful). 158 + getKnownFollowers(did) { 159 + return hostCall("getKnownFollowers", { did }); 160 + } 150 161 getRecord(repo, collection, rkey) { 151 162 return hostCall("getRecord", { repo, collection, rkey }); 152 163 } ··· 163 174 164 175 refreshFeedFilters(feedURI = null) { 165 176 return hostCall("refreshFeedFilters", feedURI); 177 + } 178 + 179 + // Actions on behalf of the signed-in user. Each method requires the 180 + // corresponding scope ("mute", "block", "feedFeedback") to be declared in 181 + // the plugin manifest's `permissions.actions` array, which the user must 182 + // grant at install time. 183 + muteActor(did) { 184 + return hostCall("muteActor", { did, mute: true }); 185 + } 186 + unmuteActor(did) { 187 + return hostCall("muteActor", { did, mute: false }); 188 + } 189 + blockActor(did) { 190 + return hostCall("blockActor", { did, block: true }); 191 + } 192 + unblockActor(did) { 193 + return hostCall("blockActor", { did, block: false }); 194 + } 195 + // Acts like the user clicking "Show less like this": sends the requestLess 196 + // feedback signal to the feed that served the post and collapses the post 197 + // behind a feedback message in feeds. 198 + showLessLikeThis(postUri, feedUri) { 199 + return hostCall("showLessLikeThis", { postUri, feedUri }); 200 + } 201 + showMoreLikeThis(postUri, feedUri) { 202 + return hostCall("showMoreLikeThis", { postUri, feedUri }); 166 203 } 167 204 } 168 205
+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
plugins.md
··· 38 38 - Add custom feed filters 39 39 - Transform rich text in posts 40 40 - Make whitelisted network requests (requires permissions) 41 + - Read appview data with the current user as the viewer (profiles, posts, etc.) 42 + - Mute, block, or send feed feedback ("show more/less like this") on the user's behalf (requires permissions) 41 43 42 44 ### Plugins CANNOT: 43 45
+29
src/css/style.css
··· 1887 1887 gap: 4px; 1888 1888 } 1889 1889 1890 + @media (hover: hover) { 1891 + .small-post .repost-label a:hover, 1892 + .reply-to-author a:hover { 1893 + text-decoration: underline; 1894 + } 1895 + } 1896 + 1890 1897 .small-post .repost-label .icon { 1891 1898 height: 17px; 1892 1899 width: 17px; ··· 3328 3335 3329 3336 .notification-text strong { 3330 3337 font-weight: 600; 3338 + } 3339 + 3340 + .notification-others-button { 3341 + background: none; 3342 + border: none; 3343 + padding: 0; 3344 + margin: 0; 3345 + font: inherit; 3346 + font-weight: 600; 3347 + cursor: pointer; 3348 + } 3349 + 3350 + @media (hover: hover) { 3351 + .notification-others-button:hover { 3352 + text-decoration: underline; 3353 + } 3331 3354 } 3332 3355 3333 3356 .notification-time { ··· 10147 10170 padding-bottom: 16px; 10148 10171 padding-left: 24px; 10149 10172 } 10173 + } 10174 + 10175 + .bottom-sheet.profile-list-modal .profile-list-modal-body { 10176 + max-height: 60vh; 10177 + overflow-y: auto; 10178 + margin: 0 -24px; 10150 10179 } 10151 10180 10152 10181 .bottom-sheet.welcome-modal {
+5
src/js/api.js
··· 632 632 } 633 633 634 634 async sendInteractions(interactions, feedProxyUrl) { 635 + // Interactions are only useful to the feed generator that served the 636 + // posts, so callers must route to one. 637 + if (!feedProxyUrl) { 638 + throw new Error("sendInteractions requires a feedProxyUrl"); 639 + } 635 640 await this.request(`app.bsky.feed.sendInteractions`, { 636 641 method: "POST", 637 642 body: { interactions },
+11
src/js/dataHelpers.js
··· 51 51 return item.type === "timeline" ? FOLLOWING_FEED_URI : item.data.uri; 52 52 } 53 53 54 + // The atproto-proxy value that routes app.bsky.feed.sendInteractions (and 55 + // post-seen tracking) to a specific feed generator's own service, so 56 + // algorithmic feeds actually receive the signal instead of it silently 57 + // going nowhere useful. 58 + export function getFeedGeneratorProxyUrl(feedGenerator) { 59 + if (!feedGenerator?.did) { 60 + return null; 61 + } 62 + return `${feedGenerator.did}#bsky_fg`; 63 + } 64 + 54 65 export function getRKey(record) { 55 66 return record.uri.split("/").pop(); 56 67 }
+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.$embeddedPosts = new SignalMap();
+12
src/js/dataLayer/declarative.js
··· 28 28 return profile; 29 29 } 30 30 31 + async ensureKnownFollowers(profileDid) { 32 + let knownFollowers = this.derived.$knownFollowers.get(profileDid); 33 + if (!knownFollowers) { 34 + await this.requests.loadKnownFollowers(profileDid); 35 + knownFollowers = this.derived.$knownFollowers.get(profileDid); 36 + } 37 + if (!knownFollowers) { 38 + throw new Error("Known followers not found"); 39 + } 40 + return knownFollowers; 41 + } 42 + 31 43 async ensureProfileFollows(profileDid) { 32 44 let profileFollows = this.derived.$profileFollows.get(profileDid); 33 45 if (!profileFollows) {
+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);
+14 -8
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 - feedContext, 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", 354 - feedContext, 357 + ...(feedContext != null ? { feedContext } : {}), 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) {
+52
src/js/modals/profileList.modal.js
··· 1 + import { html } from "/js/lib/lit-html.js"; 2 + import { Modal } from "/js/modals/modal.js"; 3 + import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; 4 + 5 + class ProfileListModal extends Modal { 6 + get className() { 7 + return "bottom-sheet text-modal profile-list-modal"; 8 + } 9 + 10 + get attributes() { 11 + return { "data-testid": "profile-list-modal" }; 12 + } 13 + 14 + render({ 15 + props: { 16 + title, 17 + profiles, 18 + isAuthenticated, 19 + currentUserDid, 20 + profileInteractionHandler, 21 + }, 22 + }) { 23 + return html` 24 + <div class="modal-dialog-content"> 25 + ${title 26 + ? html`<h2 class="modal-dialog-title" data-testid="modal-title"> 27 + ${title} 28 + </h2>` 29 + : null} 30 + <div class="profile-list-modal-body"> 31 + ${profileFeedTemplate({ 32 + profiles, 33 + hasMore: false, 34 + skeletonCount: 0, 35 + emptyMessage: "No one to show", 36 + isAuthenticated, 37 + currentUserDid, 38 + profileInteractionHandler, 39 + })} 40 + </div> 41 + </div> 42 + `; 43 + } 44 + } 45 + 46 + // Shows a fixed, already-fetched list of profiles (e.g. everyone in a 47 + // notification group, or a full known-followers list) in a scrollable 48 + // modal. Unlike postLikes/postReposts, this doesn't paginate against the 49 + // network — pass the complete list you already have. 50 + export async function profileListModal(profiles, options = {}) { 51 + return ProfileListModal.open({ profiles, ...options }); 52 + }
+19
src/js/plugins/pluginModal.js
··· 106 106 } 107 107 } 108 108 109 + const ACTION_LABELS = { 110 + mute: "Mute and unmute accounts on your behalf", 111 + block: "Block and unblock accounts on your behalf", 112 + feedFeedback: 113 + 'Send feed feedback (e.g. "show fewer/more like this") on your behalf', 114 + }; 115 + 109 116 function permissionsListTemplate({ permissions }) { 110 117 const sections = []; 111 118 const fetchPatterns = permissions.fetch ?? []; ··· 116 123 <ul class="permission-prompt-list"> 117 124 ${fetchPatterns.map( 118 125 (pattern) => html`<li><code>${pattern}</code></li>`, 126 + )} 127 + </ul> 128 + </div> 129 + `); 130 + } 131 + const actionScopes = permissions.actions ?? []; 132 + if (actionScopes.length > 0) { 133 + sections.push(html` 134 + <div class="permission-prompt-section"> 135 + <ul class="permission-prompt-list"> 136 + ${actionScopes.map( 137 + (scope) => html`<li>${ACTION_LABELS[scope] ?? scope}</li>`, 119 138 )} 120 139 </ul> 121 140 </div>
+17
src/js/plugins/pluginPermissions.js
··· 1 1 import { unique } from "/js/utils.js"; 2 2 3 + const ACTION_SCOPES = ["mute", "block", "feedFeedback"]; 4 + 3 5 export function getPermissionsFromManifest(manifest) { 4 6 return parsePermissions(manifest.permissions ?? {}); 5 7 } ··· 15 17 ); 16 18 if (fetchPatterns.length > 0) parsed.fetch = fetchPatterns; 17 19 } 20 + if (permissions.actions) { 21 + const actionsArray = Array.isArray(permissions.actions) 22 + ? permissions.actions 23 + : [permissions.actions]; 24 + const actionScopes = unique( 25 + actionsArray.filter((entry) => ACTION_SCOPES.includes(entry)), 26 + ); 27 + if (actionScopes.length > 0) parsed.actions = actionScopes; 28 + } 18 29 return parsed; 30 + } 31 + 32 + // action is one of "mute", "block", "feedFeedback" (the "show fewer/more 33 + // like this" feed-interaction signal) 34 + export function isActionAllowed(action, permissions) { 35 + return (permissions.actions ?? []).includes(action); 19 36 } 20 37 21 38 export function diffPermissions(current, next) {
+152 -8
src/js/plugins/pluginService.js
··· 21 21 getPermissionsFromManifest, 22 22 diffPermissions, 23 23 isEmptyPermissions, 24 + isActionAllowed, 24 25 } from "/js/plugins/pluginPermissions.js"; 25 26 import { compareVersions, groupBy, isDev, sortBy } from "/js/utils.js"; 26 27 import { ··· 30 31 import { Signal, SignalMap, SignalSet, ReactiveStore } from "/js/signals.js"; 31 32 import { EventEmitter } from "/js/eventEmitter.js"; 32 33 import { PLUGIN_REGISTRY_URL } from "/js/config.js"; 34 + import { getFeedGeneratorProxyUrl } from "/js/dataHelpers.js"; 33 35 34 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 + 35 44 export const PLUGIN_PREVIEW_QUERY_PARAM = "plugin-preview"; 36 45 37 46 export function arePluginsDisabledByQueryParam() { ··· 373 382 return pluginFetch(plugin, url, init); 374 383 }); 375 384 376 - this.pluginBridge.addHostMethod("getPost", (plugin, { uri }) => { 377 - return this._dataLayer?.derived.$hydratedPosts.get(uri) ?? null; 385 + this.pluginBridge.addHostMethod("getPost", async (plugin, { uri }) => { 386 + if (!this._dataLayer) return null; 387 + try { 388 + return await this._dataLayer.declarative.ensurePost(uri); 389 + } catch { 390 + return null; 391 + } 378 392 }); 379 393 380 - this.pluginBridge.addHostMethod("getProfile", (plugin, { did }) => { 381 - return this._dataLayer?.derived.$hydratedProfiles.get(did) ?? null; 394 + this.pluginBridge.addHostMethod("getProfile", async (plugin, { did }) => { 395 + if (!this._dataLayer) return null; 396 + const profile = this._dataLayer.derived.$hydratedProfiles.get(did); 397 + if (profile) return profile; 398 + try { 399 + await this._dataLayer.declarative.ensureDetailedProfile(did); 400 + } catch { 401 + return null; 402 + } 403 + return this._dataLayer.derived.$hydratedProfiles.get(did) ?? null; 382 404 }); 383 405 406 + this.pluginBridge.addHostMethod( 407 + "getDetailedProfile", 408 + async (plugin, { did }) => { 409 + if (!this._dataLayer) return null; 410 + try { 411 + return await this._dataLayer.declarative.ensureDetailedProfile(did); 412 + } catch { 413 + return null; 414 + } 415 + }, 416 + ); 417 + 418 + // Full known-followers list for did (the profile.viewer.knownFollowers 419 + // included on getProfile/getDetailedProfile is capped to a handful). 420 + // The AppView doesn't currently paginate this endpoint in practice, so 421 + // this is a single round-trip, not a cursor loop. 422 + this.pluginBridge.addHostMethod( 423 + "getKnownFollowers", 424 + async (plugin, { did }) => { 425 + if (!this._dataLayer) return null; 426 + try { 427 + return await this._dataLayer.declarative.ensureKnownFollowers(did); 428 + } catch { 429 + return null; 430 + } 431 + }, 432 + ); 433 + 384 434 this.pluginBridge.addHostMethod("getRecord", (plugin, args) => 385 435 this.slingshot.getRecord(args), 386 436 ); ··· 392 442 handle: this.session.handle, 393 443 }; 394 444 }); 445 + 446 + this.pluginBridge.addHostMethod( 447 + "muteActor", 448 + async (plugin, { did, mute = true }) => { 449 + this._requireSignedIn(); 450 + this._requireActionPermission(plugin, "mute"); 451 + requireHostMethodArg("muteActor", "did", did); 452 + const profile = this._resolveProfileForMutation(did); 453 + if (mute) await this._dataLayer.mutations.muteProfile(profile); 454 + else await this._dataLayer.mutations.unmuteProfile(profile); 455 + }, 456 + ); 457 + 458 + this.pluginBridge.addHostMethod( 459 + "blockActor", 460 + async (plugin, { did, block = true }) => { 461 + this._requireSignedIn(); 462 + this._requireActionPermission(plugin, "block"); 463 + requireHostMethodArg("blockActor", "did", did); 464 + const profile = this._resolveProfileForMutation(did); 465 + if (block) await this._dataLayer.mutations.blockProfile(profile); 466 + else await this._dataLayer.mutations.unblockProfile(profile); 467 + }, 468 + ); 469 + 470 + this.pluginBridge.addHostMethod( 471 + "showLessLikeThis", 472 + async (plugin, { postUri, feedUri = null }) => { 473 + this._requireSignedIn(); 474 + this._requireActionPermission(plugin, "feedFeedback"); 475 + requireHostMethodArg("showLessLikeThis", "postUri", postUri); 476 + requireHostMethodArg("showLessLikeThis", "feedUri", feedUri); 477 + const { feedContext, feedProxyUrl } = this._resolveFeedAttribution( 478 + postUri, 479 + feedUri, 480 + ); 481 + await this._dataLayer.mutations.sendShowLessInteraction( 482 + postUri, 483 + feedUri, 484 + feedContext, 485 + feedProxyUrl, 486 + ); 487 + }, 488 + ); 489 + 490 + this.pluginBridge.addHostMethod( 491 + "showMoreLikeThis", 492 + async (plugin, { postUri, feedUri = null }) => { 493 + this._requireSignedIn(); 494 + this._requireActionPermission(plugin, "feedFeedback"); 495 + requireHostMethodArg("showMoreLikeThis", "postUri", postUri); 496 + requireHostMethodArg("showMoreLikeThis", "feedUri", feedUri); 497 + const { feedContext, feedProxyUrl } = this._resolveFeedAttribution( 498 + postUri, 499 + feedUri, 500 + ); 501 + await this._dataLayer.mutations.sendShowMoreInteraction( 502 + postUri, 503 + feedUri, 504 + feedContext, 505 + feedProxyUrl, 506 + ); 507 + }, 508 + ); 509 + } 510 + 511 + _resolveFeedAttribution(postUri, feedUri) { 512 + const feed = this._dataLayer.dataStore.$feeds.get(feedUri); 513 + const feedItem = feed?.feed.find((item) => item.post.uri === postUri); 514 + const feedGenerator = this._dataLayer.derived.$feedGenerators.get(feedUri); 515 + return { 516 + feedContext: feedItem?.feedContext ?? null, 517 + feedProxyUrl: getFeedGeneratorProxyUrl(feedGenerator), 518 + }; 519 + } 520 + 521 + _requireSignedIn() { 522 + if (!this._dataLayer) throw new Error("Not signed in"); 523 + } 524 + 525 + _requireActionPermission(plugin, action) { 526 + if (!isActionAllowed(action, plugin.permissions)) { 527 + throw new Error( 528 + `"${plugin.pluginId}" does not have "${action}" action permission`, 529 + ); 530 + } 531 + } 532 + 533 + _resolveProfileForMutation(did) { 534 + return ( 535 + this._dataLayer.derived.$hydratedDetailedProfiles.get(did) ?? 536 + this._dataLayer.derived.$hydratedProfiles.get(did) ?? { did } 537 + ); 395 538 } 396 539 397 540 async loadEnabledPlugins() { ··· 815 958 return this.$settingTabs.get(pluginId); 816 959 } 817 960 818 - async getPostContextMenuItems(post) { 819 - return this._collectContextMenuItems("post-context-menu", post); 961 + async getPostContextMenuItems(post, meta = null) { 962 + return this._collectContextMenuItems("post-context-menu", post, meta); 820 963 } 821 964 822 965 async getProfileContextMenuItems(profile) { ··· 861 1004 return { text, cursor }; 862 1005 } 863 1006 864 - async _collectContextMenuItems(event, target) { 1007 + async _collectContextMenuItems(event, target, meta = null) { 865 1008 const listeners = this.registries.eventListeners.get(event); 866 1009 if (!listeners || listeners.size === 0) return []; 867 1010 const results = await Promise.all( 868 1011 [...listeners].map(async ([pluginId, handler]) => { 869 1012 try { 870 - const items = await handler(target); 1013 + const items = 1014 + meta != null ? await handler(target, meta) : await handler(target); 871 1015 return (items ?? []).map((item) => ({ 872 1016 pluginId, 873 1017 icon: item.icon,
+1 -1
src/js/postSeenObserver.js
··· 115 115 await this.interactionsDispatch.sendInteraction({ 116 116 item: postUri, 117 117 event: "app.bsky.feed.defs#interactionSeen", 118 - feedContext, 118 + ...(feedContext != null ? { feedContext } : {}), 119 119 }); 120 120 this.seenPosts.add(postUri); 121 121 } catch (error) {
+17 -3
src/js/templates/postActionBar.template.js
··· 14 14 import { replyIconTemplate } from "/js/templates/icons/replyIcon.template.js"; 15 15 import { heartIconTemplate } from "/js/templates/icons/heartIcon.template.js"; 16 16 import { bookmarkIconTemplate } from "/js/templates/icons/bookmarkIcon.template.js"; 17 - import { getRKey, canReplyToPost } from "/js/dataHelpers.js"; 17 + import { 18 + getRKey, 19 + canReplyToPost, 20 + getFeedGeneratorProxyUrl, 21 + } from "/js/dataHelpers.js"; 18 22 import { richTextToString } from "/js/facetHelpers.js"; 19 23 import { SignInModal } from "/js/modals/signIn.modal.js"; 20 24 import "/js/components/context-menu.js"; ··· 53 57 isUserPost, 54 58 isPinnedToProfile, 55 59 enableFeedFeedback, 60 + feedContext, 56 61 pluginItems, 57 62 onClickShowMore, 58 63 onClickShowLess, ··· 118 123 <context-menu-item-group> 119 124 <context-menu-item 120 125 data-testid="menu-action-post-show-more" 121 - @click=${() => onClickShowMore(post)} 126 + @click=${() => onClickShowMore(post, feedContext)} 122 127 > 123 128 Show more like this 124 129 </context-menu-item> 125 130 <context-menu-item 126 131 data-testid="menu-action-post-show-less" 127 - @click=${() => onClickShowLess(post)} 132 + @click=${() => onClickShowLess(post, feedContext)} 128 133 > 129 134 Show less like this 130 135 </context-menu-item> ··· 227 232 async function openPostContextMenu(event, props) { 228 233 const pluginItems = await props.pluginService.getPostContextMenuItems( 229 234 props.post, 235 + { 236 + feedGenerator: props.feedGenerator ?? null, 237 + feedContext: props.feedContext ?? null, 238 + feedProxyUrl: getFeedGeneratorProxyUrl(props.feedGenerator), 239 + }, 230 240 ); 231 241 const menu = document.createElement("context-menu"); 232 242 menu.classList.add("post-context-menu"); ··· 245 255 isAuthenticated, 246 256 currentUser, 247 257 isUserPost, 258 + feedContext = null, 259 + feedGenerator = null, 248 260 onClickReply = noop, 249 261 onClickRepost = noop, 250 262 onClickQuotePost = noop, ··· 408 420 isUserPost, 409 421 isPinnedToProfile, 410 422 enableFeedFeedback, 423 + feedContext, 424 + feedGenerator, 411 425 pluginService, 412 426 onClickShowMore, 413 427 onClickShowLess,
+4 -1
src/js/templates/postFeed.template.js
··· 110 110 feedItem, 111 111 currentUser, 112 112 isAuthenticated, 113 + feedGenerator, 113 114 hiddenPostUris, 114 115 postInteractionHandler, 115 116 onClickShowLess, ··· 156 157 currentUser, 157 158 isAuthenticated, 158 159 isPinned, 160 + feedContext, 161 + feedGenerator, 159 162 hiddenPostUris, 160 163 isUserPost: currentUser?.did === post.author?.did, 161 164 replyContext: showReplyContext ? "reply" : null, ··· 223 226 const content = html`<div 224 227 class="feed-item" 225 228 data-testid="feed-item" 226 - data-feed-context="${feedItem.feedContext}" 227 229 data-post-uri="${feedItem.post.uri}" 228 230 data-feed-generator-uri="${feedGenerator?.uri ?? ""}" 229 231 > ··· 233 235 feedItem, 234 236 currentUser, 235 237 isAuthenticated, 238 + feedGenerator, 236 239 hiddenPostUris, 237 240 postInteractionHandler, 238 241 onClickShowLess,
+10 -3
src/js/templates/postHeaderText.template.js
··· 26 26 profile: author, 27 27 })}${automatedAccountBadgeTemplate({ profile: author })} 28 28 ${includeHandle 29 - ? html`<span class="post-username" data-testid="post-author-handle" 30 - >@${author.handle}</span 31 - >` 29 + ? enableProfileLink 30 + ? html`<a 31 + href="${linkToProfile(author)}" 32 + class="post-username" 33 + data-testid="post-author-handle" 34 + >@${author.handle}</a 35 + >` 36 + : html`<span class="post-username" data-testid="post-author-handle" 37 + >@${author.handle}</span 38 + >` 32 39 : ""} 33 40 ${includeTime 34 41 ? html`<span class="post-separator">·</span
+12 -3
src/js/templates/smallPost.template.js
··· 7 7 doHideAuthorOnUnauthenticated, 8 8 } from "/js/dataHelpers.js"; 9 9 import { noop } from "/js/utils.js"; 10 - import { linkToPost } from "/js/navigation.js"; 10 + import { linkToPost, linkToProfile } from "/js/navigation.js"; 11 11 import { avatarTemplate } from "/js/templates/avatar.template.js"; 12 12 import "/js/components/plugin-rich-text.js"; 13 13 import { postEmbedTemplate } from "/js/templates/postEmbed.template.js"; ··· 78 78 ignoreContentWarning = false, 79 79 ignoreMuteWarning = false, 80 80 isPinned = false, 81 + feedContext = null, 82 + feedGenerator = null, 81 83 onClickShowLess = noop, 82 84 onClickShowMore = noop, 83 85 enableFeedFeedback = false, ··· 129 131 ${repostIconTemplate()} 130 132 ${repostAuthor.did === currentUser?.did 131 133 ? "Reposted by you" 132 - : "Reposted by " + getDisplayName(repostAuthor)} 134 + : html`Reposted by 135 + <a href="${linkToProfile(repostAuthor)}" 136 + >${getDisplayName(repostAuthor)}</a 137 + >`} 133 138 </div>` 134 139 : ""} 135 140 ${postHeaderTextTemplate({ ··· 145 150 ${replyToAuthor 146 151 ? replyToAuthor.did === currentUser?.did 147 152 ? " you" 148 - : html` ${getDisplayName(replyToAuthor)}` 153 + : html` <a href="${linkToProfile(replyToAuthor)}" 154 + >${getDisplayName(replyToAuthor)}</a 155 + >` 149 156 : " user"} 150 157 </div>` 151 158 : ""} ··· 191 198 isUserPost, 192 199 isAuthenticated, 193 200 currentUser, 201 + feedContext, 202 + feedGenerator, 194 203 onClickReply: () => { 195 204 window.router.go(linkToPost(post)); 196 205 },
+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 );
+23 -16
src/js/views/home.view.js
··· 16 16 import { showToast } from "/js/toasts.js"; 17 17 import { Signal, ReactiveStore } from "/js/signals.js"; 18 18 import { WelcomeModal } from "/js/modals/welcome.modal.js"; 19 + import { getFeedGeneratorProxyUrl } from "/js/dataHelpers.js"; 19 20 20 21 class HomeView extends View { 21 22 async render({ ··· 74 75 }); 75 76 } 76 77 77 - function getProxyUrl(feedGenerator) { 78 - if (!feedGenerator.did) { 79 - return null; 80 - } 81 - return `${feedGenerator.did}#bsky_fg`; 82 - } 83 - 84 78 const postSeenObservers = new Map(); 85 79 86 80 // Initialize post seen observers for feeds with proxy URLs ··· 96 90 } 97 91 postSeenObservers.clear(); 98 92 for (const item of interactableItems) { 99 - const proxyUrl = getProxyUrl(item); 93 + const proxyUrl = getFeedGeneratorProxyUrl(item); 100 94 if (proxyUrl) { 101 95 postSeenObservers.set(item.uri, new PostSeenObserver(api, proxyUrl)); 102 96 } ··· 122 116 async function handleShowLess(post, feedContext, feedGenerator) { 123 117 dataLayer.mutations.sendShowLessInteraction( 124 118 post.uri, 119 + feedGenerator.uri, 125 120 feedContext, 126 - getProxyUrl(feedGenerator), 121 + getFeedGeneratorProxyUrl(feedGenerator), 127 122 ); 128 123 // Scroll to keep the feedback message in view (it might be hidden by the header, but that's okay) 129 124 const feedFeedbackMessageElement = document.querySelector( ··· 137 132 async function handleShowMore(post, feedContext, feedGenerator) { 138 133 dataLayer.mutations.sendShowMoreInteraction( 139 134 post.uri, 135 + feedGenerator.uri, 140 136 feedContext, 141 - getProxyUrl(feedGenerator), 137 + getFeedGeneratorProxyUrl(feedGenerator), 142 138 ); 143 139 showToast("Feedback sent to feed operator"); 144 140 } ··· 210 206 }); 211 207 212 208 pageEffect(root, () => { 213 - const showLessInteractions = 214 - dataLayer.derived.$showLessInteractions.get() ?? []; 215 - const hiddenPostUris = showLessInteractions.map( 216 - (interaction) => interaction.item, 217 - ); 218 209 const currentUser = dataLayer.derived.$currentUser.get(); 219 210 const pinnedItems = dataLayer.derived.$hydratedPinnedItems.get() ?? []; 211 + // Map of feed items -> feedContexts for postSeenObserver 212 + const feedContextsByFeedUri = new Map( 213 + pinnedItems.map((item) => [ 214 + item.uri, 215 + new Map( 216 + (dataLayer.derived.$hydratedFeeds.get(item.uri)?.feed ?? []).map( 217 + (feedItem) => [feedItem.post.uri, feedItem.feedContext ?? null], 218 + ), 219 + ), 220 + ]), 221 + ); 220 222 const currentFeedUri = state.$currentFeedUri.get(); 221 223 const currentFeedRequestStatus = 222 224 dataLayer.requests.statusStore.$statuses.get( ··· 257 259 ${pinnedItems.map((item) => { 258 260 const acceptsInteractions = 259 261 item.acceptsInteractions || item.uri === LOGGED_OUT_FEED_URI; 262 + const hiddenPostUris = dataLayer.derived.$showLessInteractions 263 + .get(item.uri) 264 + .map((interaction) => interaction.item); 260 265 const feed = dataLayer.derived.$hydratedFeeds.get(item.uri); 261 266 const feedRequestStatus = 262 267 dataLayer.requests.statusStore.$statuses.get( ··· 297 302 ); 298 303 const feedItems = document.querySelectorAll(".feed-item"); 299 304 feedItems.forEach((feedItem) => { 300 - const { feedGeneratorUri, feedContext, postUri } = feedItem.dataset; 305 + const { feedGeneratorUri, postUri } = feedItem.dataset; 301 306 if (feedGeneratorUri) { 302 307 const postSeenObserver = postSeenObservers.get(feedGeneratorUri); 303 308 if (postSeenObserver) { 309 + const feedContext = 310 + feedContextsByFeedUri.get(feedGeneratorUri)?.get(postUri) ?? null; 304 311 postSeenObserver.register(feedItem, postUri, feedContext); 305 312 } 306 313 }
+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 );
+55 -16
src/js/views/notifications.view.js
··· 28 28 import { notificationsIconTemplate } from "/js/templates/icons/notificationsIcon.template.js"; 29 29 import { verifiedCheckIconTemplate } from "/js/templates/icons/verifiedCheckIcon.template.js"; 30 30 import { contactsIconTemplate } from "/js/templates/icons/contactsIcon.template.js"; 31 + import { profileListModal } from "/js/modals/profileList.modal.js"; 31 32 import "/js/components/tab-bar.js"; 32 33 import { NOTIFICATIONS_PAGE_SIZE } from "/js/config.js"; 33 34 import "/js/components/infinite-scroll-container.js"; ··· 242 243 `; 243 244 } 244 245 245 - function notificationProfileNamesTemplate({ notificationGroup }) { 246 + function notificationProfileNamesTemplate({ notificationGroup, title }) { 246 247 const { notifications } = notificationGroup; 247 248 const firstNotif = notifications[0]; 248 249 const displayName = getDisplayName(firstNotif.author); ··· 255 256 })}${otherCount > 0 256 257 ? html`<span> 257 258 and 258 - <strong 259 - >${otherCount} ${otherCount === 1 ? "other" : "others"}</strong 260 - ></span 259 + <button 260 + type="button" 261 + class="notification-others-button" 262 + data-testid="notification-others-button" 263 + @click=${(event) => { 264 + event.stopPropagation(); 265 + profileListModal( 266 + notifications.map((notif) => notif.author), 267 + { 268 + title, 269 + isAuthenticated, 270 + currentUserDid: dataLayer.derived.$currentUser.get()?.did, 271 + profileInteractionHandler: 272 + interactionHandlers.profileInteractionHandler, 273 + }, 274 + ); 275 + }} 276 + > 277 + ${otherCount} ${otherCount === 1 ? "other" : "others"} 278 + </button></span 261 279 >` 262 280 : ""} 263 281 </span>`; ··· 276 294 <div class="notification-content"> 277 295 ${notificationAvatarsTemplate({ notifications })} 278 296 <div class="notification-text"> 279 - ${notificationProfileNamesTemplate({ notificationGroup })} 297 + ${notificationProfileNamesTemplate({ 298 + notificationGroup, 299 + title: "Followed you", 300 + })} 280 301 ${notificationGroup.type === "follow-back" 281 302 ? "followed you back" 282 303 : "followed you"} ··· 337 358 <div class="notification-content"> 338 359 ${notificationAvatarsTemplate({ notifications })} 339 360 <div class="notification-text"> 340 - ${notificationProfileNamesTemplate({ notificationGroup })} liked 341 - ${isRepost ? "your repost" : "your post"} 361 + ${notificationProfileNamesTemplate({ 362 + notificationGroup, 363 + title: isRepost ? "Liked your repost" : "Liked your post", 364 + })} 365 + liked ${isRepost ? "your repost" : "your post"} 342 366 <span class="notification-time">· ${timeAgo}</span> 343 367 </div> 344 368 ${postPreviewTemplate({ post: likedPost })} ··· 366 390 <div class="notification-content"> 367 391 ${notificationAvatarsTemplate({ notifications })} 368 392 <div class="notification-text"> 369 - ${notificationProfileNamesTemplate({ notificationGroup })} 393 + ${notificationProfileNamesTemplate({ 394 + notificationGroup, 395 + title: isRepost ? "Reposted your repost" : "Reposted your post", 396 + })} 370 397 ${isRepost ? "reposted your repost" : "reposted your post"} 371 398 <span class="notification-time">· ${timeAgo}</span> 372 399 </div> ··· 419 446 <div class="notification-content"> 420 447 ${notificationAvatarsTemplate({ notifications })} 421 448 <div class="notification-text"> 422 - ${notificationProfileNamesTemplate({ notificationGroup })} liked 423 - your custom feed 449 + ${notificationProfileNamesTemplate({ 450 + notificationGroup, 451 + title: "Liked your custom feed", 452 + })} 453 + liked your custom feed 424 454 <span class="notification-time">· ${timeAgo}</span> 425 455 </div> 426 456 </div> ··· 448 478 <div class="notification-content"> 449 479 ${notificationAvatarsTemplate({ notifications })} 450 480 <div class="notification-text"> 451 - ${notificationProfileNamesTemplate({ notificationGroup })} signed 452 - up with your starter pack 481 + ${notificationProfileNamesTemplate({ 482 + notificationGroup, 483 + title: "Joined via your starter pack", 484 + })} 485 + signed up with your starter pack 453 486 <span class="notification-time">· ${timeAgo}</span> 454 487 </div> 455 488 </div> ··· 471 504 <div class="notification-content"> 472 505 ${notificationAvatarsTemplate({ notifications })} 473 506 <div class="notification-text"> 474 - ${notificationProfileNamesTemplate({ notificationGroup })} 507 + ${notificationProfileNamesTemplate({ 508 + notificationGroup, 509 + title: "Verified you", 510 + })} 475 511 verified you 476 512 <span class="notification-time">· ${timeAgo}</span> 477 513 </div> ··· 495 531 <div class="notification-content"> 496 532 ${notificationAvatarsTemplate({ notifications })} 497 533 <div class="notification-text"> 498 - ${notificationProfileNamesTemplate({ notificationGroup })} removed 499 - their ${otherCount > 0 ? "verifications" : "verification"} from 500 - your account 534 + ${notificationProfileNamesTemplate({ 535 + notificationGroup, 536 + title: "Removed verification", 537 + })} 538 + removed their ${otherCount > 0 ? "verifications" : "verification"} 539 + from your account 501 540 <span class="notification-time">· ${timeAgo}</span> 502 541 </div> 503 542 </div>
+11
tests/unit/specs/api.test.js
··· 786 786 "did:web:feed.example.com#feed_proxy", 787 787 ); 788 788 }); 789 + 790 + it("should reject when no feed proxy url is given", async () => { 791 + const session = createMockSession({}); 792 + const api = new Api(session); 793 + 794 + await assert.rejects( 795 + api.sendInteractions([{ uri: "post1", event: "view" }], null), 796 + /requires a feedProxyUrl/, 797 + ); 798 + assert.deepEqual(session.getLastFetchOptions(), null); 799 + }); 789 800 }); 790 801 791 802 describe("getAuthorFeed", () => {
+63
tests/unit/specs/dataLayer/declarative.test.js
··· 10 10 return { 11 11 $currentUser: sig(() => data.currentUser ?? null), 12 12 $hydratedDetailedProfiles: mapSig((did) => data.profiles?.[did] ?? null), 13 + $knownFollowers: mapSig((did) => data.knownFollowers?.[did] ?? null), 13 14 $hydratedPostThreads: mapSig((uri) => data.postThreads?.[uri] ?? null), 14 15 $hydratedPosts: mapSig((uri) => data.posts?.[uri] ?? null), 15 16 $feedGenerators: mapSig((uri) => data.feedGenerators?.[uri] ?? null), ··· 25 26 loadCurrentUser: async () => loadResults.currentUser, 26 27 loadDetailedProfile: async (did) => loadResults.profiles?.[did], 27 28 loadDetailedProfiles: async () => {}, 29 + loadKnownFollowers: async () => {}, 28 30 loadPostThread: async (uri) => loadResults.postThreads?.[uri], 29 31 loadPost: async (uri) => loadResults.posts?.[uri], 30 32 loadPosts: async () => {}, ··· 150 152 151 153 assert(error !== null); 152 154 assert.deepEqual(error.message, "Profile not found"); 155 + }); 156 + }); 157 + 158 + describe("ensureKnownFollowers", () => { 159 + it("should return existing known followers without loading", async () => { 160 + const profileDid = "did:test:profile"; 161 + const knownFollowers = { followers: [{ did: "did:test:follower" }] }; 162 + let loadCalled = false; 163 + 164 + const derived = createMockDerived({ 165 + knownFollowers: { [profileDid]: knownFollowers }, 166 + }); 167 + const requests = { 168 + loadKnownFollowers: async () => { 169 + loadCalled = true; 170 + }, 171 + }; 172 + 173 + const declarative = new Declarative(derived, requests); 174 + const result = await declarative.ensureKnownFollowers(profileDid); 175 + 176 + assert.deepEqual(result, knownFollowers); 177 + assert.deepEqual(loadCalled, false); 178 + }); 179 + 180 + it("should load known followers when not in cache", async () => { 181 + const profileDid = "did:test:profile"; 182 + const knownFollowers = { followers: [{ did: "did:test:follower" }] }; 183 + let callCount = 0; 184 + 185 + const derived = { 186 + $knownFollowers: mapSig(() => { 187 + callCount++; 188 + return callCount > 1 ? knownFollowers : null; 189 + }), 190 + }; 191 + const requests = { 192 + loadKnownFollowers: async () => {}, 193 + }; 194 + 195 + const declarative = new Declarative(derived, requests); 196 + const result = await declarative.ensureKnownFollowers(profileDid); 197 + 198 + assert.deepEqual(result, knownFollowers); 199 + }); 200 + 201 + it("should throw when known followers not found after loading", async () => { 202 + const derived = createMockDerived({}); 203 + const requests = createMockRequests({}); 204 + 205 + const declarative = new Declarative(derived, requests); 206 + 207 + let error = null; 208 + try { 209 + await declarative.ensureKnownFollowers("did:nonexistent"); 210 + } catch (e) { 211 + error = e; 212 + } 213 + 214 + assert(error !== null); 215 + assert.deepEqual(error.message, "Known followers not found"); 153 216 }); 154 217 }); 155 218
+121 -10
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 }); 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 + 3096 + it("should omit feedContext when null but keep an empty string", async () => { 3097 + const dataStore = new DataStore(); 3098 + const patchStore = new PatchStore(dataStore); 3099 + const mockPreferencesProvider = { 3100 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 3101 + }; 3102 + const sentInteractions = []; 3103 + const mutations = makeMutations( 3104 + { 3105 + sendInteractions: async (interactions) => { 3106 + sentInteractions.push(...interactions); 3107 + }, 3108 + }, 3109 + dataStore, 3110 + patchStore, 3111 + mockPreferencesProvider, 3112 + ); 3113 + 3114 + await mutations.sendShowLessInteraction( 3115 + postURI, 3116 + feedUri, 3117 + null, 3118 + feedProxyUrl, 3119 + ); 3120 + await mutations.sendShowLessInteraction(postURI, feedUri, "", feedProxyUrl); 3121 + 3122 + assert.deepEqual(sentInteractions, [ 3123 + { item: postURI, event: "app.bsky.feed.defs#requestLess" }, 3124 + { 3125 + item: postURI, 3126 + event: "app.bsky.feed.defs#requestLess", 3127 + feedContext: "", 3128 + }, 3129 + ]); 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 + }); 3057 3155 }); 3058 3156 3059 3157 describe("sendShowMoreInteraction", () => { 3060 3158 const postURI = "at://did:plc:author/app.bsky.feed.post/1"; 3159 + const feedUri = "at://did:plc:feedgen/app.bsky.feed.generator/cool"; 3061 3160 const feedContext = "ctx"; 3062 3161 const feedProxyUrl = "https://feed.example/xrpc"; 3063 3162 ··· 3079 3178 mockPreferencesProvider, 3080 3179 ); 3081 3180 3082 - await mutations.sendShowMoreInteraction(postURI, feedContext, feedProxyUrl); 3181 + await mutations.sendShowMoreInteraction( 3182 + postURI, 3183 + feedUri, 3184 + feedContext, 3185 + feedProxyUrl, 3186 + ); 3083 3187 3084 - const stored = dataStore.$showMoreInteractions.get(); 3188 + const stored = dataStore.$showMoreInteractions.get(feedUri); 3085 3189 assert.deepEqual(stored.length, 1); 3086 3190 assert.deepEqual(stored[0].item, postURI); 3087 3191 assert.deepEqual(stored[0].event, "app.bsky.feed.defs#requestMore"); ··· 3096 3200 const mockPreferencesProvider = { 3097 3201 requirePreferences: () => Preferences.createLoggedOutPreferences(), 3098 3202 }; 3099 - dataStore.$showMoreInteractions.set([{ item: "existing", event: "x" }]); 3203 + dataStore.$showMoreInteractions.set(feedUri, [ 3204 + { item: "existing", event: "x" }, 3205 + ]); 3100 3206 const mutations = makeMutations( 3101 3207 { sendInteractions: async () => {} }, 3102 3208 dataStore, ··· 3104 3210 mockPreferencesProvider, 3105 3211 ); 3106 3212 3107 - await mutations.sendShowMoreInteraction(postURI, feedContext, feedProxyUrl); 3213 + await mutations.sendShowMoreInteraction( 3214 + postURI, 3215 + feedUri, 3216 + feedContext, 3217 + feedProxyUrl, 3218 + ); 3108 3219 3109 - const stored = dataStore.$showMoreInteractions.get(); 3220 + const stored = dataStore.$showMoreInteractions.get(feedUri); 3110 3221 assert.deepEqual(stored.length, 2); 3111 3222 assert.deepEqual(stored[1].item, postURI); 3112 3223 });
+34
tests/unit/specs/plugins/pluginPermissions.test.js
··· 5 5 diffPermissions, 6 6 isEmptyPermissions, 7 7 isFetchAllowed, 8 + isActionAllowed, 8 9 } from "/js/plugins/pluginPermissions.js"; 9 10 10 11 describe("parsePermissions", () => { ··· 39 40 }), 40 41 { fetch: ["https://a.com/*", "https://b.com/*"] }, 41 42 ); 43 + }); 44 + 45 + it("parses known action scopes and drops unknown ones", () => { 46 + assert.deepEqual( 47 + parsePermissions({ 48 + actions: ["mute", "block", "feedFeedback", "deleteEverything"], 49 + }), 50 + { actions: ["mute", "block", "feedFeedback"] }, 51 + ); 52 + }); 53 + 54 + it("wraps a string actions value into an array", () => { 55 + assert.deepEqual(parsePermissions({ actions: "mute" }), { 56 + actions: ["mute"], 57 + }); 58 + }); 59 + 60 + it("omits the actions key when no valid scopes remain", () => { 61 + assert.deepEqual(parsePermissions({ actions: [] }), {}); 62 + assert.deepEqual(parsePermissions({ actions: ["feedback"] }), {}); 63 + }); 64 + }); 65 + 66 + describe("isActionAllowed", () => { 67 + it("allows only granted action scopes", () => { 68 + const permissions = { actions: ["mute", "feedFeedback"] }; 69 + assert(isActionAllowed("mute", permissions)); 70 + assert(isActionAllowed("feedFeedback", permissions)); 71 + assert(!isActionAllowed("block", permissions)); 72 + }); 73 + 74 + it("denies everything when the actions key is missing", () => { 75 + assert(!isActionAllowed("mute", {})); 42 76 }); 43 77 }); 44 78
+328 -17
tests/unit/specs/plugins/pluginService.test.js
··· 1347 1347 return { map, calls }; 1348 1348 } 1349 1349 1350 - it("getPost host method returns the hydrated post from derived", async () => { 1350 + it("getProfile host method returns the hydrated profile from derived", async () => { 1351 1351 const service = makeServiceWithRealBridge(); 1352 - const posts = makeStubComputedMap((uri) => ({ 1353 - uri, 1354 - record: { text: "cached" }, 1352 + const profiles = makeStubComputedMap((did) => ({ 1353 + did, 1354 + handle: "alice.test", 1355 1355 })); 1356 1356 service.setDataLayer({ 1357 1357 derived: { 1358 - $hydratedPosts: posts.map, 1358 + $hydratedPosts: makeStubComputedMap(() => null).map, 1359 + $hydratedProfiles: profiles.map, 1360 + }, 1361 + }); 1362 + const handler = service.pluginBridge._hostCallHandlers.get("getProfile"); 1363 + const result = await handler(null, { did: "did:plc:abc" }); 1364 + assert.deepEqual(profiles.calls, ["did:plc:abc"]); 1365 + assert.deepEqual(result, { did: "did:plc:abc", handle: "alice.test" }); 1366 + }); 1367 + 1368 + it("getPost returns null when dataLayer has not been set", async () => { 1369 + const service = makeServiceWithRealBridge(); 1370 + const handler = service.pluginBridge._hostCallHandlers.get("getPost"); 1371 + const result = await handler(null, { uri: "at://example" }); 1372 + assert.deepEqual(result, null); 1373 + }); 1374 + 1375 + it("getPost fetches the post on a cache miss", async () => { 1376 + const service = makeServiceWithRealBridge(); 1377 + const ensureCalls = []; 1378 + service.setDataLayer({ 1379 + derived: { 1380 + $hydratedPosts: makeStubComputedMap(() => null).map, 1359 1381 $hydratedProfiles: makeStubComputedMap(() => null).map, 1360 1382 }, 1383 + declarative: { 1384 + ensurePost: async (uri) => { 1385 + ensureCalls.push(uri); 1386 + return { uri, record: { text: "fetched" } }; 1387 + }, 1388 + }, 1361 1389 }); 1362 1390 const handler = service.pluginBridge._hostCallHandlers.get("getPost"); 1363 1391 const result = await handler(null, { uri: "at://example/post/1" }); 1364 - assert.deepEqual(posts.calls, ["at://example/post/1"]); 1392 + assert.deepEqual(ensureCalls, ["at://example/post/1"]); 1365 1393 assert.deepEqual(result, { 1366 1394 uri: "at://example/post/1", 1367 - record: { text: "cached" }, 1395 + record: { text: "fetched" }, 1396 + }); 1397 + }); 1398 + 1399 + it("getPost returns null when the post cannot be loaded", async () => { 1400 + const service = makeServiceWithRealBridge(); 1401 + service.setDataLayer({ 1402 + derived: { 1403 + $hydratedPosts: makeStubComputedMap(() => null).map, 1404 + $hydratedProfiles: makeStubComputedMap(() => null).map, 1405 + }, 1406 + declarative: { 1407 + ensurePost: async () => { 1408 + throw new Error("Post not found"); 1409 + }, 1410 + }, 1368 1411 }); 1412 + const handler = service.pluginBridge._hostCallHandlers.get("getPost"); 1413 + const result = await handler(null, { uri: "at://example/post/gone" }); 1414 + assert.deepEqual(result, null); 1369 1415 }); 1370 1416 1371 - it("getProfile host method returns the hydrated profile from derived", async () => { 1417 + it("getProfile fetches on a cache miss and returns the basic hydrated profile", async () => { 1372 1418 const service = makeServiceWithRealBridge(); 1373 - const profiles = makeStubComputedMap((did) => ({ 1374 - did, 1375 - handle: "alice.test", 1376 - })); 1419 + let loaded = false; 1420 + const profiles = makeStubComputedMap((did) => 1421 + loaded ? { did, handle: "alice.test" } : null, 1422 + ); 1423 + const ensureCalls = []; 1377 1424 service.setDataLayer({ 1378 1425 derived: { 1379 1426 $hydratedPosts: makeStubComputedMap(() => null).map, 1380 1427 $hydratedProfiles: profiles.map, 1428 + }, 1429 + declarative: { 1430 + ensureDetailedProfile: async (did) => { 1431 + ensureCalls.push(did); 1432 + loaded = true; 1433 + }, 1381 1434 }, 1382 1435 }); 1383 1436 const handler = service.pluginBridge._hostCallHandlers.get("getProfile"); 1384 1437 const result = await handler(null, { did: "did:plc:abc" }); 1385 - assert.deepEqual(profiles.calls, ["did:plc:abc"]); 1438 + assert.deepEqual(ensureCalls, ["did:plc:abc"]); 1386 1439 assert.deepEqual(result, { did: "did:plc:abc", handle: "alice.test" }); 1387 1440 }); 1388 1441 1389 - it("getPost returns null when dataLayer has not been set", async () => { 1442 + it("getKnownFollowers resolves via the declarative layer", async () => { 1443 + const service = makeServiceWithRealBridge(); 1444 + const knownFollowers = { followers: [{ did: "did:plc:follower" }] }; 1445 + const ensureCalls = []; 1446 + service.setDataLayer({ 1447 + declarative: { 1448 + ensureKnownFollowers: async (did) => { 1449 + ensureCalls.push(did); 1450 + return knownFollowers; 1451 + }, 1452 + }, 1453 + }); 1454 + const handler = 1455 + service.pluginBridge._hostCallHandlers.get("getKnownFollowers"); 1456 + const result = await handler(null, { did: "did:plc:abc" }); 1457 + assert.deepEqual(ensureCalls, ["did:plc:abc"]); 1458 + assert.deepEqual(result, knownFollowers); 1459 + }); 1460 + 1461 + it("getKnownFollowers returns null when the list cannot be loaded", async () => { 1390 1462 const service = makeServiceWithRealBridge(); 1391 - const handler = service.pluginBridge._hostCallHandlers.get("getPost"); 1392 - const result = await handler(null, { uri: "at://example" }); 1463 + service.setDataLayer({ 1464 + declarative: { 1465 + ensureKnownFollowers: async () => { 1466 + throw new Error("Known followers not found"); 1467 + }, 1468 + }, 1469 + }); 1470 + const handler = 1471 + service.pluginBridge._hostCallHandlers.get("getKnownFollowers"); 1472 + const result = await handler(null, { did: "did:plc:missing" }); 1393 1473 assert.deepEqual(result, null); 1394 1474 }); 1395 1475 1396 - it("getProfile returns null when the hydrated profile signal is empty", async () => { 1476 + it("getProfile returns null when the profile cannot be loaded", async () => { 1397 1477 const service = makeServiceWithRealBridge(); 1398 1478 service.setDataLayer({ 1399 1479 derived: { 1400 1480 $hydratedPosts: makeStubComputedMap(() => null).map, 1401 1481 $hydratedProfiles: makeStubComputedMap(() => null).map, 1402 1482 }, 1483 + declarative: { 1484 + ensureDetailedProfile: async () => { 1485 + throw new Error("Profile not found"); 1486 + }, 1487 + }, 1403 1488 }); 1404 1489 const handler = service.pluginBridge._hostCallHandlers.get("getProfile"); 1405 1490 const result = await handler(null, { did: "did:plc:missing" }); 1406 1491 assert.deepEqual(result, null); 1492 + }); 1493 + }); 1494 + 1495 + describe("action host methods", () => { 1496 + const feedbackPlugin = { 1497 + pluginId: "test-plugin", 1498 + permissions: { actions: ["feedFeedback"] }, 1499 + }; 1500 + const postUri = "at://did:plc:author/app.bsky.feed.post/1"; 1501 + const feedUri = "at://did:plc:feedgen/app.bsky.feed.generator/cool-feed"; 1502 + 1503 + function makeService({ 1504 + feedItem = null, 1505 + feedGenerator = null, 1506 + hydratedProfiles = {}, 1507 + } = {}) { 1508 + const { provider } = makeProvider(); 1509 + const service = new PluginService(provider, null); 1510 + const calls = { 1511 + showLess: [], 1512 + showMore: [], 1513 + mute: [], 1514 + unmute: [], 1515 + block: [], 1516 + unblock: [], 1517 + }; 1518 + service.setDataLayer({ 1519 + dataStore: { 1520 + $feeds: { 1521 + get: (uri) => 1522 + uri === feedUri && feedItem ? { feed: [feedItem] } : null, 1523 + }, 1524 + }, 1525 + derived: { 1526 + $feedGenerators: { 1527 + get: (uri) => (uri === feedUri ? feedGenerator : null), 1528 + }, 1529 + $hydratedDetailedProfiles: { get: () => null }, 1530 + $hydratedProfiles: { get: (did) => hydratedProfiles[did] ?? null }, 1531 + }, 1532 + mutations: { 1533 + sendShowLessInteraction: async (...args) => calls.showLess.push(args), 1534 + sendShowMoreInteraction: async (...args) => calls.showMore.push(args), 1535 + muteProfile: async (profile) => calls.mute.push(profile), 1536 + unmuteProfile: async (profile) => calls.unmute.push(profile), 1537 + blockProfile: async (profile) => calls.block.push(profile), 1538 + unblockProfile: async (profile) => calls.unblock.push(profile), 1539 + }, 1540 + }); 1541 + return { service, calls }; 1542 + } 1543 + 1544 + function getHandler(service, name) { 1545 + return service.pluginBridge._hostCallHandlers.get(name); 1546 + } 1547 + 1548 + it("showLessLikeThis resolves feedContext and proxy from the feed", async () => { 1549 + const { service, calls } = makeService({ 1550 + feedItem: { post: { uri: postUri }, feedContext: "ctx" }, 1551 + feedGenerator: { uri: feedUri, did: "did:web:feed.example" }, 1552 + }); 1553 + await getHandler(service, "showLessLikeThis")(feedbackPlugin, { 1554 + postUri, 1555 + feedUri, 1556 + }); 1557 + assert.deepEqual(calls.showLess, [ 1558 + [postUri, feedUri, "ctx", "did:web:feed.example#bsky_fg"], 1559 + ]); 1560 + }); 1561 + 1562 + it("showLessLikeThis and showMoreLikeThis reject when feedUri is missing", async () => { 1563 + const { service, calls } = makeService(); 1564 + await assert.rejects( 1565 + getHandler(service, "showLessLikeThis")(feedbackPlugin, { postUri }), 1566 + /requires a feedUri/, 1567 + ); 1568 + await assert.rejects( 1569 + getHandler(service, "showMoreLikeThis")(feedbackPlugin, { postUri }), 1570 + /requires a feedUri/, 1571 + ); 1572 + assert.deepEqual(calls.showLess, []); 1573 + assert.deepEqual(calls.showMore, []); 1574 + }); 1575 + 1576 + it("both methods reject when postUri is missing", async () => { 1577 + const { service, calls } = makeService(); 1578 + await assert.rejects( 1579 + getHandler(service, "showLessLikeThis")(feedbackPlugin, { feedUri }), 1580 + /requires a postUri/, 1581 + ); 1582 + await assert.rejects( 1583 + getHandler(service, "showMoreLikeThis")(feedbackPlugin, { feedUri }), 1584 + /requires a postUri/, 1585 + ); 1586 + assert.deepEqual(calls.showLess, []); 1587 + assert.deepEqual(calls.showMore, []); 1588 + }); 1589 + 1590 + it("muteActor and blockActor reject when did is missing", async () => { 1591 + const { service } = makeService(); 1592 + await assert.rejects( 1593 + getHandler(service, "muteActor")( 1594 + { pluginId: "test-plugin", permissions: { actions: ["mute"] } }, 1595 + {}, 1596 + ), 1597 + /muteActor requires a did/, 1598 + ); 1599 + await assert.rejects( 1600 + getHandler(service, "blockActor")( 1601 + { pluginId: "test-plugin", permissions: { actions: ["block"] } }, 1602 + {}, 1603 + ), 1604 + /blockActor requires a did/, 1605 + ); 1606 + }); 1607 + 1608 + it("showLessLikeThis with an uncached feed still resolves the generator proxy", async () => { 1609 + const { service, calls } = makeService({ 1610 + feedGenerator: { uri: feedUri, did: "did:web:feed.example" }, 1611 + }); 1612 + await getHandler(service, "showLessLikeThis")(feedbackPlugin, { 1613 + postUri, 1614 + feedUri, 1615 + }); 1616 + assert.deepEqual(calls.showLess, [ 1617 + [postUri, feedUri, null, "did:web:feed.example#bsky_fg"], 1618 + ]); 1619 + }); 1620 + 1621 + it("showMoreLikeThis resolves attribution the same way", async () => { 1622 + const { service, calls } = makeService({ 1623 + feedItem: { post: { uri: postUri }, feedContext: "ctx" }, 1624 + feedGenerator: { uri: feedUri, did: "did:web:feed.example" }, 1625 + }); 1626 + await getHandler(service, "showMoreLikeThis")(feedbackPlugin, { 1627 + postUri, 1628 + feedUri, 1629 + }); 1630 + assert.deepEqual(calls.showMore, [ 1631 + [postUri, feedUri, "ctx", "did:web:feed.example#bsky_fg"], 1632 + ]); 1633 + }); 1634 + 1635 + it("both methods require the feedFeedback action permission", async () => { 1636 + const { service, calls } = makeService(); 1637 + const noPermissionPlugin = { 1638 + pluginId: "test-plugin", 1639 + permissions: { actions: ["mute"] }, 1640 + }; 1641 + for (const name of ["showLessLikeThis", "showMoreLikeThis"]) { 1642 + await assert.rejects( 1643 + getHandler(service, name)(noPermissionPlugin, { postUri, feedUri }), 1644 + /"feedFeedback" action permission/, 1645 + ); 1646 + } 1647 + assert.deepEqual(calls.showLess, []); 1648 + assert.deepEqual(calls.showMore, []); 1649 + }); 1650 + 1651 + it("muteActor routes the mute flag to muteProfile and unmuteProfile", async () => { 1652 + const did = "did:plc:target"; 1653 + const profile = { did, handle: "target.example" }; 1654 + const { service, calls } = makeService({ 1655 + hydratedProfiles: { [did]: profile }, 1656 + }); 1657 + const mutePlugin = { 1658 + pluginId: "test-plugin", 1659 + permissions: { actions: ["mute"] }, 1660 + }; 1661 + await getHandler(service, "muteActor")(mutePlugin, { did, mute: true }); 1662 + await getHandler(service, "muteActor")(mutePlugin, { did, mute: false }); 1663 + assert.deepEqual(calls.mute, [profile]); 1664 + assert.deepEqual(calls.unmute, [profile]); 1665 + }); 1666 + 1667 + it("blockActor routes the block flag to blockProfile and unblockProfile", async () => { 1668 + const did = "did:plc:target"; 1669 + const { service, calls } = makeService(); 1670 + const blockPlugin = { 1671 + pluginId: "test-plugin", 1672 + permissions: { actions: ["block"] }, 1673 + }; 1674 + await getHandler(service, "blockActor")(blockPlugin, { did, block: true }); 1675 + await getHandler(service, "blockActor")(blockPlugin, { did, block: false }); 1676 + assert.deepEqual(calls.block, [{ did }]); 1677 + assert.deepEqual(calls.unblock, [{ did }]); 1678 + }); 1679 + 1680 + it("muteActor and blockActor require their action permissions", async () => { 1681 + const { service, calls } = makeService(); 1682 + const noPermissionPlugin = { 1683 + pluginId: "test-plugin", 1684 + permissions: { actions: ["feedFeedback"] }, 1685 + }; 1686 + const did = "did:plc:target"; 1687 + await assert.rejects( 1688 + getHandler(service, "muteActor")(noPermissionPlugin, { did }), 1689 + /"mute" action permission/, 1690 + ); 1691 + await assert.rejects( 1692 + getHandler(service, "blockActor")(noPermissionPlugin, { did }), 1693 + /"block" action permission/, 1694 + ); 1695 + assert.deepEqual(calls.mute, []); 1696 + assert.deepEqual(calls.block, []); 1697 + }); 1698 + 1699 + it("all action methods reject when signed out", async () => { 1700 + const { provider } = makeProvider(); 1701 + const service = new PluginService(provider, null); 1702 + const allActionsPlugin = { 1703 + pluginId: "test-plugin", 1704 + permissions: { actions: ["mute", "block", "feedFeedback"] }, 1705 + }; 1706 + const argsByMethod = { 1707 + muteActor: { did: "did:plc:target" }, 1708 + blockActor: { did: "did:plc:target" }, 1709 + showLessLikeThis: { postUri, feedUri }, 1710 + showMoreLikeThis: { postUri, feedUri }, 1711 + }; 1712 + for (const [name, args] of Object.entries(argsByMethod)) { 1713 + await assert.rejects( 1714 + getHandler(service, name)(allActionsPlugin, args), 1715 + /Not signed in/, 1716 + ); 1717 + } 1407 1718 }); 1408 1719 }); 1409 1720
+37
tests/unit/specs/plugins/pluginWorker.test.js
··· 335 335 assert.deepEqual(sent.args[0], "at://example/feed"); 336 336 }); 337 337 338 + it("app mute and block methods post hostCalls with the direction flag", async () => { 339 + clearMessages(); 340 + const plugin = new Plugin(); 341 + const did = "did:plc:target"; 342 + 343 + plugin.app.muteActor(did); 344 + assert.deepEqual(lastMessage().method, "muteActor"); 345 + assert.deepEqual(lastMessage().args[0], { did, mute: true }); 346 + 347 + plugin.app.unmuteActor(did); 348 + assert.deepEqual(lastMessage().method, "muteActor"); 349 + assert.deepEqual(lastMessage().args[0], { did, mute: false }); 350 + 351 + plugin.app.blockActor(did); 352 + assert.deepEqual(lastMessage().method, "blockActor"); 353 + assert.deepEqual(lastMessage().args[0], { did, block: true }); 354 + 355 + plugin.app.unblockActor(did); 356 + assert.deepEqual(lastMessage().method, "blockActor"); 357 + assert.deepEqual(lastMessage().args[0], { did, block: false }); 358 + }); 359 + 360 + it("app feedback methods post hostCalls with postUri and feedUri", async () => { 361 + clearMessages(); 362 + const plugin = new Plugin(); 363 + const postUri = "at://example/post/1"; 364 + const feedUri = "at://example/feed/cool"; 365 + 366 + plugin.app.showLessLikeThis(postUri, feedUri); 367 + assert.deepEqual(lastMessage().method, "showLessLikeThis"); 368 + assert.deepEqual(lastMessage().args[0], { postUri, feedUri }); 369 + 370 + plugin.app.showMoreLikeThis(postUri, feedUri); 371 + assert.deepEqual(lastMessage().method, "showMoreLikeThis"); 372 + assert.deepEqual(lastMessage().args[0], { postUri, feedUri }); 373 + }); 374 + 338 375 it("app.data.getPost posts a hostCall and resolves with the host result", async () => { 339 376 clearMessages(); 340 377 const plugin = new Plugin();
+10
tests/unit/specs/postSeenObserver.test.js
··· 115 115 assert(observer.seenPosts.has(postUriA)); 116 116 }); 117 117 118 + it("omits feedContext from the interaction when it is null", async () => { 119 + const { element } = createTrackedElement(); 120 + observer.register(element, postUriA, null); 121 + await flushTimers(); 122 + assert.deepEqual(sendInteractions.mock.callCount(), 1); 123 + assert.deepEqual(sendInteractions.mock.calls[0].arguments[0], [ 124 + { item: postUriA, event: "app.bsky.feed.defs#interactionSeen" }, 125 + ]); 126 + }); 127 + 118 128 it("does not send an interaction for an off-screen post", async () => { 119 129 const { element } = createTrackedElement({ top: 2000, bottom: 2100 }); 120 130 observer.register(element, postUriA, null);
+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: () => {} },
-12
tests/unit/specs/templates/postFeed.template.test.js
··· 115 115 const feedItem = container.querySelector("[data-testid='feed-item']"); 116 116 assert(feedItem.getAttribute("data-post-uri") !== null); 117 117 }); 118 - 119 - it("should set data-feed-context attribute on feed items", () => { 120 - const result = postFeedTemplate({ 121 - feed: { feed: feed.slice(0, 1), cursor: null }, 122 - currentUser: mockUser, 123 - postInteractionHandler, 124 - }); 125 - const container = document.createElement("div"); 126 - render(result, container); 127 - const feedItem = container.querySelector("[data-testid='feed-item']"); 128 - assert(feedItem.hasAttribute("data-feed-context")); 129 - }); 130 118 }); 131 119 132 120 describe("postFeedTemplate - pagination", () => {