diff --git a/impro-plugin/main.js b/impro-plugin/main.js index cddbe103..8085fcef 100644 --- a/impro-plugin/main.js +++ b/impro-plugin/main.js @@ -147,15 +147,55 @@ class PluginData { getProfile(did) { return hostCall("getProfile", { did }); } + // Like getProfile, but includes viewer relationship details not present + // on the basic profile view: viewer.following, viewer.followedBy, and + // viewer.knownFollowers (a summary of mutual followers). Requires no + // extra permission, but does a network round-trip if not already cached. + getDetailedProfile(did) { + return hostCall("getDetailedProfile", { did }); + } + // The full known-followers list for did (the summary on + // getDetailedProfile's viewer.knownFollowers is capped to a handful). + getKnownFollowers(did) { + return hostCall("getKnownFollowers", { did }); + } getRecord(repo, collection, rkey) { return hostCall("getRecord", { repo, collection, rkey }); } } +// Moderation actions on behalf of the signed-in user. Each method requires +// the corresponding scope ("mute", "block", "feedback") to be declared in +// the plugin manifest's `permissions.moderation` array, which the user must +// grant at install time. +class PluginModeration { + muteActor(did, mute = true) { + return hostCall("muteActor", { did, mute }); + } + blockActor(did, block = true) { + return hostCall("blockActor", { did, block }); + } + // feedContext/feedProxyUrl are optional and let the signal be attributed + // back to the specific feed generator that served the post, matching + // app.bsky.feed.sendInteractions semantics; omit them to just send a + // general requestLess signal. + sendShowLessInteraction( + postUri, + { feedContext = null, feedProxyUrl = null } = {}, + ) { + return hostCall("sendShowLessInteraction", { + postUri, + feedContext, + feedProxyUrl, + }); + } +} + class App { constructor() { this.currentUser = null; this.data = new PluginData(); + this.moderation = new PluginModeration(); } on(event, listener) { addEventListener(event, listener); diff --git a/plugins.md b/plugins.md index 9ae03a2b..17335fcf 100644 --- a/plugins.md +++ b/plugins.md @@ -38,6 +38,8 @@ Plugins are currently in **beta** as the API surface is being expanded. However, - Add custom feed filters - Transform rich text in posts - Make whitelisted network requests (requires permissions) +- Read a detailed profile (including whether the viewer follows/is followed by the account, and known/mutual followers) via `app.data.getDetailedProfile(did)` +- Mute, block, or send feed feedback ("show more/less like this") on the user's behalf via `app.moderation`, gated behind explicit `permissions.moderation` scopes (`mute`, `block`, `feedback`) the user must grant at install time ### Plugins CANNOT: diff --git a/src/js/plugins/pluginModal.js b/src/js/plugins/pluginModal.js index b2db7cb1..59730141 100644 --- a/src/js/plugins/pluginModal.js +++ b/src/js/plugins/pluginModal.js @@ -71,6 +71,13 @@ export function hidePluginModal({ pluginId, modalId }) { } } +const MODERATION_ACTION_LABELS = { + mute: "Mute and unmute accounts on your behalf", + block: "Block and unblock accounts on your behalf", + feedback: + 'Send feed feedback (e.g. "show fewer/more like this") on your behalf', +}; + function permissionsListTemplate({ permissions }) { const sections = []; const fetchPatterns = permissions.fetch ?? []; @@ -86,6 +93,19 @@ function permissionsListTemplate({ permissions }) { `); } + const moderationScopes = permissions.moderation ?? []; + if (moderationScopes.length > 0) { + sections.push(html` +
+ `); + } return sections; } diff --git a/src/js/plugins/pluginPermissions.js b/src/js/plugins/pluginPermissions.js index c0c50558..7a6e864c 100644 --- a/src/js/plugins/pluginPermissions.js +++ b/src/js/plugins/pluginPermissions.js @@ -1,5 +1,7 @@ import { unique } from "/js/utils.js"; +const MODERATION_ACTIONS = ["mute", "block", "feedback"]; + export function getPermissionsFromManifest(manifest) { return parsePermissions(manifest.permissions ?? {}); } @@ -15,9 +17,24 @@ export function parsePermissions(permissions) { ); if (fetchPatterns.length > 0) parsed.fetch = fetchPatterns; } + if (permissions.moderation) { + const moderationArray = Array.isArray(permissions.moderation) + ? permissions.moderation + : [permissions.moderation]; + const moderationScopes = unique( + moderationArray.filter((entry) => MODERATION_ACTIONS.includes(entry)), + ); + if (moderationScopes.length > 0) parsed.moderation = moderationScopes; + } return parsed; } +// action is one of "mute", "block", "feedback" (the "show fewer/more like +// this" feed-interaction signal) +export function isModerationActionAllowed(action, permissions) { + return (permissions.moderation ?? []).includes(action); +} + export function diffPermissions(current, next) { const diff = {}; let hasAny = false; diff --git a/src/js/plugins/pluginService.js b/src/js/plugins/pluginService.js index 490a20c1..1e8c844d 100644 --- a/src/js/plugins/pluginService.js +++ b/src/js/plugins/pluginService.js @@ -21,6 +21,7 @@ import { getPermissionsFromManifest, diffPermissions, isEmptyPermissions, + isModerationActionAllowed, } from "/js/plugins/pluginPermissions.js"; import { compareVersions, groupBy, isDev, sortBy } from "/js/utils.js"; import { @@ -373,10 +374,37 @@ export class PluginService extends ReactiveStore { return this._dataLayer?.derived.$hydratedProfiles.get(did) ?? null; }); + this.pluginBridge.addHostMethod( + "getDetailedProfile", + async (plugin, { did }) => { + if (!this._dataLayer) return null; + let profile = + this._dataLayer.derived.$hydratedDetailedProfiles.get(did); + if (!profile) { + await this._dataLayer.requests.loadDetailedProfile(did); + profile = this._dataLayer.derived.$hydratedDetailedProfiles.get(did); + } + return profile ?? null; + }, + ); + this.pluginBridge.addHostMethod("getRecord", (plugin, args) => this.slingshot.getRecord(args), ); + // Full known-followers list for did (the profile.viewer.knownFollowers + // included on getProfile/getDetailedProfile is capped to a handful). + // The AppView doesn't currently paginate this endpoint in practice, so + // this is a single round-trip, not a cursor loop. + this.pluginBridge.addHostMethod( + "getKnownFollowers", + async (plugin, { did }) => { + if (!this._dataLayer) return null; + await this._dataLayer.requests.loadKnownFollowers(did); + return this._dataLayer.derived.$knownFollowers.get(did) ?? null; + }, + ); + this.pluginBridge.addHostMethod("getCurrentUser", () => { if (!this.session) return null; return { @@ -384,6 +412,55 @@ export class PluginService extends ReactiveStore { handle: this.session.handle, }; }); + + this.pluginBridge.addHostMethod( + "muteActor", + async (plugin, { did, mute = true }) => { + this._requireModerationPermission(plugin, "mute"); + const profile = this._resolveProfileForMutation(did); + if (mute) await this._dataLayer.mutations.muteProfile(profile); + else await this._dataLayer.mutations.unmuteProfile(profile); + }, + ); + + this.pluginBridge.addHostMethod( + "blockActor", + async (plugin, { did, block = true }) => { + this._requireModerationPermission(plugin, "block"); + const profile = this._resolveProfileForMutation(did); + if (block) await this._dataLayer.mutations.blockProfile(profile); + else await this._dataLayer.mutations.unblockProfile(profile); + }, + ); + + this.pluginBridge.addHostMethod( + "sendShowLessInteraction", + async (plugin, { postUri, feedContext = null, feedProxyUrl = null }) => { + this._requireModerationPermission(plugin, "feedback"); + await this._dataLayer.mutations.sendShowLessInteraction( + postUri, + feedContext, + feedProxyUrl, + ); + }, + ); + } + + _requireModerationPermission(plugin, action) { + if (!isModerationActionAllowed(action, plugin.permissions)) { + throw new Error( + `"${plugin.pluginId}" does not have "${action}" moderation permission`, + ); + } + if (!this._dataLayer) throw new Error("Not signed in"); + } + + _resolveProfileForMutation(did) { + if (!this._dataLayer) return { did }; + return ( + this._dataLayer.derived.$hydratedDetailedProfiles.get(did) ?? + this._dataLayer.derived.$hydratedProfiles.get(did) ?? { did } + ); } async loadEnabledPlugins() { @@ -807,8 +884,8 @@ export class PluginService extends ReactiveStore { return this.$settingTabs.get(pluginId); } - async getPostContextMenuItems(post) { - return this._collectContextMenuItems("post-context-menu", post); + async getPostContextMenuItems(post, meta = null) { + return this._collectContextMenuItems("post-context-menu", post, meta); } async getProfileContextMenuItems(profile) { @@ -853,13 +930,14 @@ export class PluginService extends ReactiveStore { return { text, cursor }; } - async _collectContextMenuItems(event, target) { + async _collectContextMenuItems(event, target, meta = null) { const listeners = this.registries.eventListeners.get(event); if (!listeners || listeners.size === 0) return []; const results = await Promise.all( [...listeners].map(async ([pluginId, handler]) => { try { - const items = await handler(target); + const items = + meta != null ? await handler(target, meta) : await handler(target); return (items ?? []).map((item) => ({ pluginId, icon: item.icon,