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

Support group chat join link embeds

Grace Kind (Jun 26, 2026, 6:03 PM -0500) 33fe8178 89e3d741

+2137 -104
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.17.56", 3 + "version": "0.17.57", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
+141
src/css/style.css
··· 3979 3979 color: inherit; 3980 3980 } 3981 3981 3982 + .message-embed .chat-join-link-embed { 3983 + background: var(--text-input-background-color); 3984 + border-radius: var(--message-bubble-border-radius); 3985 + border: none; 3986 + padding: 12px; 3987 + display: flex; 3988 + flex-direction: column; 3989 + gap: 12px; 3990 + min-width: 240px; 3991 + max-width: 100%; 3992 + } 3993 + 3994 + .message-sent .message-embed .chat-join-link-embed { 3995 + background: var(--highlight-color-tint); 3996 + } 3997 + 3998 + .chat-join-link-embed-header { 3999 + display: flex; 4000 + flex-direction: row; 4001 + align-items: center; 4002 + gap: 12px; 4003 + } 4004 + 4005 + .chat-join-link-embed-avatars { 4006 + flex-shrink: 0; 4007 + } 4008 + 4009 + .chat-join-link-embed-text { 4010 + display: flex; 4011 + flex-direction: column; 4012 + gap: 2px; 4013 + min-width: 0; 4014 + flex: 1; 4015 + } 4016 + 4017 + .chat-join-link-embed-name { 4018 + font-weight: 700; 4019 + font-size: 15px; 4020 + overflow: hidden; 4021 + text-overflow: ellipsis; 4022 + white-space: nowrap; 4023 + } 4024 + 4025 + .chat-join-link-embed-meta { 4026 + font-size: 12px; 4027 + color: var(--text-color-muted); 4028 + } 4029 + 4030 + .chat-join-link-embed-owner { 4031 + font-size: 13px; 4032 + color: var(--text-color); 4033 + display: flex; 4034 + align-items: center; 4035 + gap: 4px; 4036 + overflow: hidden; 4037 + flex-wrap: wrap; 4038 + } 4039 + 4040 + .chat-join-link-embed-owner-name { 4041 + color: inherit; 4042 + font-weight: 500; 4043 + text-decoration: none; 4044 + } 4045 + 4046 + .chat-join-link-embed-owner-handle { 4047 + color: var(--text-color-muted); 4048 + overflow: hidden; 4049 + text-overflow: ellipsis; 4050 + white-space: nowrap; 4051 + min-width: 0; 4052 + } 4053 + 4054 + .chat-join-link-embed-unavailable { 4055 + display: flex; 4056 + align-items: center; 4057 + gap: 8px; 4058 + color: var(--text-color-muted); 4059 + font-size: 14px; 4060 + padding: 4px 0; 4061 + } 4062 + 4063 + .chat-join-link-embed-unavailable .icon { 4064 + width: 18px; 4065 + height: 18px; 4066 + flex-shrink: 0; 4067 + } 4068 + 4069 + .chat-join-link-action { 4070 + display: flex; 4071 + align-items: center; 4072 + justify-content: center; 4073 + gap: 6px; 4074 + padding: 8px 14px; 4075 + border-radius: var(--pill-button-border-radius); 4076 + border: none; 4077 + cursor: pointer; 4078 + font-size: 14px; 4079 + font-weight: 600; 4080 + background: var(--primary-button-color); 4081 + color: var(--primary-button-text-color); 4082 + width: 100%; 4083 + } 4084 + 4085 + .chat-join-link-action[disabled] { 4086 + opacity: 0.6; 4087 + cursor: default; 4088 + background: var(--secondary-button-color); 4089 + color: var(--text-color); 4090 + } 4091 + 4092 + .chat-join-link-action .icon { 4093 + width: 18px; 4094 + height: 18px; 4095 + } 4096 + 4097 + .join-group-chat-dialog .join-group-chat-dialog-content { 4098 + padding: 20px; 4099 + display: flex; 4100 + flex-direction: column; 4101 + gap: 16px; 4102 + } 4103 + 4104 + .join-group-chat-dialog-title { 4105 + font-size: 18px; 4106 + font-weight: 700; 4107 + margin: 0; 4108 + } 4109 + 4110 + .join-group-chat-dialog-body { 4111 + font-size: 16px; 4112 + color: var(--text-color); 4113 + margin: 0; 4114 + line-height: 1.4; 4115 + } 4116 + 4117 + .join-group-chat-dialog-actions { 4118 + display: flex; 4119 + justify-content: flex-end; 4120 + gap: 8px; 4121 + } 4122 + 3982 4123 .message-emoji-trigger { 3983 4124 background: transparent; 3984 4125 border: none;
+5
src/index.html
··· 110 110 import { PostComposerService } from "/js/postComposerService.js"; 111 111 import { AccountSwitcherService } from "/js/accountSwitcherService.js"; 112 112 import { ReportService } from "/js/reportService.js"; 113 + import { GroupChatLinkService } from "/js/groupChatLinkService.js"; 113 114 import { InteractionHandlers } from "/js/interactionHandlers.js"; 114 115 import { hapticsImpactLight } from "/js/haptics.js"; 115 116 import { isNative, wait } from "/js/utils.js"; ··· 179 180 ? new AccountSwitcherService(dataLayer) 180 181 : null; 181 182 const reportService = session ? new ReportService(dataLayer) : null; 183 + const groupChatLinkService = session 184 + ? new GroupChatLinkService(dataLayer) 185 + : null; 182 186 const interactionHandlers = new InteractionHandlers({ 183 187 session, 184 188 dataLayer, ··· 252 256 postComposerService, 253 257 accountSwitcherService, 254 258 reportService, 259 + groupChatLinkService, 255 260 pluginService, 256 261 interactionHandlers, 257 262 };
+21
src/js/api.js
··· 825 825 return res.data.message; 826 826 } 827 827 828 + async getJoinLinkPreviews(codes) { 829 + const res = await this.request("chat.bsky.group.getJoinLinkPreviews", { 830 + query: { codes }, 831 + headers: { 832 + "atproto-proxy": this.chatAppViewServiceDid, 833 + }, 834 + }); 835 + return res.data; 836 + } 837 + 838 + async requestJoinGroupChat(code) { 839 + const res = await this.request("chat.bsky.group.requestJoin", { 840 + method: "POST", 841 + body: { code }, 842 + headers: { 843 + "atproto-proxy": this.chatAppViewServiceDid, 844 + }, 845 + }); 846 + return res.data; 847 + } 848 + 828 849 async getLikes(postUri, { limit = 50, cursor, labelers = [] } = {}) { 829 850 const query = { uri: postUri, limit }; 830 851 if (cursor) {
+139
src/js/components/join-group-chat-dialog.js
··· 1 + import { html, render } from "/js/lib/lit-html.js"; 2 + import { Component } from "/js/components/component.js"; 3 + import { ScrollLock } from "/js/scrollLock.js"; 4 + import { enableDragToDismiss } from "/js/utils.js"; 5 + import { Signal, ReactiveStore, effect } from "/js/signals.js"; 6 + 7 + class JoinGroupChatDialog extends Component { 8 + static get observedAttributes() { 9 + return ["name", "require-approval"]; 10 + } 11 + 12 + connectedCallback() { 13 + if (this.initialized) return; 14 + this.setAttribute("data-dialog-wrapper", ""); 15 + this.scrollLock = new ScrollLock(this); 16 + this.state = new ReactiveStore("join-group-chat-dialog"); 17 + this.state.$isOpen = new Signal.State(false); 18 + this.state.$isSubmitting = new Signal.State(false); 19 + this.innerHTML = ""; 20 + this._disposeEffect = effect(() => this.render()); 21 + this.initialized = true; 22 + } 23 + 24 + disconnectedCallback() { 25 + this._disposeEffect?.(); 26 + this._disposeEffect = null; 27 + } 28 + 29 + attributeChangedCallback() { 30 + if (this.initialized) this.render(); 31 + } 32 + 33 + render() { 34 + const isOpen = this.state.$isOpen.get(); 35 + const isSubmitting = this.state.$isSubmitting.get(); 36 + if (!isOpen) { 37 + render(html``, this); 38 + return; 39 + } 40 + const name = this.getAttribute("name") ?? ""; 41 + const requireApproval = this.hasAttribute("require-approval"); 42 + render( 43 + html`<dialog 44 + class="bottom-sheet join-group-chat-dialog" 45 + data-testid="join-group-chat-dialog" 46 + @click=${(event) => { 47 + if (event.target.tagName === "DIALOG") this.close(); 48 + }} 49 + @cancel=${(event) => { 50 + event.preventDefault(); 51 + this.close(); 52 + }} 53 + > 54 + <div class="join-group-chat-dialog-content"> 55 + <h2 56 + class="join-group-chat-dialog-title" 57 + data-testid="join-group-chat-dialog-title" 58 + > 59 + ${requireApproval ? "Request to join" : "Join group chat"} 60 + </h2> 61 + <p class="join-group-chat-dialog-body"> 62 + ${requireApproval 63 + ? html`Send a request to join <strong>${name}</strong>. The group 64 + owner will review your request before you can see messages.` 65 + : html`You're about to join <strong>${name}</strong>.`} 66 + </p> 67 + <div class="join-group-chat-dialog-actions"> 68 + <button 69 + class="rounded-button" 70 + data-testid="join-group-chat-dialog-cancel" 71 + ?disabled=${isSubmitting} 72 + @click=${() => this.close()} 73 + > 74 + Cancel 75 + </button> 76 + <button 77 + class="rounded-button rounded-button-primary" 78 + data-testid="join-group-chat-dialog-confirm" 79 + ?disabled=${isSubmitting} 80 + @click=${() => this._onConfirm()} 81 + > 82 + ${isSubmitting 83 + ? "Sending…" 84 + : requireApproval 85 + ? "Send request" 86 + : "Join"} 87 + </button> 88 + </div> 89 + </div> 90 + </dialog>`, 91 + this, 92 + ); 93 + } 94 + 95 + _onConfirm() { 96 + if (this.state.$isSubmitting.get()) return; 97 + this.state.$isSubmitting.set(true); 98 + this.dispatchEvent( 99 + new CustomEvent("confirm", { 100 + detail: { 101 + successCallback: () => { 102 + this.state.$isSubmitting.set(false); 103 + this.close(); 104 + }, 105 + errorCallback: () => { 106 + this.state.$isSubmitting.set(false); 107 + }, 108 + }, 109 + }), 110 + ); 111 + } 112 + 113 + open() { 114 + this.state.$isOpen.set(true); 115 + this.state.$isSubmitting.set(false); 116 + this.render(); 117 + this.scrollLock.lock(); 118 + const dialog = this.querySelector("dialog"); 119 + if (!dialog) return; 120 + dialog.showModal(); 121 + enableDragToDismiss(dialog, { 122 + onClose: () => this.close(), 123 + confirmDismiss: () => !this.state.$isSubmitting.get(), 124 + allowUpwardStretch: true, 125 + ignoreTouchTarget: (element) => element.closest("button") !== null, 126 + }); 127 + } 128 + 129 + close() { 130 + if (this.state.$isSubmitting.get()) return; 131 + this.scrollLock.unlock(); 132 + const dialog = this.querySelector("dialog"); 133 + if (dialog?.open) dialog.close(); 134 + this.state.$isOpen.set(false); 135 + this.dispatchEvent(new CustomEvent("dialog-closed")); 136 + } 137 + } 138 + 139 + JoinGroupChatDialog.register();
+69
src/js/dataHelpers.js
··· 1 1 import { unique } from "/js/utils.js"; 2 + import { IN_APP_LINK_DOMAINS } from "/js/config.js"; 2 3 3 4 export const INVALID_HANDLE = "handle.invalid"; 4 5 export const MISSING_HANDLE = "missing.invalid"; ··· 894 895 ); 895 896 }); 896 897 } 898 + 899 + const CHAT_INVITE_PATH_REGEX = /^\/chat\/([a-zA-Z0-9]{7,10})$/; 900 + 901 + export function getInviteCodeFromUrl(url) { 902 + if (!url) return null; 903 + let pathname; 904 + try { 905 + const parsed = new URL( 906 + url.startsWith("/") ? `https://bsky.app${url}` : url, 907 + ); 908 + if ( 909 + !url.startsWith("/") && 910 + !IN_APP_LINK_DOMAINS.includes(parsed.hostname) 911 + ) { 912 + return null; 913 + } 914 + pathname = parsed.pathname; 915 + } catch { 916 + return null; 917 + } 918 + return pathname.match(CHAT_INVITE_PATH_REGEX)?.[1] ?? null; 919 + } 920 + 921 + export function isInviteLinkUrl(url) { 922 + return getInviteCodeFromUrl(url) !== null; 923 + } 924 + 925 + export function getJoinLinkCodeFromEmbed(embed) { 926 + if (embed?.$type === "chat.bsky.embed.joinLink#view") { 927 + return embed.joinLinkPreview?.code ?? null; 928 + } 929 + if (embed?.$type === "app.bsky.embed.external#view") { 930 + return getInviteCodeFromUrl(embed.external?.uri); 931 + } 932 + return null; 933 + } 934 + 935 + export function getJoinLinkCodesFromPosts(posts) { 936 + return (posts ?? []) 937 + .map((post) => getJoinLinkCodeFromEmbed(post?.embed)) 938 + .filter(Boolean); 939 + } 940 + 941 + export function getJoinLinkCodesFromMessages(messages) { 942 + return (messages ?? []) 943 + .map((message) => getJoinLinkCodeFromEmbed(message?.embed)) 944 + .filter(Boolean); 945 + } 946 + 947 + export function attachJoinLinkPreviewToEmbed(embed, preview) { 948 + if (embed?.$type === "chat.bsky.embed.joinLink#view") { 949 + if (preview === embed.joinLinkPreview) return null; 950 + return { ...embed, joinLinkPreview: preview }; 951 + } 952 + if (embed?.$type === "app.bsky.embed.external#view") { 953 + return { 954 + $type: "chat.bsky.embed.joinLink#view", 955 + joinLinkPreview: preview, 956 + }; 957 + } 958 + return null; 959 + } 960 + 961 + const JOIN_LINK_PREVIEW_TYPE = "chat.bsky.group.defs#joinLinkPreviewView"; 962 + 963 + export function isAvailableJoinLinkPreview(preview) { 964 + return preview?.$type === JOIN_LINK_PREVIEW_TYPE; 965 + }
+1
src/js/dataLayer/dataStore.js
··· 53 53 this.$knownFollowers = new SignalMap(); 54 54 this.$profileChatStatus = new SignalMap(); 55 55 this.$labelerInfo = new SignalMap(); 56 + this.$joinLinkPreviewsByCode = new SignalMap(); 56 57 } 57 58 58 59 setPosts(posts) {
+17 -3
src/js/dataLayer/derived.js
··· 20 20 markBlockedQuoteNotFound, 21 21 replaceBlockedQuote, 22 22 transformNestedQuotes, 23 + attachJoinLinkPreviewToEmbed, 24 + getJoinLinkCodeFromEmbed, 23 25 } from "/js/dataHelpers.js"; 24 26 import { sortBy } from "/js/utils.js"; 25 27 import { ··· 113 115 return null; 114 116 } 115 117 let result = this.resolveBlockedQuote(post); 118 + result = this.attachJoinLinkPreview(result); 116 119 result = applyMutedWords(result, preferences); 117 120 result = applyIsHidden(result, preferences); 118 121 result = applyLabels(result, preferences); ··· 479 482 const messages = this.dataStore.$convoMessages.get(convoId); 480 483 if (!messages) return null; 481 484 return { 482 - messages: messages.messages.map((message) => 483 - this.patchStore.$patchedMessages.get(message.id), 484 - ), 485 + messages: messages.messages.map((message) => { 486 + const patched = this.patchStore.$patchedMessages.get(message.id); 487 + return this.attachJoinLinkPreview(patched); 488 + }), 485 489 cursor: messages.cursor, 486 490 }; 487 491 }); ··· 551 555 this.$mentionNotificationCursor = new Signal.Computed(() => 552 556 this.dataStore.$mentionNotificationCursor.get(), 553 557 ); 558 + } 559 + 560 + attachJoinLinkPreview(item) { 561 + const code = getJoinLinkCodeFromEmbed(item?.embed); 562 + if (!code) return item; 563 + const preview = this.dataStore.$joinLinkPreviewsByCode.get(code); 564 + if (!preview) return item; 565 + const updated = attachJoinLinkPreviewToEmbed(item.embed, preview); 566 + if (!updated) return item; 567 + return { ...item, embed: updated }; 554 568 } 555 569 556 570 resolveBlockedQuote(post) {
+24
src/js/dataLayer/mutations.js
··· 1046 1046 return res; 1047 1047 } 1048 1048 1049 + async requestJoinGroupChat(code) { 1050 + const res = await this.api.requestJoinGroupChat(code); 1051 + const preview = this.dataStore.$joinLinkPreviewsByCode.get(code); 1052 + if ( 1053 + preview?.$type === "chat.bsky.group.defs#joinLinkPreviewView" && 1054 + preview.code === code 1055 + ) { 1056 + const updatedPreview = { ...preview }; 1057 + if (res.status === "joined" && res.convo) { 1058 + updatedPreview.convo = res.convo; 1059 + } else { 1060 + updatedPreview.viewer = { 1061 + ...(preview.viewer ?? {}), 1062 + requestedAt: getCurrentTimestamp(), 1063 + }; 1064 + } 1065 + this.dataStore.$joinLinkPreviewsByCode.set(code, updatedPreview); 1066 + } 1067 + if (res.status === "joined" && res.convo) { 1068 + this.dataStore.$convos.set(res.convo.id, res.convo); 1069 + } 1070 + return res; 1071 + } 1072 + 1049 1073 async acceptConvo(convo) { 1050 1074 await this.api.acceptConvo(convo.id); 1051 1075
+33
src/js/dataLayer/requests.js
··· 11 11 parseUri, 12 12 isGroupConvo, 13 13 getGroupConvoDetails, 14 + getJoinLinkCodesFromPosts, 15 + getJoinLinkCodesFromMessages, 14 16 } from "/js/dataHelpers.js"; 15 17 import { Constellation } from "/js/constellation.js"; 16 18 import { unique } from "/js/utils.js"; ··· 249 251 if (blockedPostUris.length > 0) { 250 252 await this._loadBlockedPosts(blockedPostUris); 251 253 } 254 + await this._loadJoinLinkPreviews(getJoinLinkCodesFromPosts(postsToSave)); 252 255 // Save post thread 253 256 this.dataStore.$postThreads.set(postURI, postThread); 254 257 this.dataStore.$postThreadOthers.set(postURI, postThreadOther); ··· 443 446 if (blockedPostUris.length > 0) { 444 447 await this._loadBlockedPosts(blockedPostUris); 445 448 } 449 + await this._loadJoinLinkPreviews(getJoinLinkCodesFromPosts(postsToSave)); 446 450 // Filter posts with plugins 447 451 await this.pluginService.refreshFiltersForFeed(feedURI, feed); 448 452 if (existingFeed && !reload) { ··· 589 593 if (blockedPostUris.length > 0) { 590 594 await this._loadBlockedPosts(blockedPostUris); 591 595 } 596 + await this._loadJoinLinkPreviews( 597 + getJoinLinkCodesFromPosts(searchResults), 598 + ); 592 599 } 593 600 const existingResults = this.dataStore.$postSearchResults.get(); 594 601 if (existingResults && cursor) { ··· 687 694 if (blockedPostUris.length > 0) { 688 695 await this._loadBlockedPosts(blockedPostUris); 689 696 } 697 + await this._loadJoinLinkPreviews(getJoinLinkCodesFromPosts(postsToSave)); 690 698 // Save feed 691 699 if (existingFeed && !reload) { 692 700 // Append to existing feed ··· 809 817 this.dataStore.$convos.set(convoId, res.convo); 810 818 } 811 819 820 + async _loadJoinLinkPreviews(codes) { 821 + const distinct = unique((codes ?? []).filter(Boolean)); 822 + if (distinct.length === 0) return; 823 + try { 824 + const res = await this.api.getJoinLinkPreviews(distinct); 825 + for (const preview of res.joinLinkPreviews ?? []) { 826 + if (preview?.code) { 827 + this.dataStore.$joinLinkPreviewsByCode.set(preview.code, preview); 828 + } 829 + } 830 + } catch (error) { 831 + console.error("Failed to load join link previews", error); 832 + } 833 + } 834 + 812 835 async loadConvoForProfile(profileDid) { 813 836 const res = await this.api.getConvoForMembers([profileDid]); 814 837 this.dataStore.$convos.set(res.convo.id, res.convo); ··· 849 872 } else { 850 873 this.dataStore.$convoMessages.set(convoId, res); 851 874 } 875 + await this._loadJoinLinkPreviews( 876 + getJoinLinkCodesFromMessages(res.messages), 877 + ); 852 878 } 853 879 854 880 async pollConvoMessages(convoId, { cursor = "" } = {}) { 855 881 const currentUser = this.dataStore.$currentUser.get(); 856 882 const res = await this.api.getChatLogs({ cursor }); 857 883 const logsForConvo = res.logs.filter((log) => log.convoId === convoId); 884 + const newMessages = []; 858 885 for (const log of logsForConvo) { 859 886 const isReactionLog = 860 887 log.$type === "chat.bsky.convo.defs#logAddReaction" || ··· 928 955 messages: [log.message, ...convoMessages.messages], 929 956 cursor: convoMessages.cursor, 930 957 }); 958 + newMessages.push(log.message); 931 959 } 960 + await this._loadJoinLinkPreviews(getJoinLinkCodesFromMessages(newMessages)); 932 961 return res.cursor; 933 962 } 934 963 ··· 1229 1258 if (blockedPostUris.length > 0) { 1230 1259 await this._loadBlockedPosts(blockedPostUris); 1231 1260 } 1261 + await this._loadJoinLinkPreviews( 1262 + getJoinLinkCodesFromPosts(searchResults), 1263 + ); 1232 1264 } 1233 1265 1234 1266 // Convert posts to feed format ··· 1280 1312 if (blockedPostUris.length > 0) { 1281 1313 await this._loadBlockedPosts(blockedPostUris); 1282 1314 } 1315 + await this._loadJoinLinkPreviews(getJoinLinkCodesFromPosts(posts)); 1283 1316 } 1284 1317 1285 1318 // Convert to feed format
+71
src/js/groupChatLinkService.js
··· 1 + import "/js/components/join-group-chat-dialog.js"; 2 + import { showToast } from "/js/toasts.js"; 3 + import { isAvailableJoinLinkPreview } from "/js/dataHelpers.js"; 4 + 5 + export class GroupChatLinkService { 6 + constructor(dataLayer) { 7 + this.dataLayer = dataLayer; 8 + } 9 + 10 + handleAction({ actionType, preview }) { 11 + if (actionType === "copy") { 12 + navigator.clipboard?.writeText(`https://bsky.app/chat/${preview.code}`); 13 + showToast("Copied to clipboard", { style: "success" }); 14 + return; 15 + } 16 + if (actionType === "open") { 17 + const convoId = preview.convo?.id ?? preview.convoId; 18 + if (convoId) window.router.go(`/messages/${convoId}`); 19 + return; 20 + } 21 + if (actionType === "join" || actionType === "request") { 22 + if (!isAvailableJoinLinkPreview(preview)) return; 23 + this._openJoinDialog(preview); 24 + return; 25 + } 26 + if (actionType === "requested") { 27 + showToast("Request pending — the group owner will review it.", { 28 + style: "default", 29 + }); 30 + } 31 + } 32 + 33 + _openJoinDialog(preview) { 34 + if (this.currentDialog) { 35 + console.warn("Join group chat dialog already open"); 36 + return; 37 + } 38 + const dialog = document.createElement("join-group-chat-dialog"); 39 + dialog.setAttribute("name", preview.name ?? ""); 40 + if (preview.requireApproval) dialog.setAttribute("require-approval", ""); 41 + dialog.addEventListener("confirm", (event) => 42 + this._submit({ preview, ...event.detail }), 43 + ); 44 + dialog.addEventListener("dialog-closed", () => { 45 + dialog.remove(); 46 + this.currentDialog = null; 47 + }); 48 + this.currentDialog = dialog; 49 + document.body.appendChild(dialog); 50 + dialog.open(); 51 + } 52 + 53 + async _submit({ preview, successCallback, errorCallback }) { 54 + try { 55 + await this.dataLayer.mutations.requestJoinGroupChat(preview.code); 56 + successCallback(); 57 + showToast( 58 + preview.requireApproval 59 + ? "Request sent — the group owner will review your request." 60 + : "Joined group chat", 61 + { style: "success" }, 62 + ); 63 + } catch (error) { 64 + console.error(error); 65 + errorCallback(); 66 + showToast("Could not send join request. Please try again.", { 67 + style: "error", 68 + }); 69 + } 70 + } 71 + }
+175
src/js/templates/chatJoinLinkEmbed.template.js
··· 1 + import { html } from "/js/lib/lit-html.js"; 2 + import { avatarGroupTemplate } from "/js/templates/avatarGroup.template.js"; 3 + import { infoIconTemplate } from "/js/templates/icons/infoIcon.template.js"; 4 + import { checkIconTemplate } from "/js/templates/icons/checkIcon.template.js"; 5 + import { chevronRightIconTemplate } from "/js/templates/icons/chevronRight.template.js"; 6 + import { userPlusIconTemplate } from "/js/templates/icons/userPlusIcon.template.js"; 7 + import { lockIconTemplate } from "/js/templates/icons/lockIcon.template.js"; 8 + import { linkIconTemplate } from "/js/templates/icons/linkIcon.template.js"; 9 + import { getDisplayName, isAvailableJoinLinkPreview } from "/js/dataHelpers.js"; 10 + import { linkToProfile } from "/js/navigation.js"; 11 + 12 + function deriveJoinLinkAction({ preview, currentConvoId }) { 13 + if (!isAvailableJoinLinkPreview(preview)) return null; 14 + const convoId = preview.convo?.id ?? preview.convoId ?? null; 15 + const isFollowing = preview.owner?.viewer?.followedBy ?? false; 16 + const hasRequested = !convoId && preview.viewer?.requestedAt != null; 17 + 18 + if (convoId && convoId === currentConvoId) { 19 + return { 20 + kind: "copy", 21 + label: "Copy link", 22 + icon: "link", 23 + side: "left", 24 + disabled: false, 25 + }; 26 + } 27 + if (convoId) { 28 + return { 29 + kind: "open", 30 + label: "Open chat", 31 + icon: "arrow-right", 32 + side: "right", 33 + disabled: false, 34 + }; 35 + } 36 + 37 + if (preview.memberCount >= preview.memberLimit) { 38 + return { 39 + kind: "full", 40 + label: "This chat is full", 41 + icon: "hand", 42 + side: "left", 43 + disabled: true, 44 + }; 45 + } 46 + if (preview.joinRule === "followedByOwner" && !isFollowing) { 47 + return { 48 + kind: "follow-required", 49 + label: "Only people the chat owner follows can join", 50 + icon: "hand", 51 + side: "left", 52 + disabled: true, 53 + }; 54 + } 55 + if (hasRequested) { 56 + return { 57 + kind: "requested", 58 + label: "Requested", 59 + icon: "check", 60 + side: "left", 61 + disabled: false, 62 + }; 63 + } 64 + return { 65 + kind: preview.requireApproval ? "request" : "join", 66 + label: preview.requireApproval ? "Request to join" : "Join", 67 + icon: "join", 68 + side: "left", 69 + disabled: false, 70 + }; 71 + } 72 + 73 + const ACTION_ICONS = { 74 + link: linkIconTemplate, 75 + "arrow-right": chevronRightIconTemplate, 76 + join: userPlusIconTemplate, 77 + check: checkIconTemplate, 78 + hand: lockIconTemplate, 79 + }; 80 + 81 + function unavailableTemplate() { 82 + return html`<div 83 + class="chat-join-link-embed-unavailable" 84 + data-testid="join-link-embed-unavailable" 85 + > 86 + ${infoIconTemplate()} 87 + <span>Chat invite link no longer available</span> 88 + </div>`; 89 + } 90 + 91 + function ownerRowTemplate({ owner }) { 92 + const displayName = getDisplayName(owner); 93 + return html`<div 94 + class="chat-join-link-embed-owner" 95 + data-testid="join-link-embed-owner" 96 + > 97 + <span>By </span> 98 + <a 99 + class="chat-join-link-embed-owner-name" 100 + href=${linkToProfile(owner)} 101 + @click=${(event) => event.stopPropagation()} 102 + >${displayName}</a 103 + > 104 + <span class="chat-join-link-embed-owner-handle">@${owner.handle}</span> 105 + </div>`; 106 + } 107 + 108 + function actionButtonTemplate({ action, onClick }) { 109 + if (!action) return null; 110 + const iconFn = ACTION_ICONS[action.icon]; 111 + const icon = iconFn ? iconFn() : null; 112 + return html`<button 113 + class="chat-join-link-action chat-join-link-action-${action.kind}" 114 + data-testid="join-link-embed-action" 115 + data-teststate=${action.kind} 116 + ?disabled=${action.disabled} 117 + @click=${(event) => { 118 + event.stopPropagation(); 119 + if (action.disabled) return; 120 + onClick?.(action.kind); 121 + }} 122 + > 123 + ${action.side === "left" ? icon : null} 124 + <span class="chat-join-link-action-label">${action.label}</span> 125 + ${action.side === "right" ? icon : null} 126 + </button>`; 127 + } 128 + 129 + export function chatJoinLinkEmbedTemplate({ embed, currentConvoId, onClick }) { 130 + const preview = embed?.joinLinkPreview; 131 + 132 + if (!isAvailableJoinLinkPreview(preview)) { 133 + return html`<div 134 + class="chat-join-link-embed embed-card chat-join-link-embed-state-unavailable" 135 + data-testid="join-link-embed" 136 + data-teststate="unavailable" 137 + > 138 + ${unavailableTemplate()} 139 + </div>`; 140 + } 141 + 142 + const action = deriveJoinLinkAction({ preview, currentConvoId }); 143 + const avatarProfiles = preview.convo?.members ?? [preview.owner]; 144 + 145 + return html`<div 146 + class="chat-join-link-embed embed-card" 147 + data-testid="join-link-embed" 148 + data-teststate="available" 149 + > 150 + <div class="chat-join-link-embed-header"> 151 + <div 152 + class="chat-join-link-embed-avatars" 153 + data-testid="join-link-embed-avatars" 154 + > 155 + ${avatarGroupTemplate({ authors: avatarProfiles })} 156 + </div> 157 + <div class="chat-join-link-embed-text"> 158 + <div 159 + class="chat-join-link-embed-name" 160 + data-testid="join-link-embed-name" 161 + > 162 + ${preview.name} 163 + </div> 164 + <div 165 + class="chat-join-link-embed-meta" 166 + data-testid="join-link-embed-meta" 167 + > 168 + Group chat · ${preview.memberCount}/${preview.memberLimit} members 169 + </div> 170 + ${ownerRowTemplate({ owner: preview.owner })} 171 + </div> 172 + </div> 173 + ${actionButtonTemplate({ action, onClick })} 174 + </div>`; 175 + }
+1 -14
src/js/templates/externalLink.template.js
··· 5 5 return new URL(uri).hostname; 6 6 } 7 7 8 - function isGroupChatLink(url) { 9 - try { 10 - const { hostname, pathname } = new URL(url); 11 - return ( 12 - hostname === "bsky.app" && /^\/chat\/[a-zA-Z0-9]{7,10}$/.test(pathname) 13 - ); 14 - } catch { 15 - return false; 16 - } 17 - } 18 - 19 8 export function externalLinkTemplate({ 20 9 url, 21 10 title, 22 - description: rawDescription, 11 + description, 23 12 image, 24 13 lazyLoadImages, 25 14 disableNavigation, 26 15 }) { 27 - // Remporary override for group chat links 28 - const description = isGroupChatLink(url) ? "Group chat" : rawDescription; 29 16 return html`<div class="external-link embed-card" data-testid="external-link"> 30 17 <a 31 18 href="${sanitizeUri(url)}"
+15
src/js/templates/icons/linkIcon.template.js
··· 1 + import { html } from "/js/lib/lit-html.js"; 2 + 3 + export function linkIconTemplate() { 4 + return html`<div class="icon link-icon"> 5 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> 6 + <path 7 + stroke="currentColor" 8 + stroke-linecap="round" 9 + stroke-linejoin="round" 10 + stroke-width="2" 11 + d="M15 8h2c1.333 0 4 .8 4 4s-2.667 4-4 4h-2M9 8H7c-1.333 0-4 .8-4 4s2.667 4 4 4h2m-1-4h8" 12 + /> 13 + </svg> 14 + </div>`; 15 + }
+2
src/js/templates/largePost.template.js
··· 107 107 afterDelete = null, 108 108 afterHide = null, 109 109 pluginService, 110 + groupChatLinkService = null, 110 111 }) { 111 112 if (isBlockedPost(post)) { 112 113 return blockedPostTemplate(); ··· 165 166 embed: post.embed, 166 167 mediaLabel: post.mediaLabel, 167 168 isAuthenticated, 169 + groupChatLinkService, 168 170 })} 169 171 </div>` 170 172 : null}
+32 -1
src/js/templates/postEmbed.template.js
··· 24 24 import "/js/components/gif-player.js"; 25 25 import "/js/components/moderation-warning.js"; 26 26 import "/js/components/image-carousel.js"; 27 + import { chatJoinLinkEmbedTemplate } from "/js/templates/chatJoinLinkEmbed.template.js"; 27 28 28 29 function galleryItemsToImages(items) { 29 30 return (items ?? []) ··· 115 116 quotedPost, 116 117 lazyLoadImages, 117 118 isAuthenticated, 119 + groupChatLinkService = null, 118 120 }) { 119 121 if (!quotedPost) { 120 122 return html`<div class="quoted-post embed-card">Post not found</div>`; ··· 195 197 mediaLabel: quotedPost.mediaLabel, 196 198 lazyLoadImages, 197 199 isAuthenticated, 200 + groupChatLinkService, 198 201 })} 199 202 </div>` 200 203 : ""} ··· 505 508 </div>`; 506 509 } 507 510 508 - function recordEmbedTemplate({ record, lazyLoadImages, isAuthenticated }) { 511 + function recordEmbedTemplate({ 512 + record, 513 + lazyLoadImages, 514 + isAuthenticated, 515 + groupChatLinkService, 516 + }) { 509 517 switch (record.$type) { 510 518 case "app.bsky.embed.record#viewRecord": 511 519 if ( ··· 519 527 quotedPost: record, 520 528 lazyLoadImages, 521 529 isAuthenticated, 530 + groupChatLinkService, 522 531 }); 523 532 // This only happens if the author is blocking the viewer 524 533 case "app.bsky.embed.record#viewBlocked": ··· 546 555 enabledEmbedTypes, 547 556 lazyLoadImages = false, 548 557 isAuthenticated, 558 + currentConvoId = null, 559 + groupChatLinkService = null, 549 560 }) { 550 561 if (enabledEmbedTypes && !enabledEmbedTypes.includes(embed.$type)) { 551 562 return null; ··· 556 567 record: embed.record, 557 568 lazyLoadImages, 558 569 isAuthenticated, 570 + groupChatLinkService, 559 571 }); 560 572 case "app.bsky.embed.recordWithMedia#view": 561 573 return html` ··· 564 576 mediaLabel, 565 577 lazyLoadImages, 566 578 isAuthenticated, 579 + groupChatLinkService, 567 580 })} 568 581 ${recordEmbedTemplate({ 569 582 record: embed.record.record, 570 583 lazyLoadImages, 571 584 isAuthenticated, 585 + groupChatLinkService, 572 586 })} 573 587 `; 574 588 case "app.bsky.embed.video#view": ··· 600 614 external: embed.external, 601 615 lazyLoadImages, 602 616 }); 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 + } 603 634 default: 604 635 console.warn("Embed type not supported: ", embed.$type); 605 636 break;
+8
src/js/templates/postFeed.template.js
··· 45 45 onClickShowMore, 46 46 enableFeedFeedback, 47 47 pluginService, 48 + groupChatLinkService, 48 49 }) { 49 50 const root = reply.root; 50 51 const parent = reply.parent; ··· 68 69 onClickShowMore, 69 70 enableFeedFeedback, 70 71 pluginService, 72 + groupChatLinkService, 71 73 isParent: true, 72 74 })} 73 75 ` ··· 98 100 onClickShowMore, 99 101 enableFeedFeedback, 100 102 pluginService, 103 + groupChatLinkService, 101 104 isParent: true, 102 105 })} 103 106 ` ··· 116 119 onClickShowMore, 117 120 enableFeedFeedback, 118 121 pluginService, 122 + groupChatLinkService, 119 123 }) { 120 124 const post = feedItem.post; 121 125 const reply = feedItem.reply; ··· 149 153 onClickShowMore, 150 154 enableFeedFeedback, 151 155 pluginService, 156 + groupChatLinkService, 152 157 }) 153 158 : ""} 154 159 ${postTemplate({ ··· 167 172 replyToAuthor, 168 173 enableFeedFeedback, 169 174 pluginService, 175 + groupChatLinkService, 170 176 })} 171 177 </div> 172 178 `; ··· 194 200 emptyMessage = null, 195 201 showEndMessage = false, 196 202 pluginService, 203 + groupChatLinkService = null, 197 204 }) { 198 205 if (!feed) { 199 206 return feedSkeletonTemplate(); ··· 239 246 onClickShowMore, 240 247 enableFeedFeedback, 241 248 pluginService, 249 + groupChatLinkService, 242 250 }), 243 251 )} 244 252 </div>`;
+2
src/js/templates/smallPost.template.js
··· 84 84 replyToAuthor = null, 85 85 lazyLoadImages = false, 86 86 pluginService, 87 + groupChatLinkService = null, 87 88 }) { 88 89 if (isBlockedPost(post)) { 89 90 return blockedPostTemplate(); ··· 173 174 mediaLabel: post.mediaLabel, 174 175 lazyLoadImages, 175 176 isAuthenticated, 177 + groupChatLinkService, 176 178 })} 177 179 </div>` 178 180 : null}`}
+2
src/js/views/bookmarks.view.js
··· 15 15 pluginService, 16 16 interactionHandlers, 17 17 mainLayout, 18 + groupChatLinkService, 18 19 }, 19 20 }) { 20 21 await auth.requireAuth(); ··· 50 51 postInteractionHandler, 51 52 emptyMessage: "No saved posts yet!", 52 53 pluginService, 54 + groupChatLinkService, 53 55 })} 54 56 </main> 55 57 `,
+3 -1
src/js/views/chatDetail.view.js
··· 27 27 import "/js/components/reactions-dialog.js"; 28 28 import "/js/components/context-menu.js"; 29 29 import "/js/components/context-menu-item.js"; 30 - 31 30 class ChatDetailView extends View { 32 31 async render({ 33 32 root, ··· 37 36 chatNotificationService, 38 37 identityResolver, 39 38 mainLayout, 39 + groupChatLinkService, 40 40 }, 41 41 }) { 42 42 await auth.requireAuth(); ··· 759 759 ${postEmbedTemplate({ 760 760 embed: message.embed, 761 761 isAuthenticated: true, 762 + currentConvoId: convoId, 763 + groupChatLinkService, 762 764 })} 763 765 </div>` 764 766 : null}
+2
src/js/views/feedDetail.view.js
··· 24 24 pluginService, 25 25 interactionHandlers, 26 26 mainLayout, 27 + groupChatLinkService, 27 28 }, 28 29 }) { 29 30 await auth.requireAuth(); ··· 119 120 onLoadMore: () => loadFeed(), 120 121 postInteractionHandler, 121 122 pluginService, 123 + groupChatLinkService, 122 124 showEndMessage: true, 123 125 })} 124 126 </div>
+2
src/js/views/hashtag.view.js
··· 18 18 pluginService, 19 19 interactionHandlers, 20 20 mainLayout, 21 + groupChatLinkService, 21 22 }, 22 23 }) { 23 24 await auth.requireAuth(); ··· 105 106 enableFeedFeedback: false, 106 107 onLoadMore: () => loadCurrentFeed(), 107 108 pluginService, 109 + groupChatLinkService, 108 110 })} 109 111 </div>`; 110 112 })}
+2
src/js/views/home.view.js
··· 21 21 pluginService, 22 22 interactionHandlers, 23 23 mainLayout, 24 + groupChatLinkService, 24 25 }, 25 26 }) { 26 27 const CURRENT_FEED_URI_STORAGE_KEY = "home-view-currentFeedUri"; ··· 232 233 enableFeedFeedback: acceptsInteractions, 233 234 onLoadMore: () => loadCurrentFeed(), 234 235 pluginService, 236 + groupChatLinkService, 235 237 showEndMessage: true, 236 238 })} 237 239 </div>`;
+2
src/js/views/listDetail.view.js
··· 28 28 pluginService, 29 29 interactionHandlers, 30 30 mainLayout, 31 + groupChatLinkService, 31 32 }, 32 33 }) { 33 34 await auth.requireAuth(); ··· 269 270 onLoadMore: () => loadFeed(), 270 271 postInteractionHandler, 271 272 pluginService, 273 + groupChatLinkService, 272 274 showEndMessage: true, 273 275 })} 274 276 </div>`
+2
src/js/views/notifications.view.js
··· 54 54 pluginService, 55 55 interactionHandlers, 56 56 mainLayout, 57 + groupChatLinkService, 57 58 }, 58 59 }) { 59 60 await auth.requireAuth(); ··· 394 395 postInteractionHandler, 395 396 ignoreMuteWarning: true, 396 397 pluginService, 398 + groupChatLinkService, 397 399 })} 398 400 </div> 399 401 `;
+2
src/js/views/postQuotes.view.js
··· 16 16 pluginService, 17 17 interactionHandlers, 18 18 mainLayout, 19 + groupChatLinkService, 19 20 }, 20 21 }) { 21 22 const { handleOrDid, rkey } = params; ··· 81 82 postInteractionHandler, 82 83 emptyMessage: "No quotes yet.", 83 84 pluginService, 85 + groupChatLinkService, 84 86 })} 85 87 </main>`, 86 88 })}
+5
src/js/views/postThread.view.js
··· 39 39 pluginService, 40 40 interactionHandlers, 41 41 mainLayout, 42 + groupChatLinkService, 42 43 }, 43 44 }) { 44 45 const { handleOrDid, rkey } = params; ··· 200 201 replyContext: getReplyContext(i, numReplies), 201 202 lazyLoadImages, 202 203 pluginService, 204 + groupChatLinkService, 203 205 }); 204 206 })} 205 207 </div>`; ··· 280 282 ignoreMuteWarning: true, 281 283 lazyLoadImages: true, 282 284 pluginService, 285 + groupChatLinkService, 283 286 }), 284 287 )} 285 288 </hidden-replies-section>` ··· 395 398 replyContext, 396 399 ignoreMuteWarning: true, 397 400 pluginService, 401 + groupChatLinkService, 398 402 }), 399 403 }); 400 404 })} ··· 421 425 currentUser, 422 426 isAuthenticated, 423 427 pluginService, 428 + groupChatLinkService, 424 429 isUserPost: currentUser?.did === mainPost?.author?.did, 425 430 postInteractionHandler, 426 431 afterHide: () => {
+2
src/js/views/profile.view.js
··· 34 34 pluginService, 35 35 interactionHandlers, 36 36 mainLayout, 37 + groupChatLinkService, 37 38 }, 38 39 }) { 39 40 const defaultAuthorFeeds = [ ··· 504 505 postInteractionHandler, 505 506 onLoadMore: () => loadAuthorFeed(), 506 507 pluginService, 508 + groupChatLinkService, 507 509 showEndMessage: true, 508 510 })} 509 511 </div>`;
+2
src/js/views/search.view.js
··· 21 21 pluginService, 22 22 interactionHandlers, 23 23 mainLayout, 24 + groupChatLinkService, 24 25 }, 25 26 }) { 26 27 const state = new ReactiveStore("searchView"); ··· 172 173 isUserPost: currentUser?.did === post.author?.did, 173 174 postInteractionHandler, 174 175 pluginService, 176 + groupChatLinkService, 175 177 })} 176 178 </div>`, 177 179 )}
+2
src/oauthScopes.js
··· 136 136 "rpc:chat.bsky.convo.unmuteConvo", 137 137 "rpc:chat.bsky.convo.updateAllRead", 138 138 "rpc:chat.bsky.convo.updateRead", 139 + "rpc:chat.bsky.group.getJoinLinkPreviews", 140 + "rpc:chat.bsky.group.requestJoin", 139 141 ]; 140 142 141 143 const CHAT_OAUTH_REPO_SCOPES = ["repo:chat.bsky.actor.declaration"];
+37 -31
tests/e2e/helpers.js
··· 1 + import { OAUTH_SCOPES } from "../../src/oauthScopes.js"; 1 2 import { userProfile } from "./fixtures.js"; 2 3 3 4 // Hold the pointer down on an element long enough to trigger a long-press ··· 21 22 authServerMetadata: { 22 23 token_endpoint: `https://fake.bsky.social/oauth/token`, 23 24 }, 25 + scope: OAUTH_SCOPES, 24 26 }; 25 27 await page.addInitScript((session) => { 26 28 localStorage.setItem("oauth_session", JSON.stringify(session)); ··· 34 36 // shows as needing re-auth), `expired: true` writes a blob whose access token 35 37 // is already past its expiry so the first request triggers a refresh. 36 38 export async function loginWithAccounts(page, accounts) { 37 - await page.addInitScript((accountsArg) => { 38 - if (sessionStorage.getItem("test-accounts-seeded") === "true") return; 39 - sessionStorage.setItem("test-accounts-seeded", "true"); 40 - const accountEntries = []; 41 - for (const account of accountsArg) { 42 - if (!account.needsReauth) { 43 - const session = { 39 + await page.addInitScript( 40 + ({ accountsArg, scopeArg }) => { 41 + if (sessionStorage.getItem("test-accounts-seeded") === "true") return; 42 + sessionStorage.setItem("test-accounts-seeded", "true"); 43 + const accountEntries = []; 44 + for (const account of accountsArg) { 45 + if (!account.needsReauth) { 46 + const session = { 47 + did: account.did, 48 + serviceEndpoint: "https://fake.bsky.social", 49 + accessToken: "mock-access-token", 50 + refreshToken: "mock-refresh-token", 51 + expiresAt: account.expired 52 + ? Date.now() - 1000 53 + : Date.now() + 3600000 * 24, 54 + clientId: "https://localhost/oauth-client-metadata.json", 55 + authServerMetadata: { 56 + token_endpoint: "https://fake.bsky.social/oauth/token", 57 + }, 58 + scope: scopeArg, 59 + }; 60 + localStorage.setItem( 61 + "oauth_session:" + account.did, 62 + JSON.stringify(session), 63 + ); 64 + } 65 + accountEntries.push({ 44 66 did: account.did, 45 - serviceEndpoint: "https://fake.bsky.social", 46 - accessToken: "mock-access-token", 47 - refreshToken: "mock-refresh-token", 48 - expiresAt: account.expired 49 - ? Date.now() - 1000 50 - : Date.now() + 3600000 * 24, 51 - clientId: "https://localhost/oauth-client-metadata.json", 52 - authServerMetadata: { 53 - token_endpoint: "https://fake.bsky.social/oauth/token", 54 - }, 55 - }; 56 - localStorage.setItem( 57 - "oauth_session:" + account.did, 58 - JSON.stringify(session), 59 - ); 67 + handle: account.handle ?? null, 68 + pdsUrl: "https://fake.bsky.social", 69 + }); 60 70 } 61 - accountEntries.push({ 62 - did: account.did, 63 - handle: account.handle ?? null, 64 - pdsUrl: "https://fake.bsky.social", 65 - }); 66 - } 67 - localStorage.setItem("oauth_accounts", JSON.stringify(accountEntries)); 68 - localStorage.setItem("oauth_current_did", accountsArg[0].did); 69 - }, accounts); 71 + localStorage.setItem("oauth_accounts", JSON.stringify(accountEntries)); 72 + localStorage.setItem("oauth_current_did", accountsArg[0].did); 73 + }, 74 + { accountsArg: accounts, scopeArg: OAUTH_SCOPES }, 75 + ); 70 76 }
+65
tests/e2e/mockServer.js
··· 14 14 this.convos = []; 15 15 this.convoMessages = new Map(); 16 16 this.chatLogs = []; 17 + this.joinLinkPreviews = new Map(); 18 + this.failJoinLinkCodes = new Set(); 17 19 this.createRecordCounter = 0; 18 20 this.interactionPayloads = []; 19 21 this.blobCounter = 0; ··· 173 175 174 176 addPosts(posts) { 175 177 this.posts.push(...posts); 178 + for (const post of posts) { 179 + const preview = post?.embed?.joinLinkPreview; 180 + if (preview?.code) { 181 + this.joinLinkPreviews.set(preview.code, preview); 182 + } 183 + } 176 184 } 177 185 178 186 addPostLikes(postUri, likes) { ··· 231 239 232 240 addConvoMessages(convoId, messages) { 233 241 this.convoMessages.set(convoId, messages); 242 + for (const message of messages) { 243 + const preview = message?.embed?.joinLinkPreview; 244 + if (preview?.code) { 245 + this.joinLinkPreviews.set(preview.code, preview); 246 + } 247 + } 248 + } 249 + 250 + setJoinLinkPreview(code, preview) { 251 + this.joinLinkPreviews.set(code, preview); 252 + } 253 + 254 + failJoinLinkRequest(code) { 255 + this.failJoinLinkCodes.add(code); 234 256 } 235 257 236 258 // Abort all subsequent document navigations (reloads, redirects) so the ··· 698 720 status: 200, 699 721 contentType: "application/json", 700 722 body: JSON.stringify({ message: message || {} }), 723 + }); 724 + }); 725 + 726 + await page.route( 727 + "**/xrpc/chat.bsky.group.getJoinLinkPreviews*", 728 + (route) => { 729 + const url = new URL(route.request().url()); 730 + const codes = url.searchParams.getAll("codes"); 731 + const joinLinkPreviews = codes 732 + .map((code) => this.joinLinkPreviews.get(code)) 733 + .filter(Boolean); 734 + return route.fulfill({ 735 + status: 200, 736 + contentType: "application/json", 737 + body: JSON.stringify({ joinLinkPreviews }), 738 + }); 739 + }, 740 + ); 741 + 742 + await page.route("**/xrpc/chat.bsky.group.requestJoin*", (route) => { 743 + const body = route.request().postDataJSON(); 744 + const code = body?.code; 745 + if (this.failJoinLinkCodes.has(code)) { 746 + return route.fulfill({ 747 + status: 500, 748 + contentType: "application/json", 749 + body: JSON.stringify({ error: "ServerError" }), 750 + }); 751 + } 752 + const preview = this.joinLinkPreviews.get(code); 753 + if (preview?.$type === "chat.bsky.group.defs#joinLinkPreviewView") { 754 + this.joinLinkPreviews.set(code, { 755 + ...preview, 756 + viewer: { 757 + ...(preview.viewer ?? {}), 758 + requestedAt: new Date().toISOString(), 759 + }, 760 + }); 761 + } 762 + return route.fulfill({ 763 + status: 200, 764 + contentType: "application/json", 765 + body: JSON.stringify({ status: "pending" }), 701 766 }); 702 767 }); 703 768
+314
tests/e2e/specs/concerns/postEmbeds.test.js
··· 560 560 await expect(feed).toContainText("@creator1.bsky.social"); 561 561 }); 562 562 563 + test("when logged out, clicking the invite action opens bsky.app in a new tab", async ({ 564 + page, 565 + context, 566 + }) => { 567 + const post = buildPost({ 568 + embed: { 569 + $type: "chat.bsky.embed.joinLink#view", 570 + joinLinkPreview: { 571 + $type: "chat.bsky.group.defs#joinLinkPreviewView", 572 + code: "abcd1234", 573 + name: "Friends of Bsky", 574 + memberCount: 5, 575 + memberLimit: 50, 576 + joinRule: "open", 577 + requireApproval: false, 578 + owner: { 579 + did: "did:plc:owner", 580 + handle: "owner.bsky.social", 581 + displayName: "Owner", 582 + avatar: "", 583 + viewer: {}, 584 + labels: [], 585 + createdAt: "2025-01-01T00:00:00.000Z", 586 + }, 587 + viewer: {}, 588 + }, 589 + }, 590 + }); 591 + const mockServer = new MockServer(); 592 + mockServer.addPosts([post]); 593 + mockServer.setPostThread(post.uri, { 594 + $type: "app.bsky.feed.defs#threadViewPost", 595 + post, 596 + replies: [], 597 + }); 598 + await mockServer.setup(page); 599 + await page.goto(postPath); 600 + 601 + const view = page.locator("#post-detail-view"); 602 + await expect(view.locator('[data-testid="join-link-embed"]')).toBeVisible({ 603 + timeout: 10000, 604 + }); 605 + const popupPromise = context.waitForEvent("page"); 606 + await view.locator('[data-testid="join-link-embed-action"]').click(); 607 + const popup = await popupPromise; 608 + expect(popup.url()).toBe("https://bsky.app/chat/abcd1234"); 609 + await popup.close(); 610 + }); 611 + 612 + test("clicking Join on an invite embed in a post opens the join dialog", async ({ 613 + page, 614 + }) => { 615 + const post = buildPost({ 616 + embed: { 617 + $type: "chat.bsky.embed.joinLink#view", 618 + joinLinkPreview: { 619 + $type: "chat.bsky.group.defs#joinLinkPreviewView", 620 + code: "abcd1234", 621 + name: "Friends of Bsky", 622 + memberCount: 5, 623 + memberLimit: 50, 624 + joinRule: "open", 625 + requireApproval: false, 626 + owner: { 627 + did: "did:plc:owner", 628 + handle: "owner.bsky.social", 629 + displayName: "Owner", 630 + avatar: "", 631 + viewer: {}, 632 + labels: [], 633 + createdAt: "2025-01-01T00:00:00.000Z", 634 + }, 635 + viewer: {}, 636 + }, 637 + }, 638 + }); 639 + await setupSinglePostThread(page, post); 640 + 641 + const view = page.locator("#post-detail-view"); 642 + await view 643 + .locator('[data-testid="join-link-embed-action"]') 644 + .click({ timeout: 10000 }); 645 + await expect( 646 + page.locator('[data-testid="join-group-chat-dialog"]'), 647 + ).toBeVisible(); 648 + }); 649 + 650 + test("renders an available chat invite embed with group name and join action", async ({ 651 + page, 652 + }) => { 653 + const post = buildPost({ 654 + embed: { 655 + $type: "chat.bsky.embed.joinLink#view", 656 + joinLinkPreview: { 657 + $type: "chat.bsky.group.defs#joinLinkPreviewView", 658 + code: "abcd1234", 659 + name: "Friends of Bsky", 660 + memberCount: 5, 661 + memberLimit: 50, 662 + joinRule: "open", 663 + requireApproval: false, 664 + owner: { 665 + did: "did:plc:owner", 666 + handle: "owner.bsky.social", 667 + displayName: "Owner", 668 + avatar: "", 669 + viewer: {}, 670 + labels: [], 671 + createdAt: "2025-01-01T00:00:00.000Z", 672 + }, 673 + viewer: {}, 674 + }, 675 + }, 676 + }); 677 + await setupSinglePostThread(page, post); 678 + 679 + const view = page.locator("#post-detail-view"); 680 + const embed = view.locator('[data-testid="join-link-embed"]'); 681 + await expect(embed).toBeVisible({ timeout: 10000 }); 682 + await expect( 683 + embed.locator('[data-testid="join-link-embed-name"]'), 684 + ).toContainText("Friends of Bsky"); 685 + await expect( 686 + embed.locator('[data-testid="join-link-embed-meta"]'), 687 + ).toContainText("5/50 members"); 688 + await expect( 689 + embed.locator('[data-testid="join-link-embed-action"]'), 690 + ).toHaveAttribute("data-teststate", "join"); 691 + }); 692 + 693 + test("renders a Request to join action when the chat requires approval", async ({ 694 + page, 695 + }) => { 696 + const post = buildPost({ 697 + embed: { 698 + $type: "chat.bsky.embed.joinLink#view", 699 + joinLinkPreview: { 700 + $type: "chat.bsky.group.defs#joinLinkPreviewView", 701 + code: "abcd1234", 702 + name: "Friends of Bsky", 703 + memberCount: 5, 704 + memberLimit: 50, 705 + joinRule: "open", 706 + requireApproval: true, 707 + owner: { 708 + did: "did:plc:owner", 709 + handle: "owner.bsky.social", 710 + displayName: "Owner", 711 + avatar: "", 712 + viewer: {}, 713 + labels: [], 714 + createdAt: "2025-01-01T00:00:00.000Z", 715 + }, 716 + viewer: {}, 717 + }, 718 + }, 719 + }); 720 + await setupSinglePostThread(page, post); 721 + 722 + const view = page.locator("#post-detail-view"); 723 + const action = view.locator('[data-testid="join-link-embed-action"]'); 724 + await expect(action).toHaveAttribute("data-teststate", "request", { 725 + timeout: 10000, 726 + }); 727 + await expect(action).toContainText("Request to join"); 728 + }); 729 + 730 + test("renders disabled state when the chat is full", async ({ page }) => { 731 + const post = buildPost({ 732 + embed: { 733 + $type: "chat.bsky.embed.joinLink#view", 734 + joinLinkPreview: { 735 + $type: "chat.bsky.group.defs#joinLinkPreviewView", 736 + code: "abcd1234", 737 + name: "Friends of Bsky", 738 + memberCount: 50, 739 + memberLimit: 50, 740 + joinRule: "open", 741 + requireApproval: false, 742 + owner: { 743 + did: "did:plc:owner", 744 + handle: "owner.bsky.social", 745 + displayName: "Owner", 746 + avatar: "", 747 + viewer: {}, 748 + labels: [], 749 + createdAt: "2025-01-01T00:00:00.000Z", 750 + }, 751 + viewer: {}, 752 + }, 753 + }, 754 + }); 755 + await setupSinglePostThread(page, post); 756 + 757 + const view = page.locator("#post-detail-view"); 758 + const action = view.locator('[data-testid="join-link-embed-action"]'); 759 + await expect(action).toHaveAttribute("data-teststate", "full", { 760 + timeout: 10000, 761 + }); 762 + await expect(action).toBeDisabled(); 763 + }); 764 + 765 + test("renders the unavailable state for a disabled invite preview", async ({ 766 + page, 767 + }) => { 768 + const post = buildPost({ 769 + embed: { 770 + $type: "chat.bsky.embed.joinLink#view", 771 + joinLinkPreview: { 772 + $type: "chat.bsky.group.defs#disabledJoinLinkPreviewView", 773 + }, 774 + }, 775 + }); 776 + await setupSinglePostThread(page, post); 777 + 778 + const view = page.locator("#post-detail-view"); 779 + await expect( 780 + view.locator('[data-testid="join-link-embed-unavailable"]'), 781 + ).toBeVisible({ timeout: 10000 }); 782 + }); 783 + 784 + test("renders the unavailable state for an unknown preview $type", async ({ 785 + page, 786 + }) => { 787 + const post = buildPost({ 788 + embed: { 789 + $type: "chat.bsky.embed.joinLink#view", 790 + joinLinkPreview: { $type: "chat.bsky.group.defs#someFutureVariant" }, 791 + }, 792 + }); 793 + await setupSinglePostThread(page, post); 794 + 795 + const view = page.locator("#post-detail-view"); 796 + await expect( 797 + view.locator('[data-testid="join-link-embed-unavailable"]'), 798 + ).toBeVisible({ timeout: 10000 }); 799 + }); 800 + 801 + test("upgrades an external embed of a bsky.app chat invite URL into the invite card", async ({ 802 + page, 803 + }) => { 804 + const post = buildPost({ 805 + embed: { 806 + $type: "app.bsky.embed.external#view", 807 + external: { 808 + uri: "https://bsky.app/chat/abcd1234", 809 + title: "Friends of Bsky", 810 + description: "", 811 + thumb: "", 812 + }, 813 + }, 814 + }); 815 + const mockServer = new MockServer(); 816 + mockServer.addPosts([post]); 817 + mockServer.setPostThread(post.uri, { 818 + $type: "app.bsky.feed.defs#threadViewPost", 819 + post, 820 + replies: [], 821 + }); 822 + mockServer.setJoinLinkPreview("abcd1234", { 823 + $type: "chat.bsky.group.defs#joinLinkPreviewView", 824 + code: "abcd1234", 825 + name: "Friends of Bsky", 826 + memberCount: 5, 827 + memberLimit: 50, 828 + joinRule: "open", 829 + requireApproval: false, 830 + owner: { 831 + did: "did:plc:owner", 832 + handle: "owner.bsky.social", 833 + displayName: "Owner", 834 + avatar: "", 835 + viewer: {}, 836 + labels: [], 837 + createdAt: "2025-01-01T00:00:00.000Z", 838 + }, 839 + viewer: {}, 840 + }); 841 + await mockServer.setup(page); 842 + await login(page); 843 + await page.goto(postPath); 844 + 845 + const view = page.locator("#post-detail-view"); 846 + await expect(view.locator('[data-testid="join-link-embed"]')).toBeVisible({ 847 + timeout: 10000, 848 + }); 849 + await expect(view.locator('[data-testid="external-link"]')).toHaveCount(0); 850 + }); 851 + 852 + test("falls back to the plain external embed when the invite preview can't be resolved", async ({ 853 + page, 854 + }) => { 855 + const post = buildPost({ 856 + embed: { 857 + $type: "app.bsky.embed.external#view", 858 + external: { 859 + uri: "https://bsky.app/chat/abcd1234", 860 + title: "Friends of Bsky", 861 + description: "Group chat invite", 862 + thumb: "", 863 + }, 864 + }, 865 + }); 866 + await setupSinglePostThread(page, post); 867 + 868 + const view = page.locator("#post-detail-view"); 869 + await expect(view.locator('[data-testid="external-link"]')).toBeVisible({ 870 + timeout: 10000, 871 + }); 872 + await expect(view.locator('[data-testid="join-link-embed"]')).toHaveCount( 873 + 0, 874 + ); 875 + }); 876 + 563 877 test("renders a list embed with list name and creator handle", async ({ 564 878 page, 565 879 }) => {
+354
tests/e2e/specs/flows/joinLinkEmbed.test.js
··· 1 + import { test, expect } from "../../base.js"; 2 + import { login } from "../../helpers.js"; 3 + import { MockServer } from "../../mockServer.js"; 4 + import { createPost, createConvo, createProfile } from "../../factories.js"; 5 + 6 + const postUri = "at://did:plc:author1/app.bsky.feed.post/joinLink1"; 7 + const postPath = "/profile/author1.bsky.social/post/joinLink1"; 8 + 9 + function makeJoinLinkPreview(overrides = {}) { 10 + return { 11 + $type: "chat.bsky.group.defs#joinLinkPreviewView", 12 + code: "abcd1234", 13 + name: "Friends of Bsky", 14 + memberCount: 5, 15 + memberLimit: 50, 16 + joinRule: "open", 17 + requireApproval: false, 18 + owner: { 19 + did: "did:plc:owner", 20 + handle: "owner.bsky.social", 21 + displayName: "Owner", 22 + avatar: "", 23 + viewer: {}, 24 + labels: [], 25 + createdAt: "2025-01-01T00:00:00.000Z", 26 + }, 27 + viewer: {}, 28 + ...overrides, 29 + }; 30 + } 31 + 32 + function buildInvitePost(preview) { 33 + return createPost({ 34 + uri: postUri, 35 + text: "Join us!", 36 + authorHandle: "author1.bsky.social", 37 + authorDisplayName: "Author One", 38 + embed: { 39 + $type: "chat.bsky.embed.joinLink#view", 40 + joinLinkPreview: preview, 41 + }, 42 + }); 43 + } 44 + 45 + async function setupInvitePostThread(page, preview, { mockServer } = {}) { 46 + const server = mockServer ?? new MockServer(); 47 + const post = buildInvitePost(preview); 48 + server.addPosts([post]); 49 + server.setPostThread(post.uri, { 50 + $type: "app.bsky.feed.defs#threadViewPost", 51 + post, 52 + replies: [], 53 + }); 54 + await server.setup(page); 55 + return server; 56 + } 57 + 58 + test.describe("Join link embed flows", () => { 59 + test.describe("render states", () => { 60 + test("renders open action when viewer is a member of a different chat", async ({ 61 + page, 62 + }) => { 63 + await setupInvitePostThread( 64 + page, 65 + makeJoinLinkPreview({ convo: { id: "other-convo" } }), 66 + ); 67 + await login(page); 68 + await page.goto(postPath); 69 + 70 + await expect( 71 + page.locator('[data-testid="join-link-embed-action"]'), 72 + ).toHaveAttribute("data-teststate", "open", { timeout: 10000 }); 73 + }); 74 + 75 + test("renders follow-required action when joinRule is followedByOwner and viewer doesn't follow", async ({ 76 + page, 77 + }) => { 78 + await setupInvitePostThread( 79 + page, 80 + makeJoinLinkPreview({ joinRule: "followedByOwner" }), 81 + ); 82 + await login(page); 83 + await page.goto(postPath); 84 + 85 + const action = page.locator('[data-testid="join-link-embed-action"]'); 86 + await expect(action).toHaveAttribute( 87 + "data-teststate", 88 + "follow-required", 89 + { 90 + timeout: 10000, 91 + }, 92 + ); 93 + await expect(action).toBeDisabled(); 94 + }); 95 + 96 + test("renders requested action when viewer has already requested", async ({ 97 + page, 98 + }) => { 99 + await setupInvitePostThread( 100 + page, 101 + makeJoinLinkPreview({ 102 + viewer: { requestedAt: "2026-06-01T00:00:00Z" }, 103 + }), 104 + ); 105 + await login(page); 106 + await page.goto(postPath); 107 + 108 + await expect( 109 + page.locator('[data-testid="join-link-embed-action"]'), 110 + ).toHaveAttribute("data-teststate", "requested", { timeout: 10000 }); 111 + }); 112 + }); 113 + 114 + test.describe("click behaviors", () => { 115 + test("clicking copy writes the invite URL to the clipboard and shows a toast", async ({ 116 + page, 117 + browserName, 118 + }) => { 119 + // The copy action only shows when the invite points at the current chat, 120 + // which only happens inside chatDetail. Render an invite embed in a DM 121 + // where the convo id matches. 122 + const mockServer = new MockServer(); 123 + const alice = createProfile({ 124 + did: "did:plc:alice1", 125 + handle: "alice.bsky.social", 126 + displayName: "Alice", 127 + }); 128 + const convo = createConvo({ id: "convo-1", otherMember: alice }); 129 + mockServer.addConvos([convo]); 130 + mockServer.addConvoMessages("convo-1", [ 131 + { 132 + $type: "chat.bsky.convo.defs#messageView", 133 + id: "msg-1", 134 + rev: "rev-msg-1", 135 + text: "", 136 + facets: [], 137 + sender: { did: alice.did }, 138 + sentAt: "2025-01-15T12:00:00.000Z", 139 + reactions: [], 140 + embed: { 141 + $type: "chat.bsky.embed.joinLink#view", 142 + joinLinkPreview: makeJoinLinkPreview({ 143 + convo: { id: "convo-1" }, 144 + }), 145 + }, 146 + }, 147 + ]); 148 + await mockServer.setup(page); 149 + 150 + if (browserName === "chromium") { 151 + await page 152 + .context() 153 + .grantPermissions(["clipboard-read", "clipboard-write"]); 154 + } 155 + 156 + await login(page); 157 + await page.goto("/messages/convo-1"); 158 + 159 + await expect( 160 + page.locator('[data-testid="join-link-embed-action"]'), 161 + ).toHaveAttribute("data-teststate", "copy", { timeout: 10000 }); 162 + await page.locator('[data-testid="join-link-embed-action"]').click(); 163 + 164 + await expect(page.locator('[data-testid="toast"]')).toContainText( 165 + "Copied to clipboard", 166 + ); 167 + 168 + if (browserName === "chromium") { 169 + const clipboardText = await page.evaluate(() => 170 + navigator.clipboard.readText(), 171 + ); 172 + expect(clipboardText).toBe("https://bsky.app/chat/abcd1234"); 173 + } 174 + }); 175 + 176 + test("clicking open navigates to the chat", async ({ page }) => { 177 + const otherProfile = createProfile({ 178 + did: "did:plc:other1", 179 + handle: "other.bsky.social", 180 + displayName: "Other", 181 + }); 182 + const otherConvo = createConvo({ 183 + id: "other-convo", 184 + otherMember: otherProfile, 185 + }); 186 + const mockServer = new MockServer(); 187 + mockServer.addConvos([otherConvo]); 188 + mockServer.addConvoMessages("other-convo", []); 189 + await setupInvitePostThread( 190 + page, 191 + makeJoinLinkPreview({ convo: { id: "other-convo" } }), 192 + { mockServer }, 193 + ); 194 + await login(page); 195 + await page.goto(postPath); 196 + 197 + await expect( 198 + page.locator('[data-testid="join-link-embed-action"]'), 199 + ).toHaveAttribute("data-teststate", "open", { timeout: 10000 }); 200 + await page.locator('[data-testid="join-link-embed-action"]').click(); 201 + 202 + await expect(page).toHaveURL(/\/messages\/other-convo$/, { 203 + timeout: 10000, 204 + }); 205 + await expect(page.locator("#chat-detail-view")).toBeVisible(); 206 + }); 207 + 208 + test("clicking request opens the dialog and confirming shows a success toast", async ({ 209 + page, 210 + }) => { 211 + await setupInvitePostThread( 212 + page, 213 + makeJoinLinkPreview({ requireApproval: true }), 214 + ); 215 + await login(page); 216 + await page.goto(postPath); 217 + 218 + await expect( 219 + page.locator('[data-testid="join-link-embed-action"]'), 220 + ).toHaveAttribute("data-teststate", "request", { timeout: 10000 }); 221 + await page.locator('[data-testid="join-link-embed-action"]').click(); 222 + 223 + await expect( 224 + page.locator('[data-testid="join-group-chat-dialog"]'), 225 + ).toBeVisible(); 226 + await page 227 + .locator('[data-testid="join-group-chat-dialog-confirm"]') 228 + .click(); 229 + 230 + await expect(page.locator('[data-testid="toast"]')).toContainText( 231 + "Request sent", 232 + ); 233 + await expect( 234 + page.locator('[data-testid="join-group-chat-dialog"]'), 235 + ).toHaveCount(0); 236 + }); 237 + 238 + test("clicking requested shows a 'request pending' toast", async ({ 239 + page, 240 + }) => { 241 + await setupInvitePostThread( 242 + page, 243 + makeJoinLinkPreview({ 244 + viewer: { requestedAt: "2026-06-01T00:00:00Z" }, 245 + }), 246 + ); 247 + await login(page); 248 + await page.goto(postPath); 249 + 250 + const action = page.locator('[data-testid="join-link-embed-action"]'); 251 + await expect(action).toHaveAttribute("data-teststate", "requested", { 252 + timeout: 10000, 253 + }); 254 + await action.click(); 255 + 256 + await expect(page.locator('[data-testid="toast"]')).toContainText( 257 + "Request pending", 258 + ); 259 + }); 260 + }); 261 + 262 + test.describe("join confirmation", () => { 263 + test("on success, the action flips to the requested state", async ({ 264 + page, 265 + }) => { 266 + await setupInvitePostThread( 267 + page, 268 + makeJoinLinkPreview({ requireApproval: true }), 269 + ); 270 + await login(page); 271 + await page.goto(postPath); 272 + 273 + const action = page.locator('[data-testid="join-link-embed-action"]'); 274 + await expect(action).toHaveAttribute("data-teststate", "request", { 275 + timeout: 10000, 276 + }); 277 + await action.click(); 278 + 279 + await page 280 + .locator('[data-testid="join-group-chat-dialog-confirm"]') 281 + .click(); 282 + 283 + await expect(action).toHaveAttribute("data-teststate", "requested", { 284 + timeout: 10000, 285 + }); 286 + }); 287 + 288 + test("on failure, the dialog stays open and an error toast appears", async ({ 289 + page, 290 + }) => { 291 + const mockServer = new MockServer(); 292 + mockServer.failJoinLinkRequest("abcd1234"); 293 + await setupInvitePostThread(page, makeJoinLinkPreview(), { mockServer }); 294 + await login(page); 295 + await page.goto(postPath); 296 + 297 + const action = page.locator('[data-testid="join-link-embed-action"]'); 298 + await expect(action).toHaveAttribute("data-teststate", "join", { 299 + timeout: 10000, 300 + }); 301 + await action.click(); 302 + 303 + await page 304 + .locator('[data-testid="join-group-chat-dialog-confirm"]') 305 + .click(); 306 + 307 + await expect(page.locator('[data-testid="toast"]')).toContainText( 308 + "Could not send join request", 309 + ); 310 + await expect( 311 + page.locator('[data-testid="join-group-chat-dialog"]'), 312 + ).toBeVisible(); 313 + }); 314 + 315 + test("cancel button closes the dialog without sending the request", async ({ 316 + page, 317 + }) => { 318 + await setupInvitePostThread(page, makeJoinLinkPreview()); 319 + await login(page); 320 + await page.goto(postPath); 321 + 322 + await page.locator('[data-testid="join-link-embed-action"]').click(); 323 + await expect( 324 + page.locator('[data-testid="join-group-chat-dialog"]'), 325 + ).toBeVisible(); 326 + 327 + await page 328 + .locator('[data-testid="join-group-chat-dialog-cancel"]') 329 + .click(); 330 + 331 + await expect( 332 + page.locator('[data-testid="join-group-chat-dialog"]'), 333 + ).toHaveCount(0); 334 + await expect( 335 + page.locator('[data-testid="join-link-embed-action"]'), 336 + ).toHaveAttribute("data-teststate", "join"); 337 + }); 338 + 339 + test("clicking the backdrop closes the dialog", async ({ page }) => { 340 + await setupInvitePostThread(page, makeJoinLinkPreview()); 341 + await login(page); 342 + await page.goto(postPath); 343 + 344 + await page.locator('[data-testid="join-link-embed-action"]').click(); 345 + const dialog = page.locator('[data-testid="join-group-chat-dialog"]'); 346 + await expect(dialog).toBeVisible(); 347 + 348 + // Click at the very top-left corner (outside the dialog content) 349 + await dialog.click({ position: { x: 2, y: 2 } }); 350 + 351 + await expect(dialog).toHaveCount(0); 352 + }); 353 + }); 354 + });
+131
tests/e2e/specs/views/chatDetail.view.test.js
··· 1206 1206 }); 1207 1207 }); 1208 1208 1209 + test.describe("Chat invite (joinLink) embeds", () => { 1210 + function makeJoinLinkEmbed(overrides = {}) { 1211 + return { 1212 + $type: "chat.bsky.embed.joinLink#view", 1213 + joinLinkPreview: { 1214 + $type: "chat.bsky.group.defs#joinLinkPreviewView", 1215 + code: "abcd1234", 1216 + name: "Friends of Bsky", 1217 + memberCount: 5, 1218 + memberLimit: 50, 1219 + joinRule: "open", 1220 + requireApproval: false, 1221 + owner: { 1222 + did: "did:plc:owner", 1223 + handle: "owner.bsky.social", 1224 + displayName: "Owner", 1225 + avatar: "", 1226 + viewer: {}, 1227 + labels: [], 1228 + createdAt: "2025-01-01T00:00:00.000Z", 1229 + }, 1230 + viewer: {}, 1231 + ...overrides, 1232 + }, 1233 + }; 1234 + } 1235 + 1236 + test("renders an available invite embed with name and join action", async ({ 1237 + page, 1238 + }) => { 1239 + const mockServer = new MockServer(); 1240 + const alice = createProfile({ 1241 + did: "did:plc:alice1", 1242 + handle: "alice.bsky.social", 1243 + displayName: "Alice", 1244 + }); 1245 + const convo = createConvo({ id: "convo-1", otherMember: alice }); 1246 + mockServer.addConvos([convo]); 1247 + mockServer.addConvoMessages("convo-1", [ 1248 + createMessage({ 1249 + id: "msg-1", 1250 + text: "", 1251 + senderDid: alice.did, 1252 + sentAt: "2025-01-15T12:00:00.000Z", 1253 + embed: makeJoinLinkEmbed(), 1254 + }), 1255 + ]); 1256 + await mockServer.setup(page); 1257 + 1258 + await login(page); 1259 + await page.goto("/messages/convo-1"); 1260 + 1261 + const view = page.locator("#chat-detail-view"); 1262 + const embed = view.locator('[data-testid="join-link-embed"]'); 1263 + await expect(embed).toBeVisible({ timeout: 10000 }); 1264 + await expect( 1265 + embed.locator('[data-testid="join-link-embed-name"]'), 1266 + ).toContainText("Friends of Bsky"); 1267 + await expect( 1268 + embed.locator('[data-testid="join-link-embed-action"]'), 1269 + ).toHaveAttribute("data-teststate", "join"); 1270 + }); 1271 + 1272 + test("renders an unavailable card for disabled previews", async ({ 1273 + page, 1274 + }) => { 1275 + const mockServer = new MockServer(); 1276 + const alice = createProfile({ 1277 + did: "did:plc:alice1", 1278 + handle: "alice.bsky.social", 1279 + displayName: "Alice", 1280 + }); 1281 + const convo = createConvo({ id: "convo-1", otherMember: alice }); 1282 + mockServer.addConvos([convo]); 1283 + mockServer.addConvoMessages("convo-1", [ 1284 + createMessage({ 1285 + id: "msg-1", 1286 + text: "", 1287 + senderDid: alice.did, 1288 + sentAt: "2025-01-15T12:00:00.000Z", 1289 + embed: { 1290 + $type: "chat.bsky.embed.joinLink#view", 1291 + joinLinkPreview: { 1292 + $type: "chat.bsky.group.defs#disabledJoinLinkPreviewView", 1293 + }, 1294 + }, 1295 + }), 1296 + ]); 1297 + await mockServer.setup(page); 1298 + 1299 + await login(page); 1300 + await page.goto("/messages/convo-1"); 1301 + 1302 + const view = page.locator("#chat-detail-view"); 1303 + await expect( 1304 + view.locator('[data-testid="join-link-embed-unavailable"]'), 1305 + ).toBeVisible({ timeout: 10000 }); 1306 + }); 1307 + 1308 + test("renders Copy action when invite points at the current chat", async ({ 1309 + page, 1310 + }) => { 1311 + const mockServer = new MockServer(); 1312 + const alice = createProfile({ 1313 + did: "did:plc:alice1", 1314 + handle: "alice.bsky.social", 1315 + displayName: "Alice", 1316 + }); 1317 + const convo = createConvo({ id: "convo-1", otherMember: alice }); 1318 + mockServer.addConvos([convo]); 1319 + mockServer.addConvoMessages("convo-1", [ 1320 + createMessage({ 1321 + id: "msg-1", 1322 + text: "", 1323 + senderDid: alice.did, 1324 + sentAt: "2025-01-15T12:00:00.000Z", 1325 + embed: makeJoinLinkEmbed({ convo: { id: "convo-1" } }), 1326 + }), 1327 + ]); 1328 + await mockServer.setup(page); 1329 + 1330 + await login(page); 1331 + await page.goto("/messages/convo-1"); 1332 + 1333 + const view = page.locator("#chat-detail-view"); 1334 + await expect( 1335 + view.locator('[data-testid="join-link-embed-action"]'), 1336 + ).toHaveAttribute("data-teststate", "copy", { timeout: 10000 }); 1337 + }); 1338 + }); 1339 + 1209 1340 test.describe("Logged-out behavior", () => { 1210 1341 test("should redirect to /login when not authenticated", async ({ 1211 1342 page,
+260
tests/unit/specs/dataHelpers.test.js
··· 34 34 getSystemMessageDisplayText, 35 35 groupReactions, 36 36 isGroupConvo, 37 + isInviteLinkUrl, 38 + getInviteCodeFromUrl, 39 + isAvailableJoinLinkPreview, 40 + getJoinLinkCodeFromEmbed, 41 + getJoinLinkCodesFromPosts, 42 + getJoinLinkCodesFromMessages, 43 + attachJoinLinkPreviewToEmbed, 37 44 } from "/js/dataHelpers.js"; 38 45 39 46 const t = new TestSuite("dataHelpers"); ··· 1764 1771 assertEquals(groups.length, 1); 1765 1772 assertEquals(groups[0].count, 2); 1766 1773 assertEquals(groups[0].senders.length, 2); 1774 + }); 1775 + }); 1776 + 1777 + t.describe("getInviteCodeFromUrl", (it) => { 1778 + it("extracts code from absolute bsky.app URL", () => { 1779 + assertEquals( 1780 + getInviteCodeFromUrl("https://bsky.app/chat/abcd1234"), 1781 + "abcd1234", 1782 + ); 1783 + }); 1784 + 1785 + it("extracts code from relative path", () => { 1786 + assertEquals(getInviteCodeFromUrl("/chat/abcd1234"), "abcd1234"); 1787 + }); 1788 + 1789 + it("ignores query and hash", () => { 1790 + assertEquals(getInviteCodeFromUrl("/chat/abcd1234?ref=x#y"), "abcd1234"); 1791 + }); 1792 + 1793 + it("rejects non-bsky hosts", () => { 1794 + assertEquals( 1795 + getInviteCodeFromUrl("https://example.com/chat/abcd1234"), 1796 + null, 1797 + ); 1798 + }); 1799 + 1800 + it("accepts impro.social hosts", () => { 1801 + assertEquals( 1802 + getInviteCodeFromUrl("https://impro.social/chat/abcd1234"), 1803 + "abcd1234", 1804 + ); 1805 + assertEquals( 1806 + getInviteCodeFromUrl("https://dev.impro.social/chat/abcd1234"), 1807 + "abcd1234", 1808 + ); 1809 + }); 1810 + 1811 + it("rejects malformed codes", () => { 1812 + assertEquals(getInviteCodeFromUrl("/chat/short"), null); 1813 + assertEquals(getInviteCodeFromUrl("/chat/!!!!!!!!"), null); 1814 + }); 1815 + 1816 + it("rejects unrelated paths", () => { 1817 + assertEquals(getInviteCodeFromUrl("/profile/foo"), null); 1818 + assertEquals(getInviteCodeFromUrl(""), null); 1819 + assertEquals(getInviteCodeFromUrl(null), null); 1820 + }); 1821 + }); 1822 + 1823 + t.describe("isInviteLinkUrl", (it) => { 1824 + it("is true for valid invite URLs", () => { 1825 + assertEquals(isInviteLinkUrl("https://bsky.app/chat/abcd1234"), true); 1826 + assertEquals(isInviteLinkUrl("/chat/abcd1234"), true); 1827 + }); 1828 + 1829 + it("is false otherwise", () => { 1830 + assertEquals(isInviteLinkUrl("https://bsky.app/profile/x"), false); 1831 + assertEquals(isInviteLinkUrl(""), false); 1832 + }); 1833 + }); 1834 + 1835 + t.describe("getJoinLinkCodeFromEmbed", (it) => { 1836 + it("returns the code from a chat invite view embed", () => { 1837 + assertEquals( 1838 + getJoinLinkCodeFromEmbed({ 1839 + $type: "chat.bsky.embed.joinLink#view", 1840 + joinLinkPreview: { code: "abcd1234" }, 1841 + }), 1842 + "abcd1234", 1843 + ); 1844 + }); 1845 + 1846 + it("returns null for a chat invite view embed without a code", () => { 1847 + assertEquals( 1848 + getJoinLinkCodeFromEmbed({ 1849 + $type: "chat.bsky.embed.joinLink#view", 1850 + joinLinkPreview: { 1851 + $type: "chat.bsky.group.defs#disabledJoinLinkPreviewView", 1852 + }, 1853 + }), 1854 + null, 1855 + ); 1856 + }); 1857 + 1858 + it("returns the code from an external embed whose URI is an invite link", () => { 1859 + assertEquals( 1860 + getJoinLinkCodeFromEmbed({ 1861 + $type: "app.bsky.embed.external#view", 1862 + external: { uri: "https://bsky.app/chat/abcd1234" }, 1863 + }), 1864 + "abcd1234", 1865 + ); 1866 + }); 1867 + 1868 + it("returns null for an external embed pointing elsewhere", () => { 1869 + assertEquals( 1870 + getJoinLinkCodeFromEmbed({ 1871 + $type: "app.bsky.embed.external#view", 1872 + external: { uri: "https://example.com" }, 1873 + }), 1874 + null, 1875 + ); 1876 + }); 1877 + 1878 + it("returns null for unrelated embed types and falsy input", () => { 1879 + assertEquals( 1880 + getJoinLinkCodeFromEmbed({ $type: "app.bsky.embed.images#view" }), 1881 + null, 1882 + ); 1883 + assertEquals(getJoinLinkCodeFromEmbed(null), null); 1884 + assertEquals(getJoinLinkCodeFromEmbed(undefined), null); 1885 + }); 1886 + }); 1887 + 1888 + t.describe("getJoinLinkCodesFromPosts", (it) => { 1889 + it("collects codes from joinLink and external invite embeds", () => { 1890 + const posts = [ 1891 + { 1892 + embed: { 1893 + $type: "chat.bsky.embed.joinLink#view", 1894 + joinLinkPreview: { code: "aaaaaaa" }, 1895 + }, 1896 + }, 1897 + { 1898 + embed: { 1899 + $type: "app.bsky.embed.external#view", 1900 + external: { uri: "https://bsky.app/chat/bbbbbbb" }, 1901 + }, 1902 + }, 1903 + ]; 1904 + assertEquals(getJoinLinkCodesFromPosts(posts), ["aaaaaaa", "bbbbbbb"]); 1905 + }); 1906 + 1907 + it("skips posts without an embed or with unrelated embeds", () => { 1908 + const posts = [ 1909 + { embed: null }, 1910 + { embed: { $type: "app.bsky.embed.images#view" } }, 1911 + { embed: undefined }, 1912 + undefined, 1913 + ]; 1914 + assertEquals(getJoinLinkCodesFromPosts(posts), []); 1915 + }); 1916 + 1917 + it("returns an empty array for null/undefined input", () => { 1918 + assertEquals(getJoinLinkCodesFromPosts(null), []); 1919 + assertEquals(getJoinLinkCodesFromPosts(undefined), []); 1920 + }); 1921 + }); 1922 + 1923 + t.describe("getJoinLinkCodesFromMessages", (it) => { 1924 + it("collects codes from message join link embeds", () => { 1925 + const messages = [ 1926 + { 1927 + embed: { 1928 + $type: "chat.bsky.embed.joinLink#view", 1929 + joinLinkPreview: { code: "aaaaaaa" }, 1930 + }, 1931 + }, 1932 + { embed: null }, 1933 + { 1934 + embed: { 1935 + $type: "app.bsky.embed.external#view", 1936 + external: { uri: "https://bsky.app/chat/bbbbbbb" }, 1937 + }, 1938 + }, 1939 + ]; 1940 + assertEquals(getJoinLinkCodesFromMessages(messages), [ 1941 + "aaaaaaa", 1942 + "bbbbbbb", 1943 + ]); 1944 + }); 1945 + 1946 + it("returns an empty array for null/undefined input", () => { 1947 + assertEquals(getJoinLinkCodesFromMessages(null), []); 1948 + assertEquals(getJoinLinkCodesFromMessages(undefined), []); 1949 + }); 1950 + }); 1951 + 1952 + t.describe("attachJoinLinkPreviewToEmbed", (it) => { 1953 + const fresh = { 1954 + $type: "chat.bsky.group.defs#joinLinkPreviewView", 1955 + code: "abcd1234", 1956 + name: "Updated", 1957 + }; 1958 + 1959 + it("returns null for unrelated embeds", () => { 1960 + assertEquals( 1961 + attachJoinLinkPreviewToEmbed( 1962 + { $type: "app.bsky.embed.images#view" }, 1963 + fresh, 1964 + ), 1965 + null, 1966 + ); 1967 + }); 1968 + 1969 + it("returns null when the cached preview is the same reference", () => { 1970 + assertEquals( 1971 + attachJoinLinkPreviewToEmbed( 1972 + { $type: "chat.bsky.embed.joinLink#view", joinLinkPreview: fresh }, 1973 + fresh, 1974 + ), 1975 + null, 1976 + ); 1977 + }); 1978 + 1979 + it("attaches a fresh preview to a joinLink embed", () => { 1980 + const updated = attachJoinLinkPreviewToEmbed( 1981 + { 1982 + $type: "chat.bsky.embed.joinLink#view", 1983 + joinLinkPreview: { code: "abcd1234", name: "Stale" }, 1984 + }, 1985 + fresh, 1986 + ); 1987 + assertEquals(updated.$type, "chat.bsky.embed.joinLink#view"); 1988 + assertEquals(updated.joinLinkPreview, fresh); 1989 + }); 1990 + 1991 + it("upgrades an external invite embed into a joinLink embed", () => { 1992 + const updated = attachJoinLinkPreviewToEmbed( 1993 + { 1994 + $type: "app.bsky.embed.external#view", 1995 + external: { uri: "https://bsky.app/chat/abcd1234" }, 1996 + }, 1997 + fresh, 1998 + ); 1999 + assertEquals(updated.$type, "chat.bsky.embed.joinLink#view"); 2000 + assertEquals(updated.joinLinkPreview, fresh); 2001 + }); 2002 + }); 2003 + 2004 + function makeJoinLinkPreview(overrides = {}) { 2005 + return { 2006 + $type: "chat.bsky.group.defs#joinLinkPreviewView", 2007 + code: "abcdefg", 2008 + name: "Friends of Bsky", 2009 + memberCount: 5, 2010 + memberLimit: 50, 2011 + joinRule: "open", 2012 + requireApproval: false, 2013 + owner: { did: "did:plc:owner", handle: "owner.test", viewer: {} }, 2014 + viewer: {}, 2015 + ...overrides, 2016 + }; 2017 + } 2018 + 2019 + t.describe("isAvailableJoinLinkPreview", (it) => { 2020 + it("returns true only for the available variant", () => { 2021 + assert(isAvailableJoinLinkPreview(makeJoinLinkPreview())); 2022 + assert( 2023 + !isAvailableJoinLinkPreview({ 2024 + $type: "chat.bsky.group.defs#disabledJoinLinkPreviewView", 2025 + }), 2026 + ); 1767 2027 }); 1768 2028 }); 1769 2029
+191
tests/unit/specs/templates/chatJoinLinkEmbed.template.test.js
··· 1 + import { TestSuite } from "../../testSuite.js"; 2 + import { assertEquals, assert } from "../../testHelpers.js"; 3 + import { render } from "/js/lib/lit-html.js"; 4 + import { chatJoinLinkEmbedTemplate } from "/js/templates/chatJoinLinkEmbed.template.js"; 5 + 6 + const t = new TestSuite("chatJoinLinkEmbedTemplate"); 7 + 8 + function makeJoinLinkPreview(overrides = {}) { 9 + return { 10 + $type: "chat.bsky.group.defs#joinLinkPreviewView", 11 + code: "abcdefg", 12 + name: "Friends of Bsky", 13 + memberCount: 5, 14 + memberLimit: 50, 15 + joinRule: "open", 16 + requireApproval: false, 17 + owner: { did: "did:plc:owner", handle: "owner.test", viewer: {} }, 18 + viewer: {}, 19 + ...overrides, 20 + }; 21 + } 22 + 23 + function renderEmbed({ preview, currentConvoId = null } = {}) { 24 + const container = document.createElement("div"); 25 + render( 26 + chatJoinLinkEmbedTemplate({ 27 + embed: { 28 + $type: "chat.bsky.embed.joinLink#view", 29 + joinLinkPreview: preview, 30 + }, 31 + currentConvoId, 32 + onClick: () => {}, 33 + }), 34 + container, 35 + ); 36 + return container; 37 + } 38 + 39 + function getActionState(container) { 40 + const action = container.querySelector( 41 + "[data-testid='join-link-embed-action']", 42 + ); 43 + return { 44 + teststate: action?.getAttribute("data-teststate") ?? null, 45 + label: 46 + action?.querySelector(".chat-join-link-action-label")?.textContent ?? 47 + null, 48 + disabled: action?.hasAttribute("disabled") ?? false, 49 + }; 50 + } 51 + 52 + t.describe("chatJoinLinkEmbedTemplate", (it) => { 53 + it("renders the unavailable card when preview is not available", () => { 54 + const container = renderEmbed({ 55 + preview: { $type: "chat.bsky.group.defs#disabledJoinLinkPreviewView" }, 56 + }); 57 + assert( 58 + container.querySelector("[data-testid='join-link-embed-unavailable']") !== 59 + null, 60 + ); 61 + assertEquals( 62 + container.querySelector("[data-testid='join-link-embed']").dataset 63 + .teststate, 64 + "unavailable", 65 + ); 66 + }); 67 + 68 + it("renders the copy action when viewer is already in this chat", () => { 69 + const container = renderEmbed({ 70 + preview: makeJoinLinkPreview({ convo: { id: "convo123" } }), 71 + currentConvoId: "convo123", 72 + }); 73 + const { teststate, label, disabled } = getActionState(container); 74 + assertEquals(teststate, "copy"); 75 + assertEquals(label, "Copy link"); 76 + assertEquals(disabled, false); 77 + }); 78 + 79 + it("renders the open action when viewer is a member of a different chat", () => { 80 + const container = renderEmbed({ 81 + preview: makeJoinLinkPreview({ convo: { id: "convo123" } }), 82 + currentConvoId: "other", 83 + }); 84 + const { teststate, label } = getActionState(container); 85 + assertEquals(teststate, "open"); 86 + assertEquals(label, "Open chat"); 87 + }); 88 + 89 + it("renders the join action when not a member and no approval", () => { 90 + const container = renderEmbed({ preview: makeJoinLinkPreview() }); 91 + const { teststate, label, disabled } = getActionState(container); 92 + assertEquals(teststate, "join"); 93 + assertEquals(label, "Join"); 94 + assertEquals(disabled, false); 95 + }); 96 + 97 + it("renders the request action when requireApproval is true", () => { 98 + const container = renderEmbed({ 99 + preview: makeJoinLinkPreview({ requireApproval: true }), 100 + }); 101 + const { teststate, label } = getActionState(container); 102 + assertEquals(teststate, "request"); 103 + assertEquals(label, "Request to join"); 104 + }); 105 + 106 + it("renders a disabled action when the chat is full", () => { 107 + const container = renderEmbed({ 108 + preview: makeJoinLinkPreview({ memberCount: 50, memberLimit: 50 }), 109 + }); 110 + const { teststate, disabled } = getActionState(container); 111 + assertEquals(teststate, "full"); 112 + assertEquals(disabled, true); 113 + }); 114 + 115 + it("renders a disabled follow-required action when joinRule is followedByOwner and not followed", () => { 116 + const container = renderEmbed({ 117 + preview: makeJoinLinkPreview({ joinRule: "followedByOwner" }), 118 + }); 119 + const { teststate, disabled } = getActionState(container); 120 + assertEquals(teststate, "follow-required"); 121 + assertEquals(disabled, true); 122 + }); 123 + 124 + it("renders the join action when followedByOwner but viewer is followed", () => { 125 + const container = renderEmbed({ 126 + preview: makeJoinLinkPreview({ 127 + joinRule: "followedByOwner", 128 + owner: { 129 + did: "did:plc:owner", 130 + handle: "owner.test", 131 + viewer: { followedBy: "at://x" }, 132 + }, 133 + }), 134 + }); 135 + const { teststate, disabled } = getActionState(container); 136 + assertEquals(teststate, "join"); 137 + assertEquals(disabled, false); 138 + }); 139 + 140 + it("renders the requested action when viewer.requestedAt is set and no convoId", () => { 141 + const container = renderEmbed({ 142 + preview: makeJoinLinkPreview({ 143 + viewer: { requestedAt: "2026-06-26T00:00:00Z" }, 144 + }), 145 + }); 146 + const { teststate, label } = getActionState(container); 147 + assertEquals(teststate, "requested"); 148 + assertEquals(label, "Requested"); 149 + }); 150 + 151 + it("invokes onClick with the action type when the button is clicked", () => { 152 + const container = document.createElement("div"); 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, 164 + ); 165 + container.querySelector("[data-testid='join-link-embed-action']").click(); 166 + assertEquals(received, ["join"]); 167 + }); 168 + 169 + it("does not invoke onClick when the action is disabled", () => { 170 + const container = document.createElement("div"); 171 + 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, 185 + ); 186 + container.querySelector("[data-testid='join-link-embed-action']").click(); 187 + assertEquals(received, []); 188 + }); 189 + }); 190 + 191 + await t.run();
-53
tests/unit/specs/templates/externalLink.template.test.js
··· 188 188 link.dispatchEvent(event); 189 189 assertEquals(event.defaultPrevented, true); 190 190 }); 191 - 192 - it("should override description to 'Group chat' for bsky.app group chat links", () => { 193 - const groupChatUrls = [ 194 - "https://bsky.app/chat/abc1234", 195 - "https://bsky.app/chat/abcDEF123", 196 - "https://bsky.app/chat/abcd123XYZ", 197 - ]; 198 - for (const url of groupChatUrls) { 199 - const container = document.createElement("div"); 200 - render( 201 - externalLinkTemplate({ 202 - url, 203 - title: "Example", 204 - description: "Original description", 205 - }), 206 - container, 207 - ); 208 - assertEquals( 209 - container 210 - .querySelector("[data-testid='external-link-description']") 211 - .textContent.trim(), 212 - "Group chat", 213 - ); 214 - } 215 - }); 216 - 217 - it("should not override description for non-group-chat bsky.app links", () => { 218 - const nonGroupChatUrls = [ 219 - "https://bsky.app/chat/abc", 220 - "https://bsky.app/chat/abcdefghijk", 221 - "https://bsky.app/chat/abc1234/extra", 222 - "https://bsky.app/chat/abc-123", 223 - "https://bsky.app/profile/foo.bsky.social", 224 - "https://example.com/chat/abc1234", 225 - ]; 226 - for (const url of nonGroupChatUrls) { 227 - const container = document.createElement("div"); 228 - render( 229 - externalLinkTemplate({ 230 - url, 231 - title: "Example", 232 - description: "Original description", 233 - }), 234 - container, 235 - ); 236 - assertEquals( 237 - container 238 - .querySelector("[data-testid='external-link-description']") 239 - .textContent.trim(), 240 - "Original description", 241 - ); 242 - } 243 - }); 244 191 }); 245 192 246 193 await t.run();