Use a paperclip picker and inline send control on desktop and mobile. Show removable attachment previews and grow the message field as users type. Cover preview cleanup and retries, and verify compact layouts, uploads and accessible touch controls in browser QA.
- commit
- 7d1f192de8284d61782dff10b3bfb5600227faff
- parent
- 7a2a81a0b9cc0529ed52e46bd655f16740ed3445
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 52 | ++++++++++++++++++++++-- |
| M | internal/ | 7 | ++- |
| M | internal/ | 30 | +++++++++++-- |
| M | scripts/ | 57 | ++++++++++++++++++++++++++ |
| M | scripts/ | 66 | ++++++++++++++++++++++++++++-- |
@@ -1077,8 +1077,16 @@
10771077 class RoomCompose extends FormElement {10781078 connectedCallback() {10791079 super.connectedCallback();1080+ this.renderFiles();1081+ this.resizeContent();10801082 if (this.keys) return;10811083 this.keys = true;1084+ this.querySelector("[data-attach]")?.addEventListener("click", () => {1085+ this.form.elements.attachments.click();1086+ });1087+ this.addEventListener("input", event => {1088+ if (event.target.name === "content") this.resizeContent();1089+ });10821090 this.addEventListener("keydown", event => {10831091 if (event.key !== "Enter" || event.shiftKey || event.isComposing || !event.target.matches?.("textarea")) return;10841092 event.preventDefault();@@ -1108,10 +1116,26 @@
11081116 event.preventDefault();11091117 this.chooseFiles(event.dataTransfer.files);11101118 });1111- this.renderFiles();11121119 }11131120 1114- disconnectedCallback() { this.uploadController?.abort(); }1121+ disconnectedCallback() {1122+ this.uploadController?.abort();1123+ this.releasePreviews();1124+ }1125+1126+ resizeContent() {1127+ const content = this.form?.elements?.content;1128+ if (!content) return;1129+ content.style.height = "auto";1130+ content.style.height = content.scrollHeight + "px";1131+ }1132+1133+ releasePreviews() {1134+ for (const entry of this.pendingFiles || []) {1135+ if (entry.preview) URL.revokeObjectURL(entry.preview);1136+ delete entry.preview;1137+ }1138+ }11151139 11161140 busy(on) {11171141 super.busy(on);@@ -1141,16 +1165,34 @@
11411165 if (!target) return;11421166 const list = el("ul");11431167 pending.forEach((entry, index) => {1144- const row = el("li"), remove = el("button", "Remove");1168+ const row = el("li"), remove = el("button", "×");11451169 remove.type = "button";11461170 remove.disabled = Boolean(this.submitting);11471171 remove.setAttribute("aria-label", "Remove " + entry.file.name);1172+ remove.title = "Remove " + entry.file.name;11481173 remove.addEventListener("click", () => {11491174 if (this.submitting) return;1175+ if (entry.preview) URL.revokeObjectURL(entry.preview);11501176 this.pendingFiles.splice(index, 1);11511177 this.renderFiles();1178+ const next = target.querySelectorAll("button")[Math.min(index, this.pendingFiles.length - 1)];1179+ (next || this.querySelector("[data-attach]"))?.focus();11521180 });1153- row.append(el("span", entry.file.name), " ", el("small", entry.descriptor ? "uploaded" : Math.ceil(entry.file.size / 1024) + " KB"), " ", remove);1181+ let preview;1182+ if (entry.file.type.startsWith("image/") && entry.file.type !== "image/svg+xml") {1183+ entry.preview ||= URL.createObjectURL(entry.file);1184+ preview = el("img");1185+ preview.src = entry.preview;1186+ preview.alt = "";1187+ } else {1188+ preview = el("span", entry.file.type.startsWith("video/") ? "Video" : entry.file.type.startsWith("audio/") ? "Audio" : "File");1189+ preview.dataset.preview = "";1190+ preview.setAttribute("aria-hidden", "true");1191+ }1192+ const name = el("span", entry.file.name);1193+ name.title = entry.file.name;1194+ const size = entry.file.size < 1024 * 1024 ? Math.ceil(entry.file.size / 1024) + " KB" : (entry.file.size / (1024 * 1024)).toFixed(1) + " MB";1195+ row.append(preview, name, el("small", entry.descriptor ? "Uploaded" : size), remove);11541196 list.append(row);11551197 });11561198 target.replaceChildren(...(pending.length ? [list] : []));@@ -1218,8 +1260,10 @@
12181260 this.report("Signing…");12191261 const event = await signAndPublish(this.event(content, files), controller.signal);12201262 form.reset();1263+ this.releasePreviews();12211264 this.pendingFiles = [];12221265 this.renderFiles();1266+ this.resizeContent();12231267 this.report("");12241268 roomAppend(event, {room: this.getAttribute("room"), inThread: event.kind === 12, own: true});12251269 } finally { this.uploadController = null; }@@ -31,7 +31,7 @@
3131 {{template "roomMessage" .}}{{else}}<p id="empty">No messages yet.</p>{{end}}3232 </div>3333 {{if not (.Query.Get "cursor")}}<room-live room="{{$id}}"></room-live>34-{{if .Actor}}<room-compose room="{{$id}}" pubkey="{{.Actor}}"><textarea name="content" rows="2" placeholder="Message #{{$id}}. Mention with @npub or @hex." required></textarea>{{if .Policy.Features.Files}}{{template "roomFilePicker" .}}{{end}}<p><button>Send</button></p></room-compose>34+{{if .Actor}}<room-compose room="{{$id}}" pubkey="{{.Actor}}">{{if .Policy.Features.Files}}<room-files aria-label="Attachments" aria-live="polite"></room-files>{{end}}<room-compose-bar>{{if .Policy.Features.Files}}{{template "roomFilePicker" .}}{{end}}<textarea name="content" rows="1" aria-label="Message" placeholder="Message…" title="Mention someone with @npub or @hex" required></textarea><button type="submit" aria-label="Send" title="Send message">{{template "roomSendIcon" .}}</button></room-compose-bar></room-compose>3535 <noscript><p>Sending needs JavaScript and a connected signer.</p></noscript>3636 {{else}}<p id="guest"><a href="/signin">Sign in</a> to send messages.</p>3737 {{end}}{{end}}</section>@@ -50,13 +50,14 @@
5050 </div>5151 {{with index $data "next_cursor"}}<p id="pages"><a href="/rooms/{{$id}}/thread/{{$root.ID}}?cursor={{urlquery (str .)}}">load earlier</a></p>{{end}}5252 {{if not (.Query.Get "cursor")}}<room-live room="{{$id}}" root="{{$root.ID}}"></room-live>53-{{if .Actor}}<room-compose room="{{$id}}" pubkey="{{.Actor}}" kind="12" root="{{$root.ID}}" root-pubkey="{{$root.PubKey}}"><textarea name="content" rows="2" placeholder="Reply in this thread" required></textarea>{{if .Policy.Features.Files}}{{template "roomFilePicker" .}}{{end}}<p><button>Reply</button></p></room-compose>53+{{if .Actor}}<room-compose room="{{$id}}" pubkey="{{.Actor}}" kind="12" root="{{$root.ID}}" root-pubkey="{{$root.PubKey}}">{{if .Policy.Features.Files}}<room-files aria-label="Attachments" aria-live="polite"></room-files>{{end}}<room-compose-bar>{{if .Policy.Features.Files}}{{template "roomFilePicker" .}}{{end}}<textarea name="content" rows="1" aria-label="Reply" placeholder="Reply in this thread…" required></textarea><button type="submit" aria-label="Reply" title="Send reply">{{template "roomSendIcon" .}}</button></room-compose-bar></room-compose>5454 <noscript><p>Replying needs JavaScript and a connected signer.</p></noscript>5555 {{else}}<p id="guest"><a href="/signin">Sign in</a> to reply.</p>5656 {{end}}{{end}}</section>5757 {{end}}{{end}}5858 59-{{define "roomFilePicker"}}<label>Attach files <input type="file" name="attachments" multiple></label><small>Paste or drop files. Up to 8 files, 32 MiB each.</small><room-files aria-live="polite"></room-files>{{end}}59+{{define "roomFilePicker"}}<button type="button" data-attach aria-label="Attach files" title="Attach files, or paste or drop them. Up to 8 files, 32 MiB each."><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21 11-8.5 8.5a6 6 0 0 1-8.5-8.5l9-9a4 4 0 0 1 5.7 5.7l-9 9a2 2 0 0 1-2.8-2.8l8.5-8.5"/></svg></button><input type="file" name="attachments" aria-label="Attach files" multiple hidden>{{end}}60+{{define "roomSendIcon"}}<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19V5m-7 7 7-7 7 7"/></svg>{{end}}6061 6162 {{/* One message. room-live builds the same markup for messages that arrive on the stream. */}}6263 {{define "roomMessage"}}<room-message id="msg-{{.ID}}" data-id="{{.ID}}" data-kind="{{.Kind}}" data-updated-at="{{.UpdatedAt}}" data-pubkey="{{.PubKey}}"{{if .Agent}} data-agent{{end}}{{if .Notice}} data-notice{{end}}{{if .Edited}} data-edited{{end}}>@@ -294,11 +294,31 @@ room-message[data-agent]>header b::after{content:" agent";color:var(--bark);font
294294 room-message[data-notice]>p{color:var(--muted);font-size:13px}295295 room-live{display:block;font:11px var(--mono);color:var(--muted);text-align:right}room-live:empty{display:none}296296 room-compose{display:block;position:sticky;bottom:0;margin:8px -22px 0;padding:10px 22px 8px;background:var(--paper);border-top:1px solid var(--line)}297-room-compose textarea{min-height:3.2em;width:100%;font:14px var(--sans);max-width:720px;display:block}298-room-compose p{margin:6px 0 0}room-compose button{margin-right:6px}299-room-compose>form>label{display:inline-block;margin:6px 12px 0 0;font:12px var(--mono);color:var(--muted)}room-compose input[type=file]{max-width:100%;font:12px var(--mono)}room-compose>form>small{display:block;color:var(--muted);font:12px var(--sans);margin-top:4px}300-room-compose[data-over]{outline:2px dashed var(--accent);outline-offset:-4px}room-files{display:block}room-files ul{list-style:none;margin:6px 0 0;padding:0}room-files li{margin:4px 0;min-height:26px;font:12px var(--mono)}room-files li::after{content:"";display:block;clear:both}room-files li>span{display:inline-block;vertical-align:middle;overflow-wrap:anywhere;max-width:calc(100% - 145px)}room-files small{color:var(--muted);white-space:nowrap}room-files button{float:right;font-size:11px}297+room-compose-bar{display:block;position:relative;padding:4px 52px 4px 8px;border:1px solid var(--line);border-radius:16px;background:var(--rail)}298+room-compose-bar:has([data-attach]){padding-left:48px}299+room-compose-bar:focus-within{border-color:var(--muted)}300+room-compose textarea{display:block;width:100%;height:44px;min-height:44px;max-height:144px;max-height:min(144px,30dvh);padding:11px 4px;border:0;outline:none;resize:none;background:transparent;font:16px/22px var(--sans)}301+room-compose textarea::placeholder{color:var(--muted);opacity:1}302+room-compose-bar>button{position:absolute;bottom:4px;width:44px;height:44px;padding:11px;border:0;border-radius:12px;line-height:0}303+room-compose-bar>button[data-attach]{left:4px;background:transparent;color:var(--muted)}304+room-compose-bar>button[type=submit]{right:4px;background:var(--accent);color:var(--paper)}305+room-compose-bar>button:hover{filter:brightness(.9)}306+room-compose-bar>button:disabled{opacity:.55;filter:none}307+room-compose button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}308+room-compose [hidden]{display:none}309+room-compose[data-over] room-compose-bar{outline:2px dashed var(--accent);outline-offset:2px;background:var(--soft)}310+room-files{display:block;overflow-x:auto;overscroll-behavior-x:contain}311+room-files:empty{display:none}312+room-files ul{list-style:none;white-space:nowrap;margin:0;padding:0 0 8px}313+room-files li{display:inline-block;position:relative;vertical-align:top;box-sizing:border-box;width:232px;max-width:100%;height:64px;margin-right:8px;padding:10px 40px 10px 62px;border:1px solid var(--line);border-radius:12px;background:var(--rail);font:13px/21px var(--sans)}314+room-files li:last-child{margin-right:0}315+room-files img,room-files li>span[data-preview]{position:absolute;left:8px;top:8px;box-sizing:border-box;width:46px;height:46px;border-radius:6px;object-fit:cover;background:var(--soft)}316+room-files li>span[data-preview]{text-align:center;color:var(--accent);font:10px/46px var(--mono);text-transform:uppercase}317+room-files li>span:not([data-preview]),room-files small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}318+room-files small{color:var(--muted);font-size:11px}319+room-files button{position:absolute;right:0;top:0;width:44px;height:44px;padding:0;border:0;border-radius:0 12px;background:transparent;color:var(--muted);font:22px var(--sans)}301320 room-compose output,room-create output,room-action output{display:block;font:12px var(--mono);color:var(--muted);margin-top:4px;overflow-wrap:anywhere}321+room-compose output:empty{display:none}302322 room-compose output[data-error],room-create output[data-error],room-action output[data-error]{color:var(--alert)}303323 room-create{display:block;max-width:720px;border-bottom:1px solid var(--line);padding:10px 0}304324 room-create label{display:inline-block;margin:0 10px 6px 0;vertical-align:top;font:13px var(--sans);color:var(--muted)}@@ -333,6 +353,6 @@ html,body{overflow:auto}
333353 #grid,#gridhead,h2:has(+#gridhead){display:none}334354 /* Phones: each wiki row reads as a title line and one muted line of facts. */335355 #wiki-list #rows thead{display:none}#wiki-list #rows,#wiki-list #rows tbody,#wiki-list #rows tr{display:block;width:auto}#wiki-list #rows tr{padding:8px 0;border-bottom:1px solid var(--line)}#wiki-list #rows td{display:inline;padding:0;border:0;width:auto;height:auto;line-height:1.4;font:12px var(--mono);color:var(--muted)}#wiki-list #rows td:first-child{display:block;font:14px var(--sans);color:var(--ink);margin:0 0 2px}#wiki-list #rows td:first-child small{display:block;margin:2px 0 0}#wiki-list #rows td:nth-child(3){display:none}#wiki-list #rows td:nth-child(2)::after,#wiki-list #rows td:nth-child(4)::after{content:" | "}336-room-compose{bottom:calc(28px + env(safe-area-inset-bottom,0px));margin:8px -2.5ch 0;padding:10px 2.5ch 8px;z-index:1}356+room-compose{bottom:calc(28px + env(safe-area-inset-bottom,0px));margin:8px -2.5ch 0;padding:8px 12px;z-index:1}337357 body:has(#room) #layout>tbody>tr>td#aside,body:has(#thread) #layout>tbody>tr>td#aside{display:none}338358 }@@ -44,9 +44,26 @@ async page => {
4444 await run("file picker previews and publishes an image", async () => {4545 const picker = page.locator('room-compose input[name="attachments"]');4646 check(await picker.count() === 1, "attachment picker missing");47+ const attach = page.locator('room-compose [data-attach]');48+ check(await attach.count() === 1, "compact attach button missing");49+ check(!await picker.isVisible(), "native file picker should be hidden behind the attach button");50+ // Check the native chooser separately with CLI click/upload commands;51+ // its modal state is managed outside run-code by playwright-cli.4752 await picker.setInputFiles("output/playwright/room-attachments/browser-upload.png");4853 await page.waitForSelector("room-files li");4954 check((await page.locator("room-files").textContent()).includes("browser-upload.png"), "pending file preview missing");55+ check(await page.locator("room-files img").count() === 1, "image attachment thumbnail missing");56+ await page.waitForFunction(() => {57+ const img = document.querySelector("room-files img");58+ return img?.complete && img.naturalWidth > 0;59+ });60+ check(await page.locator("room-files img").first().evaluate(img => img.complete && img.naturalWidth > 0), "image attachment thumbnail did not load");61+ const remove = page.getByRole("button", {name: "Remove browser-upload.png", exact: true});62+ check(await remove.count() === 1, "attachment remove button missing");63+ await remove.click();64+ check(await page.locator("room-files li").count() === 0, "attachment remove did not clear preview");65+ await picker.setInputFiles("output/playwright/room-attachments/browser-upload.png");66+ await page.waitForSelector("room-files li");5067 await page.locator('room-compose textarea[name="content"]').fill("Browser upload");5168 const request = page.waitForRequest(req => req.method() === "PUT" && req.url().includes("/rooms/attachments-qa/attachments"));5269 await page.locator("room-compose").getByRole("button", {name: "Send", exact: true}).click();@@ -77,6 +94,46 @@ async page => {
7794 return metrics;7895 });7996 97+ await run("composer stays compact and touch friendly", async () => {98+ await page.setViewportSize({width: 360, height: 420});99+ const compose = page.locator("room-compose");100+ const input = compose.locator('input[name="attachments"]');101+ check(await input.count() === 1, "attachment input missing");102+ const metrics = await page.evaluate(() => {103+ const node = document.querySelector("room-compose");104+ const attach = node?.querySelector('button[aria-label*="Attach" i], label[for], label');105+ const send = node?.querySelector('button[type="submit"], button:not([type])');106+ const box = element => element?.getBoundingClientRect();107+ const attachBox = box(attach), sendBox = box(send);108+ return {109+ composeHeight: box(node)?.height || 0,110+ viewportHeight: innerHeight,111+ scrollWidth: document.documentElement.scrollWidth,112+ viewportWidth: innerWidth,113+ attachWidth: attachBox?.width || 0,114+ attachHeight: attachBox?.height || 0,115+ sendWidth: sendBox?.width || 0,116+ sendHeight: sendBox?.height || 0,117+ placeholder: node?.querySelector("textarea")?.getAttribute("placeholder") || ""118+ };119+ });120+ check(metrics.scrollWidth <= metrics.viewportWidth, "composer overflows horizontally");121+ check(metrics.composeHeight <= 90, "empty composer occupies too much of the viewport");122+ check(metrics.attachWidth >= 40 && metrics.attachHeight >= 40, "attach control is too small to tap");123+ check(metrics.sendWidth >= 40 && metrics.sendHeight >= 40, "send control is too small to tap");124+ check(!/[0-9a-f]{16,}/i.test(metrics.placeholder), "composer exposes an internal identifier in its placeholder");125+ await page.screenshot({path: "output/playwright/room-attachments/room-mobile-composer.png"});126+ await page.setViewportSize({width: 1440, height: 900});127+ const desktop = await page.evaluate(() => ({128+ width: innerWidth,129+ scrollWidth: document.documentElement.scrollWidth,130+ composeHeight: document.querySelector("room-compose")?.getBoundingClientRect().height || 0131+ }));132+ check(desktop.scrollWidth <= desktop.width, "desktop composer overflows horizontally");133+ await page.screenshot({path: "output/playwright/room-attachments/room-desktop-composer.png"});134+ return {mobile: metrics, desktop};135+ });136+80137 await run("JavaScript-disabled SSR still renders media", async () => {81138 const context = await page.context().browser().newContext({javaScriptEnabled: false, viewport: {width: 360, height: 800}, storageState: await page.context().storageState()});82139 try {@@ -12,13 +12,14 @@ const roomsSource = source.slice(source.indexOf(" // Rooms: the compose bar"),
1212 // A small document: enough of the DOM for the room elements to render1313 // messages, find their targets and keep the list bounded.1414 export class FakeNode {15- constructor(tag) { this.localName = tag; this.childNodes = []; this.attributes = {}; this.dataset = {}; this.parent = null; this.id = ""; }15+ constructor(tag) { this.localName = tag; this.childNodes = []; this.attributes = {}; this.dataset = {}; this.style = {}; this.parent = null; this.id = ""; }1616 get children() { return this.childNodes.filter(node => node instanceof FakeNode); }1717 get firstElementChild() { return this.children[0] || null; }1818 get isConnected() { return Boolean(this.parent) && (this.parent === document || this.parent.isConnected); }1919 append(...nodes) { for (const node of nodes) { if (node instanceof FakeNode) { node.remove(); node.parent = this; this.childNodes.push(node); } else if (String(node)) this.childNodes.push(String(node)); } }2020 insertBefore(node, before) { if (!before) return this.append(node); node.remove(); const index = this.childNodes.indexOf(before); node.parent = this; this.childNodes.splice(index, 0, node); }2121 remove() { if (this.parent) { this.parent.childNodes = this.parent.childNodes.filter(node => node !== this); this.parent = null; } }22+ focus() { this.focused = true; }2223 replaceWith(node) { if (!this.parent) return; const index = this.parent.childNodes.indexOf(this); node.remove(); node.parent = this.parent; this.parent.childNodes[index] = node; this.parent = null; }2324 replaceChildren(...nodes) { this.children.forEach(node => { node.parent = null; }); this.childNodes = []; this.append(...nodes); }2425 get textContent() { return this.childNodes.map(node => typeof node === "string" ? node : node.textContent).join(""); }@@ -59,7 +60,7 @@ document.querySelector = selector => document.body.querySelector(selector);
5960 document.querySelectorAll = selector => document.body.querySelectorAll(selector);6061 6162 export function setup({attributes = {}, result = {accepted: true}, change = false} = {}) {62- const sent = [], navigated = [];63+ const sent = [], navigated = [], previewURLs = [], revokedURLs = [];6364 const secret = generateSecretKey(), pubkey = getPublicKey(secret);6465 document.body.childNodes = [];6566 const content = new FakeNode("div"); content.id = "content"; content.scrollHeight = 500; content.clientHeight = 400; content.scrollTop = 100;@@ -78,8 +79,11 @@ export function setup({attributes = {}, result = {accepted: true}, change = fals
7879 connectedCallback() { this.form = {requestSubmit() {}}; }7980 report(message) { this.message = message; }8081 }82+ class TestURL extends URL {}83+ TestURL.createObjectURL = file => { const url = "blob:room-preview-" + (previewURLs.length + 1); previewURLs.push({file, url}); return url; };84+ TestURL.revokeObjectURL = url => revokedURLs.push(url);8185 const sandbox = {82- document, HTMLElement, FormElement, URL, Uint8Array, AbortController, encodeURIComponent, setTimeout, clearTimeout,86+ document, HTMLElement, FormElement, URL: TestURL, Uint8Array, AbortController, encodeURIComponent, setTimeout, clearTimeout,8387 location: {href: "https://relay.test/r/work/rooms/build", origin: "https://relay.test"},8488 getComputedStyle: () => ({overflowY: "auto"}),8589 window: {scrollY: 0, innerHeight: 800, scrollTo() {}, NostrSigner: {verifyEvent, decodeNpub: value => { const decoded = nip19.decode(value); if (decoded.type !== "npub") throw Error("not an npub"); return decoded.data; }}, nostr: {signEvent: async event => { const copy = JSON.parse(JSON.stringify(event)); if (change) copy.content = "changed"; return finalizeEvent(copy, secret); }}},@@ -95,7 +99,7 @@ export function setup({attributes = {}, result = {accepted: true}, change = fals
9599 };96100 sandbox.sources = [];97101 const classes = vm.runInNewContext(`${roomsSource}\n({RoomCompose, RoomCreate, RoomAction, RoomLive, rooms: tiny.rooms})`, sandbox);98- return {...classes, sandbox, sent, navigated, pubkey, list, content, form: values => ({elements: Object.fromEntries(Object.entries(values).map(([name, value]) => [name, {name, value}])), reset() { this.resets = (this.resets || 0) + 1; }})};102+ return {...classes, sandbox, sent, navigated, previewURLs, revokedURLs, pubkey, list, content, form: values => ({elements: Object.fromEntries(Object.entries(values).map(([name, value]) => [name, {name, value, style: {}, scrollHeight: 0}])), reset() { this.resets = (this.resets || 0) + 1; }})};99103 }100104 const npub = nip19.npubEncode("d".repeat(64));101105 // Values built inside the sandbox belong to another realm; compare them as plain data.@@ -143,6 +147,60 @@ const descriptorFor = (file, bytes) => {
143147 return {sha256, size: file.size, type: file.type, url: "https://relay.test/r/work/media/" + sha256 + ".png"};144148 };145149 150+test("room-compose previews images, keeps preview URLs across rerenders and revokes removed previews", () => {151+ const s = setup({attributes: {room: "build"}}), compose = new s.RoomCompose();152+ const content = new FakeNode("textarea"); content.name = "content";153+ const files = new FakeNode("room-files"), image = selectedFile("photo.png", "image/png"), svg = selectedFile("icon.svg", "image/svg+xml"), pdf = selectedFile("report.pdf", "application/pdf");154+ compose.append(content, files);155+ compose.connectedCallback();156+ compose.form = {elements: {content}};157+ compose.addFiles([image, svg, pdf]);158+ assert.equal(s.previewURLs.length, 1, "only safe raster images get local previews");159+ assert.equal(files.querySelectorAll("img").length, 1);160+ const previewURL = s.previewURLs[0].url;161+ assert.equal(files.querySelector("img").src, previewURL);162+ compose.renderFiles();163+ assert.equal(s.previewURLs.length, 1, "rerendering must not allocate another object URL");164+ files.querySelectorAll("button")[0].emit("click", {});165+ assert.deepEqual(s.revokedURLs, [previewURL]);166+ assert.equal(compose.pendingFiles.length, 2);167+ compose.releasePreviews();168+ assert.equal(s.revokedURLs.length, 1, "releasePreviews must be idempotent after removal");169+});170+171+test("room-compose keeps a failed preview for retry, releases it on send and recreates it after reconnect", async () => {172+ const s = setup({attributes: {room: "build"}}), compose = new s.RoomCompose(), files = new FakeNode("room-files"), content = new FakeNode("textarea");173+ content.name = "content";174+ compose.append(content, files);175+ compose.connectedCallback();176+ const form = s.form({content: "a chart"});177+ compose.form = form;178+ const file = selectedFile("photo.png");179+ let publishes = 0;180+ const publish = s.sandbox.tiny.signedFetch;181+ s.sandbox.tiny.signedFetch = async (path, method, bytes, options) => {182+ if (path !== "/events") return Response.json(descriptorFor(file, bytes));183+ if (++publishes === 1) throw Error("connection lost");184+ return publish(path, method, bytes, options);185+ };186+ compose.addFiles([file]);187+ const previewURL = compose.pendingFiles[0].preview;188+ await assert.rejects(compose.submit(form), /connection lost/);189+ assert.equal(compose.pendingFiles.length, 1);190+ assert.equal(compose.pendingFiles[0].preview, previewURL);191+ await compose.submit(form);192+ assert.deepEqual(s.revokedURLs, [previewURL], "successful send releases the object URL");193+ assert.equal(compose.pendingFiles.length, 0);194+195+ compose.addFiles([file]);196+ const reconnectedURL = compose.pendingFiles[0].preview;197+ compose.disconnectedCallback();198+ assert.deepEqual(s.revokedURLs, [previewURL, reconnectedURL], "disconnect releases pending previews");199+ compose.connectedCallback();200+ assert.equal(compose.pendingFiles[0].preview, s.previewURLs[2].url, "reconnect can create a fresh preview for the same file");201+ assert.notEqual(compose.pendingFiles[0].preview, reconnectedURL);202+});203+146204 test("room-compose uploads only on send, then publishes Buzz metadata and an attachment-only message", async () => {147205 const s = setup({attributes: {room: "build"}}), compose = new s.RoomCompose();148206 const uploads = [], publish = s.sandbox.tiny.signedFetch;