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

Refactor group chat link service

Grace Kind (Jun 27, 2026, 1:03 AM -0500) da970aff 7d591be0

+92 -153
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.17.60", 3 + "version": "0.17.61", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
+1 -3
src/index.html
··· 180 180 ? new AccountSwitcherService(dataLayer) 181 181 : null; 182 182 const reportService = session ? new ReportService(dataLayer) : null; 183 - const groupChatLinkService = session 184 - ? new GroupChatLinkService(dataLayer) 185 - : null; 183 + const groupChatLinkService = new GroupChatLinkService(dataLayer); 186 184 const interactionHandlers = new InteractionHandlers({ 187 185 session, 188 186 dataLayer,
+9 -3
src/js/groupChatLinkService.js
··· 1 1 import "/js/components/join-group-chat-dialog.js"; 2 2 import { showToast } from "/js/toasts.js"; 3 - import { isAvailableJoinLinkPreview } from "/js/dataHelpers.js"; 4 3 5 4 export class GroupChatLinkService { 6 5 constructor(dataLayer) { 7 6 this.dataLayer = dataLayer; 8 7 } 9 8 10 - handleAction({ actionType, preview }) { 9 + handleAction(actionType, preview) { 11 10 if (actionType === "copy") { 12 11 navigator.clipboard?.writeText(`https://bsky.app/chat/${preview.code}`); 13 12 showToast("Copied to clipboard", { style: "success" }); 14 13 return; 15 14 } 15 + if (!this.dataLayer.isAuthenticated) { 16 + window.open( 17 + `https://bsky.app/chat/${preview.code}`, 18 + "_blank", 19 + "noopener", 20 + ); 21 + return; 22 + } 16 23 if (actionType === "open") { 17 24 const convoId = preview.convo?.id ?? preview.convoId; 18 25 if (convoId) window.router.go(`/messages/${convoId}`); 19 26 return; 20 27 } 21 28 if (actionType === "join" || actionType === "request") { 22 - if (!isAvailableJoinLinkPreview(preview)) return; 23 29 this._openJoinDialog(preview); 24 30 return; 25 31 }
+18 -13
src/js/templates/chatJoinLinkEmbed.template.js
··· 17 17 18 18 if (convoId && convoId === currentConvoId) { 19 19 return { 20 - kind: "copy", 20 + type: "copy", 21 21 label: "Copy link", 22 22 icon: "link", 23 23 side: "left", ··· 26 26 } 27 27 if (convoId) { 28 28 return { 29 - kind: "open", 29 + type: "open", 30 30 label: "Open chat", 31 31 icon: "arrow-right", 32 32 side: "right", ··· 36 36 37 37 if (preview.memberCount >= preview.memberLimit) { 38 38 return { 39 - kind: "full", 39 + type: "full", 40 40 label: "This chat is full", 41 41 icon: "hand", 42 42 side: "left", ··· 45 45 } 46 46 if (preview.joinRule === "followedByOwner" && !isFollowing) { 47 47 return { 48 - kind: "follow-required", 48 + type: "follow-required", 49 49 label: "Only people the chat owner follows can join", 50 50 icon: "hand", 51 51 side: "left", ··· 54 54 } 55 55 if (hasRequested) { 56 56 return { 57 - kind: "requested", 57 + type: "requested", 58 58 label: "Requested", 59 59 icon: "check", 60 60 side: "left", ··· 62 62 }; 63 63 } 64 64 return { 65 - kind: preview.requireApproval ? "request" : "join", 65 + type: preview.requireApproval ? "request" : "join", 66 66 label: preview.requireApproval ? "Request to join" : "Join", 67 67 icon: "join", 68 68 side: "left", ··· 105 105 </div>`; 106 106 } 107 107 108 - function actionButtonTemplate({ action, onClick }) { 108 + function actionButtonTemplate({ action, preview }) { 109 109 if (!action) return null; 110 110 const iconFn = ACTION_ICONS[action.icon]; 111 111 const icon = iconFn ? iconFn() : null; 112 112 return html`<button 113 - class="chat-join-link-action chat-join-link-action-${action.kind}" 113 + class="chat-join-link-action chat-join-link-action-${action.type}" 114 114 data-testid="join-link-embed-action" 115 - data-teststate=${action.kind} 115 + data-teststate=${action.type} 116 116 ?disabled=${action.disabled} 117 - @click=${(event) => { 117 + @click=${function (event) { 118 118 event.stopPropagation(); 119 119 if (action.disabled) return; 120 - onClick?.(action.kind); 120 + this.dispatchEvent( 121 + new CustomEvent("chat-join-link:click", { 122 + detail: { actionType: action.type, preview }, 123 + bubbles: true, 124 + }), 125 + ); 121 126 }} 122 127 > 123 128 ${action.side === "left" ? icon : null} ··· 126 131 </button>`; 127 132 } 128 133 129 - export function chatJoinLinkEmbedTemplate({ embed, currentConvoId, onClick }) { 134 + export function chatJoinLinkEmbedTemplate({ embed, currentConvoId }) { 130 135 const preview = embed?.joinLinkPreview; 131 136 132 137 if (!isAvailableJoinLinkPreview(preview)) { ··· 170 175 ${ownerRowTemplate({ owner: preview.owner })} 171 176 </div> 172 177 </div> 173 - ${actionButtonTemplate({ action, onClick })} 178 + ${actionButtonTemplate({ action, preview })} 174 179 </div>`; 175 180 }
-2
src/js/templates/largePost.template.js
··· 107 107 afterDelete = null, 108 108 afterHide = null, 109 109 pluginService, 110 - groupChatLinkService = null, 111 110 }) { 112 111 if (isBlockedPost(post)) { 113 112 return blockedPostTemplate(); ··· 166 165 embed: post.embed, 167 166 mediaLabel: post.mediaLabel, 168 167 isAuthenticated, 169 - groupChatLinkService, 170 168 })} 171 169 </div>` 172 170 : null}
+48 -37
src/js/templates/mainLayout.template.js
··· 14 14 postComposerService, 15 15 accountSwitcherService, 16 16 pluginService, 17 + groupChatLinkService, 17 18 } = context; 18 19 const onLongPressProfile = 19 20 accountSwitcherService && auth.supportsMultipleAccounts() ··· 31 32 onClickComposeButton: () => 32 33 postComposerService.composePost({ currentUser }), 33 34 onLongPressProfile, 35 + groupChatLinkService, 34 36 ...options, 35 37 }); 36 38 }; ··· 49 51 showSidebarOverlay = true, 50 52 pluginService, 51 53 onLongPressProfile = null, 54 + groupChatLinkService, 52 55 }) { 53 56 // This fixes a weird performance bug that was happening on the postThread view 54 57 // (specifically with the profile image) ··· 56 59 const isLargeScreen = window.innerWidth > 800; 57 60 const doRenderSidebar = isLargeScreen || showSidebarOverlay; 58 61 return html` 59 - <div class="view-columns"> 60 - <div class="view-column-left"> 61 - ${doRenderSidebar 62 - ? sidebarTemplate({ 63 - isAuthenticated, 64 - currentUser, 65 - activeNavItem, 66 - numNotifications, 67 - numChatNotifications, 68 - onClickActiveItem: onClickActiveNavItem, 69 - onClickComposeButton, 70 - pluginSidebarItems: pluginService.getSidebarItems(), 71 - onLongPressProfile, 72 - }) 73 - : ""} 62 + <div 63 + @chat-join-link:click=${(e) => 64 + groupChatLinkService.handleAction( 65 + e.detail.actionType, 66 + e.detail.preview, 67 + )} 68 + > 69 + <div class="view-columns"> 70 + <div class="view-column-left"> 71 + ${doRenderSidebar 72 + ? sidebarTemplate({ 73 + isAuthenticated, 74 + currentUser, 75 + activeNavItem, 76 + numNotifications, 77 + numChatNotifications, 78 + onClickActiveItem: onClickActiveNavItem, 79 + onClickComposeButton, 80 + pluginSidebarItems: pluginService.getSidebarItems(), 81 + onLongPressProfile, 82 + }) 83 + : ""} 84 + </div> 85 + <div class="view-column-center" data-testid="view-column-center"> 86 + ${children} 87 + </div> 88 + <div class="view-column-right"></div> 74 89 </div> 75 - <div class="view-column-center" data-testid="view-column-center"> 76 - ${children} 77 - </div> 78 - <div class="view-column-right"></div> 90 + ${footerTemplate({ 91 + isAuthenticated, 92 + currentUser, 93 + activeNavItem, 94 + numNotifications, 95 + numChatNotifications, 96 + onClickActiveItem: onClickActiveNavItem, 97 + onLongPressProfile, 98 + })} 99 + ${currentUser && showFloatingComposeButton 100 + ? html`<button 101 + class="floating-compose-button" 102 + data-testid="floating-compose-button" 103 + @click=${() => onClickComposeButton()} 104 + > 105 + ${editIconTemplate()} 106 + </button>` 107 + : ""} 79 108 </div> 80 - ${footerTemplate({ 81 - isAuthenticated, 82 - currentUser, 83 - activeNavItem, 84 - numNotifications, 85 - numChatNotifications, 86 - onClickActiveItem: onClickActiveNavItem, 87 - onLongPressProfile, 88 - })} 89 - ${currentUser && showFloatingComposeButton 90 - ? html`<button 91 - class="floating-compose-button" 92 - data-testid="floating-compose-button" 93 - @click=${() => onClickComposeButton()} 94 - > 95 - ${editIconTemplate()} 96 - </button>` 97 - : ""} 98 109 `; 99 110 }
+3 -30
src/js/templates/postEmbed.template.js
··· 116 116 quotedPost, 117 117 lazyLoadImages, 118 118 isAuthenticated, 119 - groupChatLinkService = null, 120 119 }) { 121 120 if (!quotedPost) { 122 121 return html`<div class="quoted-post embed-card">Post not found</div>`; ··· 197 196 mediaLabel: quotedPost.mediaLabel, 198 197 lazyLoadImages, 199 198 isAuthenticated, 200 - groupChatLinkService, 201 199 })} 202 200 </div>` 203 201 : ""} ··· 508 506 </div>`; 509 507 } 510 508 511 - function recordEmbedTemplate({ 512 - record, 513 - lazyLoadImages, 514 - isAuthenticated, 515 - groupChatLinkService, 516 - }) { 509 + function recordEmbedTemplate({ record, lazyLoadImages, isAuthenticated }) { 517 510 switch (record.$type) { 518 511 case "app.bsky.embed.record#viewRecord": 519 512 if ( ··· 527 520 quotedPost: record, 528 521 lazyLoadImages, 529 522 isAuthenticated, 530 - groupChatLinkService, 531 523 }); 532 524 // This only happens if the author is blocking the viewer 533 525 case "app.bsky.embed.record#viewBlocked": ··· 556 548 lazyLoadImages = false, 557 549 isAuthenticated, 558 550 currentConvoId = null, 559 - groupChatLinkService = null, 560 551 }) { 561 552 if (enabledEmbedTypes && !enabledEmbedTypes.includes(embed.$type)) { 562 553 return null; ··· 567 558 record: embed.record, 568 559 lazyLoadImages, 569 560 isAuthenticated, 570 - groupChatLinkService, 571 561 }); 572 562 case "app.bsky.embed.recordWithMedia#view": 573 563 return html` ··· 576 566 mediaLabel, 577 567 lazyLoadImages, 578 568 isAuthenticated, 579 - groupChatLinkService, 580 569 })} 581 570 ${recordEmbedTemplate({ 582 571 record: embed.record.record, 583 572 lazyLoadImages, 584 573 isAuthenticated, 585 - groupChatLinkService, 586 574 })} 587 575 `; 588 576 case "app.bsky.embed.video#view": ··· 614 602 external: embed.external, 615 603 lazyLoadImages, 616 604 }); 617 - case "chat.bsky.embed.joinLink#view": { 618 - const preview = embed.joinLinkPreview; 619 - const onClick = groupChatLinkService 620 - ? (actionType) => 621 - groupChatLinkService.handleAction({ actionType, preview }) 622 - : () => 623 - window.open( 624 - `https://bsky.app/chat/${preview?.code}`, 625 - "_blank", 626 - "noopener", 627 - ); 628 - return chatJoinLinkEmbedTemplate({ 629 - embed, 630 - currentConvoId, 631 - onClick, 632 - }); 633 - } 605 + case "chat.bsky.embed.joinLink#view": 606 + return chatJoinLinkEmbedTemplate({ embed, currentConvoId }); 634 607 default: 635 608 console.warn("Embed type not supported: ", embed.$type); 636 609 break;
-8
src/js/templates/postFeed.template.js
··· 45 45 onClickShowMore, 46 46 enableFeedFeedback, 47 47 pluginService, 48 - groupChatLinkService, 49 48 }) { 50 49 const root = reply.root; 51 50 const parent = reply.parent; ··· 69 68 onClickShowMore, 70 69 enableFeedFeedback, 71 70 pluginService, 72 - groupChatLinkService, 73 71 isParent: true, 74 72 })} 75 73 ` ··· 100 98 onClickShowMore, 101 99 enableFeedFeedback, 102 100 pluginService, 103 - groupChatLinkService, 104 101 isParent: true, 105 102 })} 106 103 ` ··· 119 116 onClickShowMore, 120 117 enableFeedFeedback, 121 118 pluginService, 122 - groupChatLinkService, 123 119 }) { 124 120 const post = feedItem.post; 125 121 const reply = feedItem.reply; ··· 153 149 onClickShowMore, 154 150 enableFeedFeedback, 155 151 pluginService, 156 - groupChatLinkService, 157 152 }) 158 153 : ""} 159 154 ${postTemplate({ ··· 172 167 replyToAuthor, 173 168 enableFeedFeedback, 174 169 pluginService, 175 - groupChatLinkService, 176 170 })} 177 171 </div> 178 172 `; ··· 200 194 emptyMessage = null, 201 195 showEndMessage = false, 202 196 pluginService, 203 - groupChatLinkService = null, 204 197 }) { 205 198 if (!feed) { 206 199 return feedSkeletonTemplate(); ··· 246 239 onClickShowMore, 247 240 enableFeedFeedback, 248 241 pluginService, 249 - groupChatLinkService, 250 242 }), 251 243 )} 252 244 </div>`;
-2
src/js/templates/smallPost.template.js
··· 84 84 replyToAuthor = null, 85 85 lazyLoadImages = false, 86 86 pluginService, 87 - groupChatLinkService = null, 88 87 }) { 89 88 if (isBlockedPost(post)) { 90 89 return blockedPostTemplate(); ··· 174 173 mediaLabel: post.mediaLabel, 175 174 lazyLoadImages, 176 175 isAuthenticated, 177 - groupChatLinkService, 178 176 })} 179 177 </div>` 180 178 : null}`}
-2
src/js/views/bookmarks.view.js
··· 15 15 pluginService, 16 16 interactionHandlers, 17 17 mainLayout, 18 - groupChatLinkService, 19 18 }, 20 19 }) { 21 20 await auth.requireAuth(); ··· 51 50 postInteractionHandler, 52 51 emptyMessage: "No saved posts yet!", 53 52 pluginService, 54 - groupChatLinkService, 55 53 })} 56 54 </main> 57 55 `,
-2
src/js/views/chatDetail.view.js
··· 36 36 chatNotificationService, 37 37 identityResolver, 38 38 mainLayout, 39 - groupChatLinkService, 40 39 }, 41 40 }) { 42 41 await auth.requireAuth(); ··· 760 759 embed: message.embed, 761 760 isAuthenticated: true, 762 761 currentConvoId: convoId, 763 - groupChatLinkService, 764 762 })} 765 763 </div>` 766 764 : null}
-2
src/js/views/feedDetail.view.js
··· 24 24 pluginService, 25 25 interactionHandlers, 26 26 mainLayout, 27 - groupChatLinkService, 28 27 }, 29 28 }) { 30 29 await auth.requireAuth(); ··· 120 119 onLoadMore: () => loadFeed(), 121 120 postInteractionHandler, 122 121 pluginService, 123 - groupChatLinkService, 124 122 showEndMessage: true, 125 123 })} 126 124 </div>
-2
src/js/views/hashtag.view.js
··· 18 18 pluginService, 19 19 interactionHandlers, 20 20 mainLayout, 21 - groupChatLinkService, 22 21 }, 23 22 }) { 24 23 await auth.requireAuth(); ··· 106 105 enableFeedFeedback: false, 107 106 onLoadMore: () => loadCurrentFeed(), 108 107 pluginService, 109 - groupChatLinkService, 110 108 })} 111 109 </div>`; 112 110 })}
-2
src/js/views/home.view.js
··· 21 21 pluginService, 22 22 interactionHandlers, 23 23 mainLayout, 24 - groupChatLinkService, 25 24 }, 26 25 }) { 27 26 const CURRENT_FEED_URI_STORAGE_KEY = "home-view-currentFeedUri"; ··· 233 232 enableFeedFeedback: acceptsInteractions, 234 233 onLoadMore: () => loadCurrentFeed(), 235 234 pluginService, 236 - groupChatLinkService, 237 235 showEndMessage: true, 238 236 })} 239 237 </div>`;
-2
src/js/views/listDetail.view.js
··· 28 28 pluginService, 29 29 interactionHandlers, 30 30 mainLayout, 31 - groupChatLinkService, 32 31 }, 33 32 }) { 34 33 await auth.requireAuth(); ··· 270 269 onLoadMore: () => loadFeed(), 271 270 postInteractionHandler, 272 271 pluginService, 273 - groupChatLinkService, 274 272 showEndMessage: true, 275 273 })} 276 274 </div>`
-2
src/js/views/notifications.view.js
··· 54 54 pluginService, 55 55 interactionHandlers, 56 56 mainLayout, 57 - groupChatLinkService, 58 57 }, 59 58 }) { 60 59 await auth.requireAuth(); ··· 395 394 postInteractionHandler, 396 395 ignoreMuteWarning: true, 397 396 pluginService, 398 - groupChatLinkService, 399 397 })} 400 398 </div> 401 399 `;
-2
src/js/views/postQuotes.view.js
··· 16 16 pluginService, 17 17 interactionHandlers, 18 18 mainLayout, 19 - groupChatLinkService, 20 19 }, 21 20 }) { 22 21 const { handleOrDid, rkey } = params; ··· 82 81 postInteractionHandler, 83 82 emptyMessage: "No quotes yet.", 84 83 pluginService, 85 - groupChatLinkService, 86 84 })} 87 85 </main>`, 88 86 })}
-5
src/js/views/postThread.view.js
··· 39 39 pluginService, 40 40 interactionHandlers, 41 41 mainLayout, 42 - groupChatLinkService, 43 42 }, 44 43 }) { 45 44 const { handleOrDid, rkey } = params; ··· 201 200 replyContext: getReplyContext(i, numReplies), 202 201 lazyLoadImages, 203 202 pluginService, 204 - groupChatLinkService, 205 203 }); 206 204 })} 207 205 </div>`; ··· 282 280 ignoreMuteWarning: true, 283 281 lazyLoadImages: true, 284 282 pluginService, 285 - groupChatLinkService, 286 283 }), 287 284 )} 288 285 </hidden-replies-section>` ··· 398 395 replyContext, 399 396 ignoreMuteWarning: true, 400 397 pluginService, 401 - groupChatLinkService, 402 398 }), 403 399 }); 404 400 })} ··· 425 421 currentUser, 426 422 isAuthenticated, 427 423 pluginService, 428 - groupChatLinkService, 429 424 isUserPost: currentUser?.did === mainPost?.author?.did, 430 425 postInteractionHandler, 431 426 afterHide: () => {
-2
src/js/views/profile.view.js
··· 34 34 pluginService, 35 35 interactionHandlers, 36 36 mainLayout, 37 - groupChatLinkService, 38 37 }, 39 38 }) { 40 39 const defaultAuthorFeeds = [ ··· 505 504 postInteractionHandler, 506 505 onLoadMore: () => loadAuthorFeed(), 507 506 pluginService, 508 - groupChatLinkService, 509 507 showEndMessage: true, 510 508 })} 511 509 </div>`;
-2
src/js/views/search.view.js
··· 21 21 pluginService, 22 22 interactionHandlers, 23 23 mainLayout, 24 - groupChatLinkService, 25 24 }, 26 25 }) { 27 26 const state = new ReactiveStore("searchView"); ··· 173 172 isUserPost: currentUser?.did === post.author?.did, 174 173 postInteractionHandler, 175 174 pluginService, 176 - groupChatLinkService, 177 175 })} 178 176 </div>`, 179 177 )}
+12 -29
tests/unit/specs/templates/chatJoinLinkEmbed.template.test.js
··· 29 29 joinLinkPreview: preview, 30 30 }, 31 31 currentConvoId, 32 - onClick: () => {}, 33 32 }), 34 33 container, 35 34 ); ··· 148 147 assertEquals(label, "Requested"); 149 148 }); 150 149 151 - it("invokes onClick with the action type when the button is clicked", () => { 152 - const container = document.createElement("div"); 150 + it("dispatches a bubbling chat-join-link:click event when the button is clicked", () => { 151 + const preview = makeJoinLinkPreview(); 152 + const container = renderEmbed({ preview }); 153 153 const received = []; 154 - render( 155 - chatJoinLinkEmbedTemplate({ 156 - embed: { 157 - $type: "chat.bsky.embed.joinLink#view", 158 - joinLinkPreview: makeJoinLinkPreview(), 159 - }, 160 - currentConvoId: null, 161 - onClick: (actionType) => received.push(actionType), 162 - }), 163 - container, 154 + container.addEventListener("chat-join-link:click", (event) => 155 + received.push(event.detail), 164 156 ); 165 157 container.querySelector("[data-testid='join-link-embed-action']").click(); 166 - assertEquals(received, ["join"]); 158 + assertEquals(received, [{ actionType: "join", preview }]); 167 159 }); 168 160 169 - it("does not invoke onClick when the action is disabled", () => { 170 - const container = document.createElement("div"); 161 + it("does not dispatch an event when the action is disabled", () => { 162 + const container = renderEmbed({ 163 + preview: makeJoinLinkPreview({ memberCount: 50, memberLimit: 50 }), 164 + }); 171 165 const received = []; 172 - render( 173 - chatJoinLinkEmbedTemplate({ 174 - embed: { 175 - $type: "chat.bsky.embed.joinLink#view", 176 - joinLinkPreview: makeJoinLinkPreview({ 177 - memberCount: 50, 178 - memberLimit: 50, 179 - }), 180 - }, 181 - currentConvoId: null, 182 - onClick: (actionType) => received.push(actionType), 183 - }), 184 - container, 166 + container.addEventListener("chat-join-link:click", (event) => 167 + received.push(event.detail), 185 168 ); 186 169 container.querySelector("[data-testid='join-link-embed-action']").click(); 187 170 assertEquals(received, []);