[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 embedded records in chat messages

Grace Kind (Jul 4, 2026, 3:23 PM -0500) 57968b79 e75da366

+1133 -101
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.17.97", 3 + "version": "0.17.98", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
+59 -2
src/css/style.css
··· 2696 2696 position: relative; 2697 2697 } 2698 2698 2699 + /* Quoted-post cards carry a margin-top for the feed context; inside preview 2700 + containers it would leave the close button flush with the card's top edge. */ 2701 + .post-composer-embed-preview .quoted-post, 2702 + .message-embed-preview .quoted-post { 2703 + margin-top: 0; 2704 + } 2705 + 2699 2706 .embed-preview-close-button { 2700 2707 z-index: 1; 2701 2708 position: absolute; ··· 3327 3334 border: var(--hair) solid var(--post-border-color); 3328 3335 } 3329 3336 3330 - .notification-preview-video .video-preview-play-button { 3337 + .video-preview-play-button { 3331 3338 position: absolute; 3332 3339 top: 50%; 3333 3340 left: 50%; ··· 3338 3345 border-radius: 50%; 3339 3346 } 3340 3347 3341 - .notification-preview-video .video-preview-play-button::after { 3348 + .video-preview-play-button::after { 3342 3349 content: ""; 3343 3350 position: absolute; 3344 3351 top: 50%; ··· 4284 4291 color: var(--text-color-muted); 4285 4292 cursor: pointer; 4286 4293 padding: 4px 8px; 4294 + } 4295 + 4296 + .message-embed-preview { 4297 + position: relative; 4298 + padding: 8px 16px; 4299 + background-color: var(--background-color); 4300 + border-top: 1px solid var(--generic-border-color); 4301 + z-index: var(--message-input-z-index); 4302 + } 4303 + 4304 + .message-embed-preview-pending { 4305 + display: flex; 4306 + align-items: center; 4307 + justify-content: center; 4308 + min-height: 64px; 4309 + border: var(--hair) solid var(--embed-border-color); 4310 + border-radius: var(--embed-border-radius); 4311 + color: var(--text-color-muted); 4312 + } 4313 + 4314 + .quoted-post-condensed .post-text { 4315 + display: -webkit-box; 4316 + -webkit-box-orient: vertical; 4317 + -webkit-line-clamp: 3; 4318 + line-clamp: 3; 4319 + overflow: hidden; 4320 + } 4321 + 4322 + .quoted-post-media-thumbs { 4323 + display: flex; 4324 + gap: 4px; 4325 + margin-top: 8px; 4326 + } 4327 + 4328 + .quoted-post-media-thumb { 4329 + width: 64px; 4330 + height: 64px; 4331 + object-fit: cover; 4332 + border-radius: var(--image-preview-border-radius); 4333 + background-color: var(--generic-border-color); 4334 + border: var(--hair) solid var(--post-border-color); 4335 + } 4336 + 4337 + .quoted-post-media-video { 4338 + position: relative; 4339 + } 4340 + 4341 + .message-embed-preview .embed-preview-close-button { 4342 + right: 24px; 4343 + top: 16px; 4287 4344 } 4288 4345 4289 4346 .message-input-container {
+4 -1
src/js/api.js
··· 710 710 return res.data; 711 711 } 712 712 713 - async sendMessage(convoId, { text, facets, replyTo }) { 713 + async sendMessage(convoId, { text, facets, replyTo, embed }) { 714 714 const message = { text, facets }; 715 715 if (replyTo) { 716 716 message.replyTo = replyTo; 717 + } 718 + if (embed) { 719 + message.embed = embed; 717 720 } 718 721 const res = await this.request("chat.bsky.convo.sendMessage", { 719 722 method: "POST",
+27 -10
src/js/components/chat-input.js
··· 68 68 ); 69 69 } 70 70 71 + handleInput(event) { 72 + this.updateTextareaHeight(); 73 + const textarea = event.target; 74 + this.dispatchEvent( 75 + new CustomEvent("input-change", { 76 + detail: { 77 + text: textarea.value, 78 + inputType: event.inputType || null, 79 + selectionStart: textarea.selectionStart, 80 + selectionEnd: textarea.selectionEnd, 81 + }, 82 + }), 83 + ); 84 + } 85 + 71 86 handleSend() { 72 87 if (this.disabled) return; 73 88 const textarea = this.querySelector(".message-input-field"); 74 - const message = textarea?.value.trim(); 75 - if (message) { 76 - this.dispatchEvent( 77 - new CustomEvent("send", { 78 - detail: { message }, 79 - }), 80 - ); 81 - // Clear input after sending 89 + const message = textarea?.value.trim() ?? ""; 90 + const hasEmbed = this.getAttribute("has-embed") !== null; 91 + if (!message && !hasEmbed) return; 92 + this.dispatchEvent( 93 + new CustomEvent("send", { 94 + detail: { message }, 95 + }), 96 + ); 97 + // Clear input after sending 98 + if (textarea) { 82 99 textarea.value = ""; 83 - this.updateTextareaHeight(); 84 100 } 101 + this.updateTextareaHeight(); 85 102 } 86 103 87 104 handleEmojiButtonClick(event) { ··· 125 142 placeholder="Write a message" 126 143 rows="1" 127 144 ?disabled=${this.disabled} 128 - @input=${() => this.updateTextareaHeight()} 145 + @input=${(e) => this.handleInput(e)} 129 146 @keydown=${(e) => this.handleKeyDown(e)} 130 147 ></textarea> 131 148 <div class="message-input-emoji-wrapper">
+6 -65
src/js/components/post-composer.js
··· 23 23 VideoUploader, 24 24 VideoValidationError, 25 25 } from "/js/videoUtils.js"; 26 - import { IN_APP_LINK_DOMAINS, LINK_CARD_SERVICE_URL } from "/js/config.js"; 26 + import { LINK_CARD_SERVICE_URL } from "/js/config.js"; 27 27 import { recordEmbedTemplate } from "/js/templates/postEmbed.template.js"; 28 - import { createEmbedFromPost } from "/js/dataHelpers.js"; 28 + import { parseRecordLink, resolveRecordFromLink } from "/js/embedHelpers.js"; 29 29 import "/js/components/rich-text-input.js"; 30 30 import "/js/components/image-alt-text-dialog.js"; 31 31 import "/js/components/emoji-picker-dialog.js"; 32 - 33 - // e.g. https://bsky.app/profile/gracekind.net/post/3m63ewg5nws23 34 - const RECORD_LINK_PATTERNS = [ 35 - { 36 - pattern: /^\/profile\/([a-zA-Z0-9:.-]+)\/post\/([a-zA-Z0-9.-]+)$/, 37 - collection: "app.bsky.feed.post", 38 - }, 39 - { 40 - pattern: /^\/profile\/([a-zA-Z0-9:.-]+)\/feed\/([a-zA-Z0-9.-]+)$/, 41 - collection: "app.bsky.feed.generator", 42 - }, 43 - { 44 - pattern: /^\/profile\/([a-zA-Z0-9:.-]+)\/lists\/([a-zA-Z0-9.-]+)$/, 45 - collection: "app.bsky.graph.list", 46 - }, 47 - { 48 - pattern: /^\/profile\/([a-zA-Z0-9:.-]+)\/starter-pack\/([a-zA-Z0-9.-]+)$/, 49 - collection: "app.bsky.graph.starterpack", 50 - }, 51 - { 52 - pattern: /^\/starter-pack\/([a-zA-Z0-9:.-]+)\/([a-zA-Z0-9.-]+)$/, 53 - collection: "app.bsky.graph.starterpack", 54 - }, 55 - ]; 56 - 57 - function parseRecordLink(url) { 58 - try { 59 - const parsedUrl = new URL(url); 60 - if (!IN_APP_LINK_DOMAINS.includes(parsedUrl.hostname)) { 61 - return null; 62 - } 63 - for (const { pattern, collection } of RECORD_LINK_PATTERNS) { 64 - const match = pattern.exec(parsedUrl.pathname); 65 - if (match) { 66 - return { collection, didOrHandle: match[1], rkey: match[2] }; 67 - } 68 - } 69 - return null; 70 - } catch (error) { 71 - return null; 72 - } 73 - } 74 32 75 33 function replyToTemplate({ post }) { 76 34 return html` ··· 477 435 async loadQuotedRecordFromLink() { 478 436 const url = this._quotedRecordUrl; 479 437 try { 480 - const { collection, didOrHandle, rkey } = parseRecordLink(url); 481 - const did = didOrHandle.startsWith("did:") 482 - ? didOrHandle 483 - : await this.identityResolver.resolveHandle(didOrHandle); 484 - const recordUri = `at://${did}/${collection}/${rkey}`; 485 - let record = null; 486 - if (collection === "app.bsky.feed.post") { 487 - const post = await this.dataLayer.declarative.ensurePost(recordUri); 488 - record = createEmbedFromPost(post); 489 - } else if (collection === "app.bsky.feed.generator") { 490 - const view = 491 - await this.dataLayer.declarative.ensureFeedGenerator(recordUri); 492 - record = { ...view, $type: "app.bsky.feed.defs#generatorView" }; 493 - } else if (collection === "app.bsky.graph.list") { 494 - const view = await this.dataLayer.declarative.ensureList(recordUri); 495 - record = { ...view, $type: "app.bsky.graph.defs#listView" }; 496 - } else { 497 - const view = 498 - await this.dataLayer.declarative.ensureStarterPack(recordUri); 499 - record = { ...view, $type: "app.bsky.graph.defs#starterPackViewBasic" }; 500 - } 438 + const record = await resolveRecordFromLink(url, { 439 + identityResolver: this.identityResolver, 440 + dataLayer: this.dataLayer, 441 + }); 501 442 // the embed may have been closed or replaced while the record was loading 502 443 if (this._quotedRecordUrl !== url) return; 503 444 this.quotedRecord = record;
+2 -1
src/js/dataLayer/mutations.js
··· 1018 1018 this.dataStore.$posts.set(post.uri, createNotFoundPost(post.uri)); 1019 1019 } 1020 1020 1021 - async createMessage(convoId, { text, facets, replyTo }) { 1021 + async createMessage(convoId, { text, facets, replyTo, embed }) { 1022 1022 // no optimistic update 1023 1023 const res = await this.api.sendMessage(convoId, { 1024 1024 text, 1025 1025 facets, 1026 1026 replyTo, 1027 + embed, 1027 1028 }); 1028 1029 this.dataStore.$messages.set(res.id, res); 1029 1030 // Add the new message to the chat messages array in the dataStore
+73
src/js/embedHelpers.js
··· 1 + import { IN_APP_LINK_DOMAINS } from "/js/config.js"; 2 + import { createEmbedFromPost } from "/js/dataHelpers.js"; 3 + 4 + // e.g. https://bsky.app/profile/gracekind.net/post/3m63ewg5nws23 5 + const RECORD_LINK_PATTERNS = [ 6 + { 7 + pattern: /^\/profile\/([a-zA-Z0-9:.-]+)\/post\/([a-zA-Z0-9.-]+)$/, 8 + collection: "app.bsky.feed.post", 9 + }, 10 + { 11 + pattern: /^\/profile\/([a-zA-Z0-9:.-]+)\/feed\/([a-zA-Z0-9.-]+)$/, 12 + collection: "app.bsky.feed.generator", 13 + }, 14 + { 15 + pattern: /^\/profile\/([a-zA-Z0-9:.-]+)\/lists\/([a-zA-Z0-9.-]+)$/, 16 + collection: "app.bsky.graph.list", 17 + }, 18 + { 19 + pattern: /^\/profile\/([a-zA-Z0-9:.-]+)\/starter-pack\/([a-zA-Z0-9.-]+)$/, 20 + collection: "app.bsky.graph.starterpack", 21 + }, 22 + { 23 + pattern: /^\/starter-pack\/([a-zA-Z0-9:.-]+)\/([a-zA-Z0-9.-]+)$/, 24 + collection: "app.bsky.graph.starterpack", 25 + }, 26 + ]; 27 + 28 + // E.g. https://bsky.app/profile/gracekind.net/post/3m63ewg5nws23 29 + // -> { collection: "app.bsky.feed.post", didOrHandle: "gracekind.net", rkey: "3m63ewg5nws23" } 30 + export function parseRecordLink(url) { 31 + try { 32 + const parsedUrl = new URL(url); 33 + if (!IN_APP_LINK_DOMAINS.includes(parsedUrl.hostname)) { 34 + return null; 35 + } 36 + for (const { pattern, collection } of RECORD_LINK_PATTERNS) { 37 + const match = pattern.exec(parsedUrl.pathname); 38 + if (match) { 39 + return { collection, didOrHandle: match[1], rkey: match[2] }; 40 + } 41 + } 42 + return null; 43 + } catch (error) { 44 + return null; 45 + } 46 + } 47 + 48 + export async function resolveRecordFromLink( 49 + url, 50 + { identityResolver, dataLayer }, 51 + ) { 52 + const parsedLink = parseRecordLink(url); 53 + if (!parsedLink) { 54 + throw new Error(`Not a record link: ${url}`); 55 + } 56 + const { collection, didOrHandle, rkey } = parsedLink; 57 + const did = didOrHandle.startsWith("did:") 58 + ? didOrHandle 59 + : await identityResolver.resolveHandle(didOrHandle); 60 + const recordUri = `at://${did}/${collection}/${rkey}`; 61 + if (collection === "app.bsky.feed.post") { 62 + const post = await dataLayer.declarative.ensurePost(recordUri); 63 + return createEmbedFromPost(post); 64 + } else if (collection === "app.bsky.feed.generator") { 65 + const view = await dataLayer.declarative.ensureFeedGenerator(recordUri); 66 + return { ...view, $type: "app.bsky.feed.defs#generatorView" }; 67 + } else if (collection === "app.bsky.graph.list") { 68 + const view = await dataLayer.declarative.ensureList(recordUri); 69 + return { ...view, $type: "app.bsky.graph.defs#listView" }; 70 + } 71 + const view = await dataLayer.declarative.ensureStarterPack(recordUri); 72 + return { ...view, $type: "app.bsky.graph.defs#starterPackViewBasic" }; 73 + }
+29
src/js/facetHelpers.js
··· 172 172 return resolvedFacets; 173 173 } 174 174 175 + export function getLinkUrlsFromText(text) { 176 + if (!text) { 177 + return []; 178 + } 179 + return getLinkFacetsFromText(text).map((facet) => facet.features[0].uri); 180 + } 181 + 182 + // Removes the link matching `url` from the text when it sits at the very 183 + // start or end of the message (ignoring surrounding whitespace). Matching is 184 + // facet-based because a typed link without a scheme (e.g. "bsky.app/...") 185 + // produces a normalized facet uri that doesn't appear verbatim in the text. 186 + export function stripLeadingOrTrailingLink(text, url) { 187 + const linkFacets = getLinkFacetsFromText(text).filter( 188 + (facet) => facet.features[0].uri === url, 189 + ); 190 + const totalBytes = getByteLength(text); 191 + for (const facet of linkFacets) { 192 + const before = sliceByByte(text, 0, facet.index.byteStart); 193 + const after = sliceByByte(text, facet.index.byteEnd, totalBytes); 194 + if (before.trim() === "") { 195 + return after.trimStart(); 196 + } 197 + if (after.trim() === "") { 198 + return before.trimEnd(); 199 + } 200 + } 201 + return text; 202 + } 203 + 175 204 export function getTagsFromFacets(facets) { 176 205 return facets.filter( 177 206 (facet) => facet.features[0].$type === "app.bsky.richtext.facet#tag",
+50 -11
src/js/templates/postEmbed.template.js
··· 113 113 return true; 114 114 } 115 115 116 + function condensedMediaTemplate({ embed }) { 117 + if (embed?.$type === "app.bsky.embed.images#view") { 118 + return html`<div class="quoted-post-media-thumbs"> 119 + ${embed.images 120 + .slice(0, 4) 121 + .map( 122 + (image) => 123 + html`<img 124 + class="quoted-post-media-thumb" 125 + src="${image.thumb}" 126 + alt="${image.alt || ""}" 127 + />`, 128 + )} 129 + </div>`; 130 + } 131 + if (embed?.$type === "app.bsky.embed.video#view") { 132 + return html`<div class="quoted-post-media-thumbs"> 133 + <div class="quoted-post-media-video"> 134 + <img 135 + class="quoted-post-media-thumb" 136 + src="${embed.thumbnail}" 137 + alt="${embed.alt || ""}" 138 + /> 139 + <div class="video-preview-play-button"></div> 140 + </div> 141 + </div>`; 142 + } 143 + return ""; 144 + } 145 + 116 146 export function quotedPostTemplate({ 117 147 quotedPost, 118 148 lazyLoadImages, 119 149 isAuthenticated, 150 + condensed = false, 120 151 }) { 121 152 if (!quotedPost) { 122 153 return html`<div class="quoted-post embed-card">Post not found</div>`; ··· 166 197 class="quoted-post-link" 167 198 href=${linkToPost(quotedPost)} 168 199 > 169 - <div class="quoted-post post-content embed-card"> 200 + <div 201 + class="quoted-post post-content embed-card ${condensed 202 + ? "quoted-post-condensed" 203 + : ""}" 204 + > 170 205 ${mutedWrapperTemplate({ 171 206 isMuted, 172 207 label: mutedLabel, ··· 195 230 })} 196 231 </div>` 197 232 : ""} 198 - ${embed && showNestedEmbed(embed) 199 - ? html`<div class="post-embed"> 200 - ${postEmbedTemplate({ 201 - embed: embed, 202 - mediaLabel: quotedPost.mediaLabel, 203 - lazyLoadImages, 204 - isAuthenticated, 205 - })} 206 - </div>` 207 - : ""} 233 + ${embed && condensed 234 + ? condensedMediaTemplate({ embed }) 235 + : embed && showNestedEmbed(embed) 236 + ? html`<div class="post-embed"> 237 + ${postEmbedTemplate({ 238 + embed: embed, 239 + mediaLabel: quotedPost.mediaLabel, 240 + lazyLoadImages, 241 + isAuthenticated, 242 + })} 243 + </div>` 244 + : ""} 208 245 </div> 209 246 `, 210 247 })} ··· 559 596 record, 560 597 lazyLoadImages, 561 598 isAuthenticated, 599 + condensed = false, 562 600 }) { 563 601 switch (record.$type) { 564 602 case "app.bsky.embed.record#viewRecord": ··· 573 611 quotedPost: record, 574 612 lazyLoadImages, 575 613 isAuthenticated, 614 + condensed, 576 615 }); 577 616 // This only happens if the author is blocking the viewer 578 617 case "app.bsky.embed.record#viewBlocked":
+140 -7
src/js/views/chatDetail.view.js
··· 3 3 import { html, render, ref } from "/js/lib/lit-html.js"; 4 4 import { headerTemplate } from "/js/templates/header.template.js"; 5 5 import { richTextTemplate } from "/js/templates/richText.template.js"; 6 - import { getFacetsFromText } from "/js/facetHelpers.js"; 6 + import { 7 + getFacetsFromText, 8 + getLinkUrlsFromText, 9 + stripLeadingOrTrailingLink, 10 + } from "/js/facetHelpers.js"; 7 11 import { auth } from "/js/auth.js"; 8 12 import { 9 13 getDisplayName, ··· 12 16 getSystemMessageDisplayText, 13 17 groupReactions, 14 18 } from "/js/dataHelpers.js"; 19 + import { parseRecordLink, resolveRecordFromLink } from "/js/embedHelpers.js"; 15 20 import { avatarTemplate } from "/js/templates/avatar.template.js"; 16 21 import { avatarGroupTemplate } from "/js/templates/avatarGroup.template.js"; 17 - import { postEmbedTemplate } from "/js/templates/postEmbed.template.js"; 22 + import { 23 + postEmbedTemplate, 24 + recordEmbedTemplate, 25 + } from "/js/templates/postEmbed.template.js"; 18 26 import { CHAT_MESSAGES_PAGE_SIZE } from "/js/config.js"; 19 27 import { showToast } from "/js/toasts.js"; 20 28 import { ··· 55 63 state.$paletteMessageId = new Signal.State(null); 56 64 state.$reactionsDialogMessageId = new Signal.State(null); 57 65 state.$stagedReply = new Signal.State(null); 66 + // null | { url, record, status: "loading" | "ready" | "error" } 67 + state.$stagedRecordEmbed = new Signal.State(null); 68 + const rejectedRecordLinks = new Set(); 58 69 59 70 function setReply(message) { 60 71 if (!message) return; ··· 66 77 state.$stagedReply.set(null); 67 78 } 68 79 80 + function handleComposerInput({ text, inputType }) { 81 + const commit = 82 + text.endsWith(" ") || 83 + text.endsWith("\n") || 84 + inputType === "insertFromPaste"; 85 + const urls = getLinkUrlsFromText(text); 86 + for (const rejectedUrl of rejectedRecordLinks) { 87 + if (!urls.includes(rejectedUrl)) { 88 + rejectedRecordLinks.delete(rejectedUrl); 89 + } 90 + } 91 + if (!commit || state.$stagedRecordEmbed.get()) return; 92 + for (const url of urls) { 93 + if (rejectedRecordLinks.has(url) || !parseRecordLink(url)) continue; 94 + state.$stagedRecordEmbed.set({ url, record: null, status: "loading" }); 95 + loadStagedRecordEmbed(url); 96 + break; 97 + } 98 + } 99 + 100 + async function loadStagedRecordEmbed(url) { 101 + try { 102 + const record = await resolveRecordFromLink(url, { 103 + identityResolver, 104 + dataLayer, 105 + }); 106 + // the embed may have been removed while the record was loading 107 + if (state.$stagedRecordEmbed.get()?.url !== url) return; 108 + state.$stagedRecordEmbed.set({ url, record, status: "ready" }); 109 + } catch (error) { 110 + console.warn("Error loading record embed from link: ", error); 111 + if (state.$stagedRecordEmbed.get()?.url === url) { 112 + state.$stagedRecordEmbed.set({ url, record: null, status: "error" }); 113 + } 114 + } 115 + } 116 + 117 + function clearStagedRecordEmbed() { 118 + const staged = state.$stagedRecordEmbed.get(); 119 + if (staged) { 120 + rejectedRecordLinks.add(staged.url); 121 + } 122 + state.$stagedRecordEmbed.set(null); 123 + } 124 + 125 + function stagedEmbedPreviewTemplate({ staged }) { 126 + const { record, status } = staged; 127 + let body; 128 + if (status === "loading") { 129 + body = html`<div class="message-embed-preview-pending"> 130 + <div class="loading-spinner"></div> 131 + </div>`; 132 + } else if (status === "error") { 133 + body = html`<div class="message-embed-preview-pending"> 134 + Couldn't load embed 135 + </div>`; 136 + } else { 137 + body = html`<div inert> 138 + ${recordEmbedTemplate({ 139 + record, 140 + isAuthenticated: true, 141 + condensed: true, 142 + })} 143 + </div>`; 144 + } 145 + return html`<div 146 + class="message-embed-preview" 147 + data-testid="message-embed-preview" 148 + data-teststate=${status} 149 + > 150 + <button 151 + class="embed-preview-close-button" 152 + type="button" 153 + aria-label="Remove embed" 154 + data-testid="message-embed-preview-remove" 155 + @click=${() => clearStagedRecordEmbed()} 156 + > 157 + <span>×</span> 158 + </button> 159 + ${body} 160 + </div>`; 161 + } 162 + 69 163 function messageReplyPreviewTemplate({ staged, senderProfile }) { 70 164 const senderName = senderProfile 71 165 ? getDisplayName(senderProfile) ··· 468 562 state.$isSendingMessage.set(true); 469 563 const stagedReply = state.$stagedReply.get(); 470 564 try { 471 - const facets = await getFacetsFromText(messageText, identityResolver); 565 + const staged = state.$stagedRecordEmbed.get(); 566 + let text = messageText; 567 + let embed = null; 568 + if (staged) { 569 + let record = staged.record; 570 + if (!record) { 571 + try { 572 + record = await resolveRecordFromLink(staged.url, { 573 + identityResolver, 574 + dataLayer, 575 + }); 576 + } catch (error) { 577 + console.warn("Error resolving record embed at send: ", error); 578 + } 579 + } 580 + if (record) { 581 + text = stripLeadingOrTrailingLink(text, staged.url); 582 + embed = { 583 + $type: "app.bsky.embed.record", 584 + record: { uri: record.uri, cid: record.cid }, 585 + }; 586 + } else if (!text.trim()) { 587 + showToast("Couldn't load embed", { style: "error" }); 588 + return; 589 + } 590 + } 591 + const facets = await getFacetsFromText(text, identityResolver); 472 592 await dataLayer.mutations.createMessage(convoId, { 473 - text: messageText, 593 + text, 474 594 facets, 475 595 replyTo: stagedReply ? { messageId: stagedReply.id } : null, 596 + embed, 476 597 }); 477 598 state.$stagedReply.set(null); 599 + state.$stagedRecordEmbed.set(null); 600 + rejectedRecordLinks.clear(); 478 601 await raf(); 479 602 await raf(); 480 603 scrollToBottom(); ··· 1149 1272 const convoPermalink = getPermalinkForConvo(convoId); 1150 1273 const reactionsDialogMessageId = state.$reactionsDialogMessageId.get(); 1151 1274 const stagedReply = state.$stagedReply.get(); 1275 + const stagedRecordEmbed = state.$stagedRecordEmbed.get(); 1152 1276 const stagedReplySenderProfile = 1153 1277 stagedReply && stagedReply.sender 1154 1278 ? getMemberProfile(convo, stagedReply.sender.did) ··· 1254 1378 senderProfile: stagedReplySenderProfile, 1255 1379 }) 1256 1380 : ""} 1381 + ${stagedRecordEmbed 1382 + ? stagedEmbedPreviewTemplate({ 1383 + staged: stagedRecordEmbed, 1384 + }) 1385 + : ""} 1257 1386 <chat-input 1258 1387 @send=${(e) => handleSendMessage(e.detail.message)} 1388 + @input-change=${(e) => handleComposerInput(e.detail)} 1259 1389 @height-change=${handleInputHeightChange} 1390 + ?has-embed=${!!stagedRecordEmbed} 1260 1391 ?disabled=${!messages || isSendingMessage} 1261 1392 ?loading=${isSendingMessage} 1262 1393 ></chat-input> ··· 1348 1479 return otherMessages[0].sentAt; 1349 1480 }); 1350 1481 1351 - // Clear the staged reply if the conversation becomes locked or disabled 1352 - // (e.g. a lock-status change arrives while the user is composing). 1482 + // Clear the staged reply and record embed if the conversation becomes 1483 + // locked or disabled (e.g. a lock-status change arrives while the user 1484 + // is composing). 1353 1485 pageEffect(root, () => { 1354 - if (!state.$stagedReply.get()) return; 1486 + if (!state.$stagedReply.get() && !state.$stagedRecordEmbed.get()) return; 1355 1487 const convo = dataLayer.derived.$convos.get(convoId); 1356 1488 const groupDetails = convo ? getGroupConvoDetails(convo) : null; 1357 1489 const isLocked = !!groupDetails && groupDetails.lockStatus !== "unlocked"; 1358 1490 if (isLocked || convo?.status === "disabled") { 1359 1491 state.$stagedReply.set(null); 1492 + state.$stagedRecordEmbed.set(null); 1360 1493 } 1361 1494 }); 1362 1495
+13 -2
tests/e2e/mockServer.js
··· 50 50 this.postThreadOthers = new Map(); 51 51 this.postThreads = new Map(); 52 52 this.postThreadDelays = new Map(); 53 + this.postDelays = new Map(); 53 54 this.profileFollowers = new Map(); 54 55 this.profileFollows = new Map(); 55 56 this.knownFollowers = new Map(); ··· 181 182 this.notificationCursor = cursor; 182 183 } 183 184 184 - addPosts(posts) { 185 + addPosts(posts, { delayMs = 0 } = {}) { 185 186 this.posts.push(...posts); 186 187 for (const post of posts) { 187 188 const preview = post?.embed?.joinLinkPreview; 188 189 if (preview?.code) { 189 190 this.joinLinkPreviews.set(preview.code, preview); 191 + } 192 + if (delayMs > 0) { 193 + this.postDelays.set(post.uri, delayMs); 190 194 } 191 195 } 192 196 } ··· 639 643 }); 640 644 }); 641 645 642 - await page.route("**/xrpc/app.bsky.feed.getPosts*", (route) => { 646 + await page.route("**/xrpc/app.bsky.feed.getPosts*", async (route) => { 643 647 const url = new URL(route.request().url()); 644 648 const uris = url.searchParams.getAll("uris"); 649 + const delayMs = Math.max( 650 + 0, 651 + ...uris.map((uri) => this.postDelays.get(uri) ?? 0), 652 + ); 653 + if (delayMs > 0) { 654 + await new Promise((resolve) => setTimeout(resolve, delayMs)); 655 + } 645 656 const posts = uris 646 657 .map((uri) => this.posts.find((p) => p.uri === uri)) 647 658 .filter(Boolean);
+225
tests/e2e/specs/views/chatDetail.view.test.js
··· 6 6 createGroupConvo, 7 7 createMessage, 8 8 createMessageLog, 9 + createPost, 9 10 createProfile, 10 11 createSystemMessage, 11 12 } from "../../factories.js"; ··· 1617 1618 await wrapper.locator('[data-testid="message-more-trigger"]').click(); 1618 1619 await expect( 1619 1620 page.locator('[data-testid="message-action-reply"]'), 1621 + ).toBeVisible(); 1622 + }); 1623 + }); 1624 + 1625 + test.describe("Record link embeds in composer", () => { 1626 + const quotedPostUri = "at://did:plc:author2/app.bsky.feed.post/3quoted"; 1627 + const quotedPostLink = 1628 + "https://bsky.app/profile/did:plc:author2/post/3quoted"; 1629 + 1630 + async function setupConvoWithQuotablePost(page, { postDelayMs = 0 } = {}) { 1631 + const mockServer = new MockServer(); 1632 + const alice = createProfile({ 1633 + did: "did:plc:alice1", 1634 + handle: "alice.bsky.social", 1635 + displayName: "Alice", 1636 + }); 1637 + const convo = createConvo({ 1638 + id: "convo-1", 1639 + otherMember: alice, 1640 + }); 1641 + const quotablePost = createPost({ 1642 + uri: quotedPostUri, 1643 + text: "The quotable post", 1644 + authorHandle: "author2.bsky.social", 1645 + authorDisplayName: "Author Two", 1646 + }); 1647 + mockServer.addConvos([convo]); 1648 + mockServer.addConvoMessages("convo-1", [ 1649 + createMessage({ 1650 + id: "msg-1", 1651 + text: "Hey there!", 1652 + senderDid: alice.did, 1653 + sentAt: "2025-01-15T12:00:00.000Z", 1654 + }), 1655 + ]); 1656 + mockServer.addPosts([quotablePost], { delayMs: postDelayMs }); 1657 + await mockServer.setup(page); 1658 + 1659 + await login(page); 1660 + await page.goto("/messages/convo-1"); 1661 + 1662 + const chatDetailView = page.locator("#chat-detail-view"); 1663 + await expect(chatDetailView.locator(".message-input-field")).toBeVisible({ 1664 + timeout: 10000, 1665 + }); 1666 + return { mockServer, chatDetailView, quotablePost }; 1667 + } 1668 + 1669 + test("stages a preview for a pasted post link and removes it via the close button", async ({ 1670 + page, 1671 + }) => { 1672 + const { chatDetailView } = await setupConvoWithQuotablePost(page); 1673 + 1674 + await chatDetailView 1675 + .locator(".message-input-field") 1676 + .fill(`check this ${quotedPostLink} `); 1677 + 1678 + const preview = chatDetailView.locator( 1679 + '[data-testid="message-embed-preview"]', 1680 + ); 1681 + await expect(preview).toBeVisible(); 1682 + await expect(preview).toContainText("The quotable post"); 1683 + 1684 + await chatDetailView 1685 + .locator('[data-testid="message-embed-preview-remove"]') 1686 + .click(); 1687 + await expect(preview).toHaveCount(0); 1688 + }); 1689 + 1690 + test("stages a preview immediately on paste without a trailing space", async ({ 1691 + page, 1692 + }) => { 1693 + const { chatDetailView } = await setupConvoWithQuotablePost(page); 1694 + 1695 + await chatDetailView 1696 + .locator(".message-input-field") 1697 + .evaluate((textarea, link) => { 1698 + textarea.value = link; 1699 + textarea.dispatchEvent( 1700 + new InputEvent("input", { 1701 + bubbles: true, 1702 + inputType: "insertFromPaste", 1703 + }), 1704 + ); 1705 + }, quotedPostLink); 1706 + 1707 + await expect( 1708 + chatDetailView.locator('[data-testid="message-embed-preview"]'), 1709 + ).toBeVisible(); 1710 + }); 1711 + 1712 + test("sends the message with a record embed and strips the trailing link", async ({ 1713 + page, 1714 + }) => { 1715 + const { mockServer, chatDetailView, quotablePost } = 1716 + await setupConvoWithQuotablePost(page); 1717 + 1718 + await chatDetailView 1719 + .locator(".message-input-field") 1720 + .fill(`check this ${quotedPostLink} `); 1721 + await expect( 1722 + chatDetailView.locator( 1723 + '[data-testid="message-embed-preview"][data-teststate="ready"]', 1724 + ), 1725 + ).toBeVisible(); 1726 + 1727 + await chatDetailView.locator(".message-input-send-button").click(); 1728 + 1729 + await expect.poll(() => mockServer.sentMessageRequests.length).toBe(1); 1730 + const sentMessage = mockServer.sentMessageRequests[0].message; 1731 + expect(sentMessage.text).toBe("check this"); 1732 + expect(sentMessage.embed).toEqual({ 1733 + $type: "app.bsky.embed.record", 1734 + record: { uri: quotablePost.uri, cid: quotablePost.cid }, 1735 + }); 1736 + await expect( 1737 + chatDetailView.locator('[data-testid="message-embed-preview"]'), 1738 + ).toHaveCount(0); 1739 + }); 1740 + 1741 + test("sends an embed-only message when the input contains just the link", async ({ 1742 + page, 1743 + }) => { 1744 + const { mockServer, chatDetailView, quotablePost } = 1745 + await setupConvoWithQuotablePost(page); 1746 + 1747 + await chatDetailView 1748 + .locator(".message-input-field") 1749 + .fill(`${quotedPostLink} `); 1750 + await expect( 1751 + chatDetailView.locator( 1752 + '[data-testid="message-embed-preview"][data-teststate="ready"]', 1753 + ), 1754 + ).toBeVisible(); 1755 + 1756 + await chatDetailView.locator(".message-input-send-button").click(); 1757 + 1758 + await expect.poll(() => mockServer.sentMessageRequests.length).toBe(1); 1759 + const sentMessage = mockServer.sentMessageRequests[0].message; 1760 + expect(sentMessage.text).toBe(""); 1761 + expect(sentMessage.embed.record.uri).toBe(quotablePost.uri); 1762 + }); 1763 + 1764 + test("attaches the embed when sent while the preview is still loading", async ({ 1765 + page, 1766 + }) => { 1767 + const { mockServer, chatDetailView, quotablePost } = 1768 + await setupConvoWithQuotablePost(page, { postDelayMs: 1500 }); 1769 + 1770 + await chatDetailView 1771 + .locator(".message-input-field") 1772 + .fill(`check this ${quotedPostLink} `); 1773 + await expect( 1774 + chatDetailView.locator( 1775 + '[data-testid="message-embed-preview"][data-teststate="loading"]', 1776 + ), 1777 + ).toBeVisible(); 1778 + 1779 + await chatDetailView.locator(".message-input-send-button").click(); 1780 + 1781 + await expect 1782 + .poll(() => mockServer.sentMessageRequests.length, { timeout: 10000 }) 1783 + .toBe(1); 1784 + const sentMessage = mockServer.sentMessageRequests[0].message; 1785 + expect(sentMessage.text).toBe("check this"); 1786 + expect(sentMessage.embed).toEqual({ 1787 + $type: "app.bsky.embed.record", 1788 + record: { uri: quotablePost.uri, cid: quotablePost.cid }, 1789 + }); 1790 + }); 1791 + 1792 + test("shows an error preview for an unresolvable link and sends the message as plain text", async ({ 1793 + page, 1794 + }) => { 1795 + const missingPostLink = 1796 + "https://bsky.app/profile/did:plc:author2/post/3missing"; 1797 + const { mockServer, chatDetailView } = 1798 + await setupConvoWithQuotablePost(page); 1799 + 1800 + await chatDetailView 1801 + .locator(".message-input-field") 1802 + .fill(`look at this ${missingPostLink} `); 1803 + await expect( 1804 + chatDetailView.locator( 1805 + '[data-testid="message-embed-preview"][data-teststate="error"]', 1806 + ), 1807 + ).toBeVisible(); 1808 + 1809 + await chatDetailView.locator(".message-input-send-button").click(); 1810 + 1811 + await expect.poll(() => mockServer.sentMessageRequests.length).toBe(1); 1812 + const sentMessage = mockServer.sentMessageRequests[0].message; 1813 + expect(sentMessage.text).toBe(`look at this ${missingPostLink}`); 1814 + expect(sentMessage.embed).toBe(undefined); 1815 + await expect( 1816 + chatDetailView.locator('[data-testid="message-embed-preview"]'), 1817 + ).toHaveCount(0); 1818 + }); 1819 + 1820 + test("does not send an embed-only message when the embed can't be resolved", async ({ 1821 + page, 1822 + }) => { 1823 + const missingPostLink = 1824 + "https://bsky.app/profile/did:plc:author2/post/3missing"; 1825 + const { mockServer, chatDetailView } = 1826 + await setupConvoWithQuotablePost(page); 1827 + 1828 + const input = chatDetailView.locator(".message-input-field"); 1829 + await input.fill(`${missingPostLink} `); 1830 + await expect( 1831 + chatDetailView.locator( 1832 + '[data-testid="message-embed-preview"][data-teststate="error"]', 1833 + ), 1834 + ).toBeVisible(); 1835 + 1836 + await input.fill(""); 1837 + await chatDetailView.locator(".message-input-send-button").click(); 1838 + 1839 + await expect(page.locator('[data-testid="toast"]')).toBeVisible(); 1840 + expect(mockServer.sentMessageRequests.length).toBe(0); 1841 + await expect( 1842 + chatDetailView.locator( 1843 + '[data-testid="message-embed-preview"][data-teststate="error"]', 1844 + ), 1620 1845 ).toBeVisible(); 1621 1846 }); 1622 1847 });
+27
tests/unit/specs/api.test.js
··· 1014 1014 const body = JSON.parse(options.body); 1015 1015 assertEquals(body.convoId, "convo1"); 1016 1016 assertEquals(body.message.text, "hello"); 1017 + assertEquals(body.message.embed, undefined); 1018 + }); 1019 + 1020 + it("should include embed when provided", async () => { 1021 + const session = createMockSession({ id: "msg1", text: "hello" }); 1022 + const api = new Api(session); 1023 + 1024 + const embed = { 1025 + $type: "app.bsky.embed.record", 1026 + record: { uri: "at://did:plc:abc/app.bsky.feed.post/3abc", cid: "cid1" }, 1027 + }; 1028 + await api.sendMessage("convo1", { text: "hello", facets: [], embed }); 1029 + 1030 + const { options } = session.getLastFetchOptions(); 1031 + const body = JSON.parse(options.body); 1032 + assertEquals(body.message.embed, embed); 1033 + }); 1034 + 1035 + it("should omit embed when null", async () => { 1036 + const session = createMockSession({ id: "msg1", text: "hello" }); 1037 + const api = new Api(session); 1038 + 1039 + await api.sendMessage("convo1", { text: "hello", facets: [], embed: null }); 1040 + 1041 + const { options } = session.getLastFetchOptions(); 1042 + const body = JSON.parse(options.body); 1043 + assertEquals(body.message.embed, undefined); 1017 1044 }); 1018 1045 }); 1019 1046
+67
tests/unit/specs/components/chat-input.test.js
··· 244 244 }); 245 245 }); 246 246 247 + t.describe("ChatInput - embed-only send", (it) => { 248 + it("should dispatch send with an empty message when has-embed is set", () => { 249 + const element = document.createElement("chat-input"); 250 + element.setAttribute("has-embed", ""); 251 + document.body.appendChild(element); 252 + 253 + let receivedMessage = null; 254 + element.addEventListener("send", (e) => { 255 + receivedMessage = e.detail.message; 256 + }); 257 + 258 + const button = element.querySelector(".message-input-send-button"); 259 + button.click(); 260 + 261 + assertEquals(receivedMessage, ""); 262 + }); 263 + }); 264 + 265 + t.describe("ChatInput - input-change event", (it) => { 266 + function setup() { 267 + const element = document.createElement("chat-input"); 268 + document.body.appendChild(element); 269 + const textarea = element.querySelector(".message-input-field"); 270 + const events = []; 271 + element.addEventListener("input-change", (e) => { 272 + events.push(e.detail); 273 + }); 274 + return { element, textarea, events }; 275 + } 276 + 277 + it("emits the current text on input", () => { 278 + const { textarea, events } = setup(); 279 + textarea.value = "hello world"; 280 + textarea.dispatchEvent(new window.InputEvent("input", { bubbles: true })); 281 + 282 + assertEquals(events.length, 1); 283 + assertEquals(events[0].text, "hello world"); 284 + }); 285 + 286 + it("passes the inputType through, defaulting to null", () => { 287 + const { textarea, events } = setup(); 288 + textarea.value = "pasted text"; 289 + textarea.dispatchEvent( 290 + new window.InputEvent("input", { 291 + bubbles: true, 292 + inputType: "insertFromPaste", 293 + }), 294 + ); 295 + textarea.dispatchEvent(new window.InputEvent("input", { bubbles: true })); 296 + 297 + assertEquals(events.length, 2); 298 + assertEquals(events[0].inputType, "insertFromPaste"); 299 + assertEquals(events[1].inputType, null); 300 + }); 301 + 302 + it("includes the selection range", () => { 303 + const { textarea, events } = setup(); 304 + textarea.value = "hello world"; 305 + textarea.setSelectionRange(2, 5); 306 + textarea.dispatchEvent(new window.InputEvent("input", { bubbles: true })); 307 + 308 + assertEquals(events.length, 1); 309 + assertEquals(events[0].selectionStart, 2); 310 + assertEquals(events[0].selectionEnd, 5); 311 + }); 312 + }); 313 + 247 314 t.describe("ChatInput - keyboard handling", (it) => { 248 315 it("should send message on Enter key", () => { 249 316 const element = document.createElement("chat-input");
+26
tests/unit/specs/dataLayer/mutations.test.js
··· 2414 2414 assertEquals(apiCalledWith.replyTo.messageId, "msg-target"); 2415 2415 }); 2416 2416 2417 + it("should pass embed to the api", async () => { 2418 + let apiCalledWith = null; 2419 + const dataStore = new DataStore(); 2420 + const patchStore = new PatchStore(dataStore); 2421 + const mockPreferencesProvider = { 2422 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 2423 + }; 2424 + const mutations = makeMutations( 2425 + { 2426 + sendMessage: async (id, body) => { 2427 + apiCalledWith = body; 2428 + return sentMessage; 2429 + }, 2430 + }, 2431 + dataStore, 2432 + patchStore, 2433 + mockPreferencesProvider, 2434 + ); 2435 + const embed = { 2436 + $type: "app.bsky.embed.record", 2437 + record: { uri: "at://did:plc:abc/app.bsky.feed.post/3abc", cid: "cid1" }, 2438 + }; 2439 + await mutations.createMessage(convoId, { text: "hello", embed }); 2440 + assertEquals(apiCalledWith.embed, embed); 2441 + }); 2442 + 2417 2443 it("should propagate the raw error on send failure", async () => { 2418 2444 const dataStore = new DataStore(); 2419 2445 const patchStore = new PatchStore(dataStore);
+202
tests/unit/specs/embedHelpers.test.js
··· 1 + import { TestSuite } from "../testSuite.js"; 2 + import { assertEquals } from "../testHelpers.js"; 3 + import { parseRecordLink, resolveRecordFromLink } from "/js/embedHelpers.js"; 4 + 5 + const t = new TestSuite("embedHelpers"); 6 + 7 + t.describe("parseRecordLink", (it) => { 8 + it("parses a post link", () => { 9 + assertEquals( 10 + parseRecordLink("https://bsky.app/profile/alice.test/post/3abc"), 11 + { 12 + collection: "app.bsky.feed.post", 13 + didOrHandle: "alice.test", 14 + rkey: "3abc", 15 + }, 16 + ); 17 + }); 18 + 19 + it("parses a feed link", () => { 20 + assertEquals( 21 + parseRecordLink("https://bsky.app/profile/alice.test/feed/cool-feed"), 22 + { 23 + collection: "app.bsky.feed.generator", 24 + didOrHandle: "alice.test", 25 + rkey: "cool-feed", 26 + }, 27 + ); 28 + }); 29 + 30 + it("parses a list link", () => { 31 + assertEquals( 32 + parseRecordLink("https://bsky.app/profile/did:plc:abc/lists/3list"), 33 + { 34 + collection: "app.bsky.graph.list", 35 + didOrHandle: "did:plc:abc", 36 + rkey: "3list", 37 + }, 38 + ); 39 + }); 40 + 41 + it("parses both starter pack link forms", () => { 42 + assertEquals( 43 + parseRecordLink("https://bsky.app/profile/alice.test/starter-pack/3pack"), 44 + { 45 + collection: "app.bsky.graph.starterpack", 46 + didOrHandle: "alice.test", 47 + rkey: "3pack", 48 + }, 49 + ); 50 + assertEquals( 51 + parseRecordLink("https://bsky.app/starter-pack/alice.test/3pack"), 52 + { 53 + collection: "app.bsky.graph.starterpack", 54 + didOrHandle: "alice.test", 55 + rkey: "3pack", 56 + }, 57 + ); 58 + }); 59 + 60 + it("returns null for hosts outside the in-app link domains", () => { 61 + assertEquals( 62 + parseRecordLink("https://example.com/profile/alice.test/post/3abc"), 63 + null, 64 + ); 65 + }); 66 + 67 + it("returns null for unrelated in-app paths", () => { 68 + assertEquals(parseRecordLink("https://bsky.app/profile/alice.test"), null); 69 + assertEquals(parseRecordLink("https://bsky.app/settings"), null); 70 + }); 71 + 72 + it("returns null for invalid urls", () => { 73 + assertEquals(parseRecordLink("not a url"), null); 74 + assertEquals(parseRecordLink(""), null); 75 + }); 76 + }); 77 + 78 + t.describe("resolveRecordFromLink", (it) => { 79 + function makeDeps({ resolveHandleCalls = [] } = {}) { 80 + return { 81 + identityResolver: { 82 + resolveHandle: async (handle) => { 83 + resolveHandleCalls.push(handle); 84 + return "did:plc:resolved1"; 85 + }, 86 + }, 87 + dataLayer: { 88 + declarative: { 89 + ensurePost: async (uri) => ({ 90 + uri, 91 + cid: "postcid", 92 + author: { did: "did:plc:resolved1", handle: "alice.test" }, 93 + record: { 94 + text: "Original post", 95 + createdAt: "2025-01-01T00:00:00Z", 96 + }, 97 + indexedAt: "2025-01-01T00:00:00.000Z", 98 + labels: [], 99 + }), 100 + ensureFeedGenerator: async (uri) => ({ 101 + uri, 102 + cid: "feedcid", 103 + displayName: "Cool Feed", 104 + }), 105 + ensureList: async (uri) => ({ 106 + uri, 107 + cid: "listcid", 108 + name: "Cool List", 109 + }), 110 + ensureStarterPack: async (uri) => ({ 111 + uri, 112 + cid: "packcid", 113 + record: { name: "Cool Pack" }, 114 + }), 115 + }, 116 + }, 117 + }; 118 + } 119 + 120 + it("resolves a post link to a viewRecord embed", async () => { 121 + const record = await resolveRecordFromLink( 122 + "https://bsky.app/profile/alice.test/post/3abc", 123 + makeDeps(), 124 + ); 125 + assertEquals(record.$type, "app.bsky.embed.record#viewRecord"); 126 + assertEquals(record.uri, "at://did:plc:resolved1/app.bsky.feed.post/3abc"); 127 + assertEquals(record.cid, "postcid"); 128 + }); 129 + 130 + it("does not resolve the handle for DID-form urls", async () => { 131 + const resolveHandleCalls = []; 132 + const record = await resolveRecordFromLink( 133 + "https://bsky.app/profile/did:plc:direct1/post/3abc", 134 + makeDeps({ resolveHandleCalls }), 135 + ); 136 + assertEquals(resolveHandleCalls, []); 137 + assertEquals(record.uri, "at://did:plc:direct1/app.bsky.feed.post/3abc"); 138 + }); 139 + 140 + it("tags a feed generator view", async () => { 141 + const record = await resolveRecordFromLink( 142 + "https://bsky.app/profile/alice.test/feed/cool-feed", 143 + makeDeps(), 144 + ); 145 + assertEquals(record.$type, "app.bsky.feed.defs#generatorView"); 146 + assertEquals( 147 + record.uri, 148 + "at://did:plc:resolved1/app.bsky.feed.generator/cool-feed", 149 + ); 150 + }); 151 + 152 + it("tags a list view", async () => { 153 + const record = await resolveRecordFromLink( 154 + "https://bsky.app/profile/alice.test/lists/3list", 155 + makeDeps(), 156 + ); 157 + assertEquals(record.$type, "app.bsky.graph.defs#listView"); 158 + }); 159 + 160 + it("tags a starter pack view", async () => { 161 + const record = await resolveRecordFromLink( 162 + "https://bsky.app/starter-pack/alice.test/3pack", 163 + makeDeps(), 164 + ); 165 + assertEquals(record.$type, "app.bsky.graph.defs#starterPackViewBasic"); 166 + }); 167 + 168 + it("throws an informative error for urls that are not record links", async () => { 169 + let thrown = null; 170 + try { 171 + await resolveRecordFromLink( 172 + "https://example.com/profile/alice.test/post/3abc", 173 + makeDeps(), 174 + ); 175 + } catch (error) { 176 + thrown = error; 177 + } 178 + assertEquals( 179 + thrown?.message, 180 + "Not a record link: https://example.com/profile/alice.test/post/3abc", 181 + ); 182 + }); 183 + 184 + it("propagates resolution failures", async () => { 185 + const deps = makeDeps(); 186 + deps.dataLayer.declarative.ensurePost = async () => { 187 + throw new Error("not found"); 188 + }; 189 + let thrown = null; 190 + try { 191 + await resolveRecordFromLink( 192 + "https://bsky.app/profile/alice.test/post/3abc", 193 + deps, 194 + ); 195 + } catch (error) { 196 + thrown = error; 197 + } 198 + assertEquals(thrown?.message, "not found"); 199 + }); 200 + }); 201 + 202 + await t.run();
+69
tests/unit/specs/facetHelpers.test.js
··· 5 5 resolveFacets, 6 6 getFacetsFromText, 7 7 getTagsFromFacets, 8 + getLinkUrlsFromText, 9 + stripLeadingOrTrailingLink, 8 10 richTextToString, 9 11 } from "/js/facetHelpers.js"; 10 12 ··· 592 594 assertEquals( 593 595 richTextToString(text, facets), 594 596 "héllo https://example.com/x/full", 597 + ); 598 + }); 599 + }); 600 + 601 + t.describe("getLinkUrlsFromText", (it) => { 602 + it("returns urls for links in the text", () => { 603 + assertEquals( 604 + getLinkUrlsFromText("check this https://bsky.app/profile/alice.test"), 605 + ["https://bsky.app/profile/alice.test"], 606 + ); 607 + }); 608 + 609 + it("normalizes scheme-less links to https", () => { 610 + assertEquals( 611 + getLinkUrlsFromText("check bsky.app/profile/alice.test/post/3abc out"), 612 + ["https://bsky.app/profile/alice.test/post/3abc"], 613 + ); 614 + }); 615 + 616 + it("returns an empty array when there are no links", () => { 617 + assertEquals(getLinkUrlsFromText("just some plain text"), []); 618 + assertEquals(getLinkUrlsFromText(""), []); 619 + }); 620 + }); 621 + 622 + t.describe("stripLeadingOrTrailingLink", (it) => { 623 + const url = "https://bsky.app/profile/alice.test/post/3abc"; 624 + 625 + it("strips a leading link", () => { 626 + assertEquals( 627 + stripLeadingOrTrailingLink(`${url} check this out`, url), 628 + "check this out", 629 + ); 630 + }); 631 + 632 + it("strips a trailing link", () => { 633 + assertEquals( 634 + stripLeadingOrTrailingLink(`check this out ${url}`, url), 635 + "check this out", 636 + ); 637 + }); 638 + 639 + it("returns an empty string for link-only text", () => { 640 + assertEquals(stripLeadingOrTrailingLink(url, url), ""); 641 + assertEquals(stripLeadingOrTrailingLink(` ${url} `, url), ""); 642 + }); 643 + 644 + it("leaves a mid-text link in place", () => { 645 + const text = `look at ${url} right there`; 646 + assertEquals(stripLeadingOrTrailingLink(text, url), text); 647 + }); 648 + 649 + it("strips a scheme-less link matching the normalized url", () => { 650 + assertEquals( 651 + stripLeadingOrTrailingLink( 652 + "bsky.app/profile/alice.test/post/3abc so cool", 653 + url, 654 + ), 655 + "so cool", 656 + ); 657 + }); 658 + 659 + it("leaves text unchanged when the url does not match any link", () => { 660 + const text = `check this out ${url}`; 661 + assertEquals( 662 + stripLeadingOrTrailingLink(text, "https://bsky.app/profile/other"), 663 + text, 595 664 ); 596 665 }); 597 666 });
+113 -1
tests/unit/specs/templates/postEmbed.template.test.js
··· 1 1 import { TestSuite } from "../../testSuite.js"; 2 2 import { assert, assertEquals } from "../../testHelpers.js"; 3 - import { postEmbedTemplate } from "/js/templates/postEmbed.template.js"; 3 + import { 4 + postEmbedTemplate, 5 + recordEmbedTemplate, 6 + } from "/js/templates/postEmbed.template.js"; 4 7 import { post } from "../../fixtures.js"; 5 8 import { render } from "/js/lib/lit-html.js"; 6 9 ··· 826 829 assert(link !== null); 827 830 assert(link.textContent.endsWith("...")); 828 831 assert(link.textContent.length < url.length); 832 + }); 833 + }); 834 + 835 + t.describe("recordEmbedTemplate - condensed quoted posts", (it) => { 836 + function makeViewRecord({ embeds } = {}) { 837 + return { 838 + $type: "app.bsky.embed.record#viewRecord", 839 + author: post.author, 840 + value: post.record, 841 + uri: post.uri, 842 + cid: "quotedcid", 843 + ...(embeds ? { embeds } : {}), 844 + }; 845 + } 846 + 847 + function renderRecord(record, { condensed } = {}) { 848 + const container = document.createElement("div"); 849 + render( 850 + recordEmbedTemplate({ record, isAuthenticated: true, condensed }), 851 + container, 852 + ); 853 + return container; 854 + } 855 + 856 + it("renders without the condensed class by default", () => { 857 + const container = renderRecord(makeViewRecord()); 858 + assert(container.querySelector(".quoted-post") !== null); 859 + assertEquals(container.querySelector(".quoted-post-condensed"), null); 860 + }); 861 + 862 + it("adds the condensed class when condensed", () => { 863 + const container = renderRecord(makeViewRecord(), { condensed: true }); 864 + assert(container.querySelector(".quoted-post-condensed") !== null); 865 + }); 866 + 867 + it("renders image thumbnails instead of the full embed when condensed", () => { 868 + const record = makeViewRecord({ 869 + embeds: [ 870 + { 871 + $type: "app.bsky.embed.images#view", 872 + images: [ 873 + { thumb: "thumb1.jpg", fullsize: "full1.jpg", alt: "first" }, 874 + { thumb: "thumb2.jpg", fullsize: "full2.jpg", alt: "" }, 875 + ], 876 + }, 877 + ], 878 + }); 879 + const container = renderRecord(record, { condensed: true }); 880 + const thumbs = container.querySelectorAll(".quoted-post-media-thumb"); 881 + assertEquals(thumbs.length, 2); 882 + assertEquals(thumbs[0].getAttribute("src"), "thumb1.jpg"); 883 + assertEquals(thumbs[0].getAttribute("alt"), "first"); 884 + assertEquals(container.querySelector(".post-embed"), null); 885 + }); 886 + 887 + it("caps condensed image thumbnails at four", () => { 888 + const images = Array.from({ length: 6 }, (unused, index) => ({ 889 + thumb: `thumb${index}.jpg`, 890 + fullsize: `full${index}.jpg`, 891 + alt: "", 892 + })); 893 + const record = makeViewRecord({ 894 + embeds: [{ $type: "app.bsky.embed.images#view", images }], 895 + }); 896 + const container = renderRecord(record, { condensed: true }); 897 + assertEquals( 898 + container.querySelectorAll(".quoted-post-media-thumb").length, 899 + 4, 900 + ); 901 + }); 902 + 903 + it("renders a video thumbnail with a play button when condensed", () => { 904 + const record = makeViewRecord({ 905 + embeds: [ 906 + { 907 + $type: "app.bsky.embed.video#view", 908 + cid: "videocid", 909 + playlist: "playlist.m3u8", 910 + thumbnail: "poster.jpg", 911 + alt: "a video", 912 + }, 913 + ], 914 + }); 915 + const container = renderRecord(record, { condensed: true }); 916 + const thumb = container.querySelector( 917 + ".quoted-post-media-video .quoted-post-media-thumb", 918 + ); 919 + assert(thumb !== null); 920 + assertEquals(thumb.getAttribute("src"), "poster.jpg"); 921 + assert(container.querySelector(".video-preview-play-button") !== null); 922 + }); 923 + 924 + it("skips non-media embeds entirely when condensed", () => { 925 + const record = makeViewRecord({ 926 + embeds: [ 927 + { 928 + $type: "app.bsky.embed.external#view", 929 + external: { 930 + uri: "https://example.com", 931 + title: "Example", 932 + description: "An example site", 933 + }, 934 + }, 935 + ], 936 + }); 937 + const container = renderRecord(record, { condensed: true }); 938 + assertEquals(container.querySelector(".post-embed"), null); 939 + assertEquals(container.querySelector(".quoted-post-media-thumbs"), null); 940 + assertEquals(container.querySelector(".external-link-embed"), null); 829 941 }); 830 942 }); 831 943