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.

Add "copy link to plugin" button

Grace Kind (Jul 15, 2026, 3:32 PM -0500) 839513eb 1b35521b

+105 -22
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.17.162", 3 + "version": "0.17.163", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
+2 -6
src/css/style.css
··· 5536 5536 align-items: center; 5537 5537 } 5538 5538 5539 - .feed-menu-button, 5540 - .list-menu-button, 5541 - .chat-menu-button { 5539 + .context-menu-button { 5542 5540 border: none; 5543 5541 background: none; 5544 5542 cursor: pointer; ··· 5551 5549 margin-right: 12px; 5552 5550 } 5553 5551 5554 - .feed-menu-button span, 5555 - .list-menu-button span, 5556 - .chat-menu-button span { 5552 + .context-menu-button span { 5557 5553 position: relative; 5558 5554 top: -2px; 5559 5555 }
+8
src/js/navigation.js
··· 95 95 return `${getPermalinkOrigin()}/messages/${encodePathSegment(convoId)}`; 96 96 } 97 97 98 + export function linkToCommunityPlugin(pluginId) { 99 + return `/plugins/community/${encodePathSegment(pluginId)}`; 100 + } 101 + 102 + export function getPermalinkForCommunityPlugin(pluginId) { 103 + return "https://impro.social" + linkToCommunityPlugin(pluginId); 104 + } 105 + 98 106 export function linkToLogin({ query = {} } = {}) { 99 107 const params = new URLSearchParams(); 100 108 for (const [key, value] of Object.entries(query)) {
+1 -1
src/js/views/chatDetail.view.js
··· 1257 1257 backButtonFallbackRoute: "/messages", 1258 1258 rightItemTemplate: () => html` 1259 1259 <button 1260 - class="chat-menu-button" 1260 + class="context-menu-button" 1261 1261 data-testid="chat-menu-button" 1262 1262 @click=${function (e) { 1263 1263 const contextMenu = this.nextElementSibling;
+30 -1
src/js/views/communityPluginListing.view.js
··· 2 2 import { html, render } from "/js/lib/lit-html.js"; 3 3 import { pageEffect } from "/js/router.js"; 4 4 import { headerTemplate } from "/js/templates/header.template.js"; 5 - import { linkToLogin } from "/js/navigation.js"; 5 + import { linkToLogin, getPermalinkForCommunityPlugin } from "/js/navigation.js"; 6 6 import { showToast } from "/js/toasts.js"; 7 7 import { confirmModal } from "/js/modals/confirm.modal.js"; 8 8 import { Signal, ReactiveStore } from "/js/signals.js"; 9 9 import { PermissionsDeclinedError } from "/js/plugins/pluginService.js"; 10 10 import "/js/components/rendered-markdown.js"; 11 + import "/js/components/context-menu.js"; 12 + import "/js/components/context-menu-item.js"; 11 13 12 14 class CommunityPluginListingView extends View { 13 15 async render({ root, params, context: { pluginService, isAuthenticated } }) { ··· 131 133 const readme = state.$readme.get(); 132 134 const loading = state.$loading.get(); 133 135 const pendingAction = state.$pendingAction.get(); 136 + const pluginPermalink = getPermalinkForCommunityPlugin(pluginId); 134 137 render( 135 138 html`<div id="community-plugin-listing-view"> 136 139 ${headerTemplate({ 137 140 title: "Community plugins", 138 141 backButtonFallbackRoute: "/plugins/community", 142 + rightItemTemplate: listing 143 + ? () => html` 144 + <button 145 + class="context-menu-button" 146 + @click=${function (e) { 147 + const contextMenu = this.nextElementSibling; 148 + contextMenu.open(e.clientX, e.clientY); 149 + }} 150 + > 151 + <span>...</span> 152 + </button> 153 + <context-menu> 154 + <context-menu-item 155 + data-testid="menu-action-plugin-copy-link" 156 + @click=${() => { 157 + navigator.clipboard.writeText(pluginPermalink); 158 + showToast("Link copied to clipboard", { 159 + style: "success", 160 + }); 161 + }} 162 + > 163 + Copy link to plugin 164 + </context-menu-item> 165 + </context-menu> 166 + ` 167 + : null, 139 168 })} 140 169 <main> 141 170 ${(() => {
+2 -1
src/js/views/communityPlugins.view.js
··· 4 4 import { headerTemplate } from "/js/templates/header.template.js"; 5 5 import { chevronRightIconTemplate } from "/js/templates/icons/chevronRight.template.js"; 6 6 import { Signal, ReactiveStore } from "/js/signals.js"; 7 + import { linkToCommunityPlugin } from "/js/navigation.js"; 7 8 8 9 class CommunityPluginsView extends View { 9 10 async render({ root, context: { pluginService, isAuthenticated } }) { ··· 59 60 <li class="plugin-list-item"> 60 61 <a 61 62 class="plugin-list-item-link" 62 - href="/plugins/community/${listing.id}" 63 + href=${linkToCommunityPlugin(listing.id)} 63 64 > 64 65 <div class="plugin-list-item-info"> 65 66 <div class="plugin-list-item-name">
+1 -1
src/js/views/feedDetail.view.js
··· 62 62 rightItemTemplate: () => { 63 63 const feedLink = `https://bsky.app/profile/${feedAuthorHandle || handleOrDid}/feed/${rkey}`; 64 64 return html`<button 65 - class="feed-menu-button" 65 + class="context-menu-button" 66 66 @click=${function (e) { 67 67 const contextMenu = this.nextElementSibling; 68 68 contextMenu.open(e.clientX, e.clientY);
+1 -1
src/js/views/listDetail.view.js
··· 143 143 listInteractionHandler, 144 144 })} 145 145 <button 146 - class="list-menu-button" 146 + class="context-menu-button" 147 147 @click=${function (e) { 148 148 const contextMenu = this.nextElementSibling; 149 149 contextMenu.open(e.clientX, e.clientY);
+22
tests/e2e/specs/views/communityPluginListing.view.test.js
··· 152 152 ).toEqual([REMOTE_ID]); 153 153 }); 154 154 155 + test("copies a link to the plugin from the context menu", async ({ 156 + page, 157 + }) => { 158 + const mockServer = new MockServer(); 159 + mockServer.registryEntries = [REGISTRY_ENTRY]; 160 + await mockServer.setup(page); 161 + await login(page); 162 + 163 + await page.goto(detailUrl()); 164 + const view = page.locator("#community-plugin-listing-view"); 165 + await expect(view.locator(".context-menu-button")).toBeVisible({ 166 + timeout: 10000, 167 + }); 168 + 169 + await view.locator(".context-menu-button").click(); 170 + await view 171 + .locator('[data-testid="menu-action-plugin-copy-link"]') 172 + .click(); 173 + 174 + await expect(page.locator('[data-testid="toast"]')).toBeVisible(); 175 + }); 176 + 155 177 test("shows a not-found message for an unknown plugin", async ({ page }) => { 156 178 const mockServer = new MockServer(); 157 179 mockServer.registryEntries = [REGISTRY_ENTRY];
+6 -6
tests/e2e/specs/views/feedDetail.view.test.js
··· 213 213 await page.goto("/profile/creator1.bsky.social/feed/trending"); 214 214 215 215 const view = page.locator("#feed-detail-view"); 216 - await expect(view.locator(".feed-menu-button")).toBeVisible({ 216 + await expect(view.locator(".context-menu-button")).toBeVisible({ 217 217 timeout: 10000, 218 218 }); 219 219 220 - await view.locator(".feed-menu-button").click(); 220 + await view.locator(".context-menu-button").click(); 221 221 222 222 const menu = view.locator("context-menu"); 223 223 await expect(menu.locator("context-menu-item")).toHaveCount(2, { ··· 247 247 await page.goto("/profile/creator1.bsky.social/feed/trending"); 248 248 249 249 const view = page.locator("#feed-detail-view"); 250 - await expect(view.locator(".feed-menu-button")).toBeVisible({ 250 + await expect(view.locator(".context-menu-button")).toBeVisible({ 251 251 timeout: 10000, 252 252 }); 253 253 254 254 const popupPromise = page.waitForEvent("popup"); 255 - await view.locator(".feed-menu-button").click(); 255 + await view.locator(".context-menu-button").click(); 256 256 await view.locator('[data-testid="menu-action-feed-open-in-bsky"]').click(); 257 257 258 258 const popup = await popupPromise; ··· 277 277 await page.goto("/profile/creator1.bsky.social/feed/trending"); 278 278 279 279 const view = page.locator("#feed-detail-view"); 280 - await expect(view.locator(".feed-menu-button")).toBeVisible({ 280 + await expect(view.locator(".context-menu-button")).toBeVisible({ 281 281 timeout: 10000, 282 282 }); 283 283 284 - await view.locator(".feed-menu-button").click(); 284 + await view.locator(".context-menu-button").click(); 285 285 await view.locator('[data-testid="menu-action-feed-copy-link"]').click(); 286 286 287 287 await expect(page.locator('[data-testid="toast"]')).toBeVisible();
+4 -4
tests/e2e/specs/views/listDetail.view.test.js
··· 196 196 await page.goto("/profile/creator1.bsky.social/lists/mylist"); 197 197 198 198 const view = page.locator("#list-detail-view"); 199 - await expect(view.locator(".list-menu-button")).toBeVisible({ 199 + await expect(view.locator(".context-menu-button")).toBeVisible({ 200 200 timeout: 10000, 201 201 }); 202 202 203 - await view.locator(".list-menu-button").click(); 203 + await view.locator(".context-menu-button").click(); 204 204 205 205 const menu = view.locator("context-menu"); 206 206 await expect( ··· 222 222 await page.goto("/profile/creator1.bsky.social/lists/mylist"); 223 223 224 224 const view = page.locator("#list-detail-view"); 225 - await expect(view.locator(".list-menu-button")).toBeVisible({ 225 + await expect(view.locator(".context-menu-button")).toBeVisible({ 226 226 timeout: 10000, 227 227 }); 228 228 229 229 const popupPromise = page.waitForEvent("popup"); 230 - await view.locator(".list-menu-button").click(); 230 + await view.locator(".context-menu-button").click(); 231 231 await view.locator('[data-testid="menu-action-list-open-in-bsky"]').click(); 232 232 233 233 const popup = await popupPromise;
+27
tests/unit/specs/navigation.test.js
··· 13 13 linkToProfileFollowers, 14 14 linkToProfileFollowing, 15 15 linkToFeed, 16 + linkToCommunityPlugin, 16 17 getPermalinkForPost, 17 18 getPermalinkForProfile, 19 + getPermalinkForCommunityPlugin, 18 20 validateReturnToParam, 19 21 linkToLogin, 20 22 } from "/js/navigation.js"; ··· 325 327 assert.deepEqual( 326 328 getPermalinkForProfile(profile), 327 329 "https://bsky.app/profile/did:plc:alice", 330 + ); 331 + }); 332 + }); 333 + 334 + describe("linkToCommunityPlugin", () => { 335 + it("should return correct community plugin link", () => { 336 + assert.deepEqual( 337 + linkToCommunityPlugin("remote-themes"), 338 + "/plugins/community/remote-themes", 339 + ); 340 + }); 341 + 342 + it("should encode slashes in plugin ids", () => { 343 + assert.deepEqual( 344 + linkToCommunityPlugin("evil/plugin"), 345 + "/plugins/community/evil%2Fplugin", 346 + ); 347 + }); 348 + }); 349 + 350 + describe("getPermalinkForCommunityPlugin", () => { 351 + it("should return impro.social permalink for community plugin", () => { 352 + assert.deepEqual( 353 + getPermalinkForCommunityPlugin("remote-themes"), 354 + "https://impro.social/plugins/community/remote-themes", 328 355 ); 329 356 }); 330 357 });