tinyrelaycommit

Commit 7d1f192de828

browse files at this commit | history

1diff --git a/internal/webui/components.js b/internal/webui/components.js
2index 37c4a4f..d7b8e3e 100644
3--- a/internal/webui/components.js
4+++ b/internal/webui/components.js
5@@ -1077,8 +1077,16 @@
6 class RoomCompose extends FormElement {
7 connectedCallback() {
8 super.connectedCallback();
9+ this.renderFiles();
10+ this.resizeContent();
11 if (this.keys) return;
12 this.keys = true;
13+ this.querySelector("[data-attach]")?.addEventListener("click", () => {
14+ this.form.elements.attachments.click();
15+ });
16+ this.addEventListener("input", event => {
17+ if (event.target.name === "content") this.resizeContent();
18+ });
19 this.addEventListener("keydown", event => {
20 if (event.key !== "Enter" || event.shiftKey || event.isComposing || !event.target.matches?.("textarea")) return;
21 event.preventDefault();
22@@ -1108,10 +1116,26 @@
23 event.preventDefault();
24 this.chooseFiles(event.dataTransfer.files);
25 });
26- this.renderFiles();
27 }
28
29- disconnectedCallback() { this.uploadController?.abort(); }
30+ disconnectedCallback() {
31+ this.uploadController?.abort();
32+ this.releasePreviews();
33+ }
34+
35+ resizeContent() {
36+ const content = this.form?.elements?.content;
37+ if (!content) return;
38+ content.style.height = "auto";
39+ content.style.height = content.scrollHeight + "px";
40+ }
41+
42+ releasePreviews() {
43+ for (const entry of this.pendingFiles || []) {
44+ if (entry.preview) URL.revokeObjectURL(entry.preview);
45+ delete entry.preview;
46+ }
47+ }
48
49 busy(on) {
50 super.busy(on);
51@@ -1141,16 +1165,34 @@
52 if (!target) return;
53 const list = el("ul");
54 pending.forEach((entry, index) => {
55- const row = el("li"), remove = el("button", "Remove");
56+ const row = el("li"), remove = el("button", "×");
57 remove.type = "button";
58 remove.disabled = Boolean(this.submitting);
59 remove.setAttribute("aria-label", "Remove " + entry.file.name);
60+ remove.title = "Remove " + entry.file.name;
61 remove.addEventListener("click", () => {
62 if (this.submitting) return;
63+ if (entry.preview) URL.revokeObjectURL(entry.preview);
64 this.pendingFiles.splice(index, 1);
65 this.renderFiles();
66+ const next = target.querySelectorAll("button")[Math.min(index, this.pendingFiles.length - 1)];
67+ (next || this.querySelector("[data-attach]"))?.focus();
68 });
69- row.append(el("span", entry.file.name), " ", el("small", entry.descriptor ? "uploaded" : Math.ceil(entry.file.size / 1024) + " KB"), " ", remove);
70+ let preview;
71+ if (entry.file.type.startsWith("image/") && entry.file.type !== "image/svg+xml") {
72+ entry.preview ||= URL.createObjectURL(entry.file);
73+ preview = el("img");
74+ preview.src = entry.preview;
75+ preview.alt = "";
76+ } else {
77+ preview = el("span", entry.file.type.startsWith("video/") ? "Video" : entry.file.type.startsWith("audio/") ? "Audio" : "File");
78+ preview.dataset.preview = "";
79+ preview.setAttribute("aria-hidden", "true");
80+ }
81+ const name = el("span", entry.file.name);
82+ name.title = entry.file.name;
83+ const size = entry.file.size < 1024 * 1024 ? Math.ceil(entry.file.size / 1024) + " KB" : (entry.file.size / (1024 * 1024)).toFixed(1) + " MB";
84+ row.append(preview, name, el("small", entry.descriptor ? "Uploaded" : size), remove);
85 list.append(row);
86 });
87 target.replaceChildren(...(pending.length ? [list] : []));
88@@ -1218,8 +1260,10 @@
89 this.report("Signing…");
90 const event = await signAndPublish(this.event(content, files), controller.signal);
91 form.reset();
92+ this.releasePreviews();
93 this.pendingFiles = [];
94 this.renderFiles();
95+ this.resizeContent();
96 this.report("");
97 roomAppend(event, {room: this.getAttribute("room"), inThread: event.kind === 12, own: true});
98 } finally { this.uploadController = null; }
99diff --git a/internal/webui/rooms.html b/internal/webui/rooms.html
100index 02ebe73..ebca938 100644
101--- a/internal/webui/rooms.html
102+++ b/internal/webui/rooms.html
103@@ -31,7 +31,7 @@
104 {{template "roomMessage" .}}{{else}}<p id="empty">No messages yet.</p>{{end}}
105 </div>
106 {{if not (.Query.Get "cursor")}}<room-live room="{{$id}}"></room-live>
107-{{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>
108+{{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>
109 <noscript><p>Sending needs JavaScript and a connected signer.</p></noscript>
110 {{else}}<p id="guest"><a href="/signin">Sign in</a> to send messages.</p>
111 {{end}}{{end}}</section>
112@@ -50,13 +50,14 @@
113 </div>
114 {{with index $data "next_cursor"}}<p id="pages"><a href="/rooms/{{$id}}/thread/{{$root.ID}}?cursor={{urlquery (str .)}}">load earlier</a></p>{{end}}
115 {{if not (.Query.Get "cursor")}}<room-live room="{{$id}}" root="{{$root.ID}}"></room-live>
116-{{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>
117+{{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>
118 <noscript><p>Replying needs JavaScript and a connected signer.</p></noscript>
119 {{else}}<p id="guest"><a href="/signin">Sign in</a> to reply.</p>
120 {{end}}{{end}}</section>
121 {{end}}{{end}}
122
123-{{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}}
124+{{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}}
125+{{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}}
126
127 {{/* One message. room-live builds the same markup for messages that arrive on the stream. */}}
128 {{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}}>
129diff --git a/internal/webui/style.css b/internal/webui/style.css
130index b72fcab..3cfccbb 100644
131--- a/internal/webui/style.css
132+++ b/internal/webui/style.css
133@@ -294,11 +294,31 @@ room-message[data-agent]>header b::after{content:" agent";color:var(--bark);font
134 room-message[data-notice]>p{color:var(--muted);font-size:13px}
135 room-live{display:block;font:11px var(--mono);color:var(--muted);text-align:right}room-live:empty{display:none}
136 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)}
137-room-compose textarea{min-height:3.2em;width:100%;font:14px var(--sans);max-width:720px;display:block}
138-room-compose p{margin:6px 0 0}room-compose button{margin-right:6px}
139-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}
140-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}
141+room-compose-bar{display:block;position:relative;padding:4px 52px 4px 8px;border:1px solid var(--line);border-radius:16px;background:var(--rail)}
142+room-compose-bar:has([data-attach]){padding-left:48px}
143+room-compose-bar:focus-within{border-color:var(--muted)}
144+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)}
145+room-compose textarea::placeholder{color:var(--muted);opacity:1}
146+room-compose-bar>button{position:absolute;bottom:4px;width:44px;height:44px;padding:11px;border:0;border-radius:12px;line-height:0}
147+room-compose-bar>button[data-attach]{left:4px;background:transparent;color:var(--muted)}
148+room-compose-bar>button[type=submit]{right:4px;background:var(--accent);color:var(--paper)}
149+room-compose-bar>button:hover{filter:brightness(.9)}
150+room-compose-bar>button:disabled{opacity:.55;filter:none}
151+room-compose button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
152+room-compose [hidden]{display:none}
153+room-compose[data-over] room-compose-bar{outline:2px dashed var(--accent);outline-offset:2px;background:var(--soft)}
154+room-files{display:block;overflow-x:auto;overscroll-behavior-x:contain}
155+room-files:empty{display:none}
156+room-files ul{list-style:none;white-space:nowrap;margin:0;padding:0 0 8px}
157+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)}
158+room-files li:last-child{margin-right:0}
159+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)}
160+room-files li>span[data-preview]{text-align:center;color:var(--accent);font:10px/46px var(--mono);text-transform:uppercase}
161+room-files li>span:not([data-preview]),room-files small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
162+room-files small{color:var(--muted);font-size:11px}
163+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)}
164 room-compose output,room-create output,room-action output{display:block;font:12px var(--mono);color:var(--muted);margin-top:4px;overflow-wrap:anywhere}
165+room-compose output:empty{display:none}
166 room-compose output[data-error],room-create output[data-error],room-action output[data-error]{color:var(--alert)}
167 room-create{display:block;max-width:720px;border-bottom:1px solid var(--line);padding:10px 0}
168 room-create label{display:inline-block;margin:0 10px 6px 0;vertical-align:top;font:13px var(--sans);color:var(--muted)}
169@@ -333,6 +353,6 @@ html,body{overflow:auto}
170 #grid,#gridhead,h2:has(+#gridhead){display:none}
171 /* Phones: each wiki row reads as a title line and one muted line of facts. */
172 #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:" | "}
173-room-compose{bottom:calc(28px + env(safe-area-inset-bottom,0px));margin:8px -2.5ch 0;padding:10px 2.5ch 8px;z-index:1}
174+room-compose{bottom:calc(28px + env(safe-area-inset-bottom,0px));margin:8px -2.5ch 0;padding:8px 12px;z-index:1}
175 body:has(#room) #layout>tbody>tr>td#aside,body:has(#thread) #layout>tbody>tr>td#aside{display:none}
176 }
177diff --git a/scripts/qa/room-attachments-browser.js b/scripts/qa/room-attachments-browser.js
178index c9a391f..91d426a 100644
179--- a/scripts/qa/room-attachments-browser.js
180+++ b/scripts/qa/room-attachments-browser.js
181@@ -44,9 +44,26 @@ async page => {
182 await run("file picker previews and publishes an image", async () => {
183 const picker = page.locator('room-compose input[name="attachments"]');
184 check(await picker.count() === 1, "attachment picker missing");
185+ const attach = page.locator('room-compose [data-attach]');
186+ check(await attach.count() === 1, "compact attach button missing");
187+ check(!await picker.isVisible(), "native file picker should be hidden behind the attach button");
188+ // Check the native chooser separately with CLI click/upload commands;
189+ // its modal state is managed outside run-code by playwright-cli.
190 await picker.setInputFiles("output/playwright/room-attachments/browser-upload.png");
191 await page.waitForSelector("room-files li");
192 check((await page.locator("room-files").textContent()).includes("browser-upload.png"), "pending file preview missing");
193+ check(await page.locator("room-files img").count() === 1, "image attachment thumbnail missing");
194+ await page.waitForFunction(() => {
195+ const img = document.querySelector("room-files img");
196+ return img?.complete && img.naturalWidth > 0;
197+ });
198+ check(await page.locator("room-files img").first().evaluate(img => img.complete && img.naturalWidth > 0), "image attachment thumbnail did not load");
199+ const remove = page.getByRole("button", {name: "Remove browser-upload.png", exact: true});
200+ check(await remove.count() === 1, "attachment remove button missing");
201+ await remove.click();
202+ check(await page.locator("room-files li").count() === 0, "attachment remove did not clear preview");
203+ await picker.setInputFiles("output/playwright/room-attachments/browser-upload.png");
204+ await page.waitForSelector("room-files li");
205 await page.locator('room-compose textarea[name="content"]').fill("Browser upload");
206 const request = page.waitForRequest(req => req.method() === "PUT" && req.url().includes("/rooms/attachments-qa/attachments"));
207 await page.locator("room-compose").getByRole("button", {name: "Send", exact: true}).click();
208@@ -77,6 +94,46 @@ async page => {
209 return metrics;
210 });
211
212+ await run("composer stays compact and touch friendly", async () => {
213+ await page.setViewportSize({width: 360, height: 420});
214+ const compose = page.locator("room-compose");
215+ const input = compose.locator('input[name="attachments"]');
216+ check(await input.count() === 1, "attachment input missing");
217+ const metrics = await page.evaluate(() => {
218+ const node = document.querySelector("room-compose");
219+ const attach = node?.querySelector('button[aria-label*="Attach" i], label[for], label');
220+ const send = node?.querySelector('button[type="submit"], button:not([type])');
221+ const box = element => element?.getBoundingClientRect();
222+ const attachBox = box(attach), sendBox = box(send);
223+ return {
224+ composeHeight: box(node)?.height || 0,
225+ viewportHeight: innerHeight,
226+ scrollWidth: document.documentElement.scrollWidth,
227+ viewportWidth: innerWidth,
228+ attachWidth: attachBox?.width || 0,
229+ attachHeight: attachBox?.height || 0,
230+ sendWidth: sendBox?.width || 0,
231+ sendHeight: sendBox?.height || 0,
232+ placeholder: node?.querySelector("textarea")?.getAttribute("placeholder") || ""
233+ };
234+ });
235+ check(metrics.scrollWidth <= metrics.viewportWidth, "composer overflows horizontally");
236+ check(metrics.composeHeight <= 90, "empty composer occupies too much of the viewport");
237+ check(metrics.attachWidth >= 40 && metrics.attachHeight >= 40, "attach control is too small to tap");
238+ check(metrics.sendWidth >= 40 && metrics.sendHeight >= 40, "send control is too small to tap");
239+ check(!/[0-9a-f]{16,}/i.test(metrics.placeholder), "composer exposes an internal identifier in its placeholder");
240+ await page.screenshot({path: "output/playwright/room-attachments/room-mobile-composer.png"});
241+ await page.setViewportSize({width: 1440, height: 900});
242+ const desktop = await page.evaluate(() => ({
243+ width: innerWidth,
244+ scrollWidth: document.documentElement.scrollWidth,
245+ composeHeight: document.querySelector("room-compose")?.getBoundingClientRect().height || 0
246+ }));
247+ check(desktop.scrollWidth <= desktop.width, "desktop composer overflows horizontally");
248+ await page.screenshot({path: "output/playwright/room-attachments/room-desktop-composer.png"});
249+ return {mobile: metrics, desktop};
250+ });
251+
252 await run("JavaScript-disabled SSR still renders media", async () => {
253 const context = await page.context().browser().newContext({javaScriptEnabled: false, viewport: {width: 360, height: 800}, storageState: await page.context().storageState()});
254 try {
255diff --git a/scripts/rooms.test.mjs b/scripts/rooms.test.mjs
256index 7a707cf..cbb228c 100644
257--- a/scripts/rooms.test.mjs
258+++ b/scripts/rooms.test.mjs
259@@ -12,13 +12,14 @@ const roomsSource = source.slice(source.indexOf(" // Rooms: the compose bar"),
260 // A small document: enough of the DOM for the room elements to render
261 // messages, find their targets and keep the list bounded.
262 export class FakeNode {
263- constructor(tag) { this.localName = tag; this.childNodes = []; this.attributes = {}; this.dataset = {}; this.parent = null; this.id = ""; }
264+ constructor(tag) { this.localName = tag; this.childNodes = []; this.attributes = {}; this.dataset = {}; this.style = {}; this.parent = null; this.id = ""; }
265 get children() { return this.childNodes.filter(node => node instanceof FakeNode); }
266 get firstElementChild() { return this.children[0] || null; }
267 get isConnected() { return Boolean(this.parent) && (this.parent === document || this.parent.isConnected); }
268 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)); } }
269 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); }
270 remove() { if (this.parent) { this.parent.childNodes = this.parent.childNodes.filter(node => node !== this); this.parent = null; } }
271+ focus() { this.focused = true; }
272 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; }
273 replaceChildren(...nodes) { this.children.forEach(node => { node.parent = null; }); this.childNodes = []; this.append(...nodes); }
274 get textContent() { return this.childNodes.map(node => typeof node === "string" ? node : node.textContent).join(""); }
275@@ -59,7 +60,7 @@ document.querySelector = selector => document.body.querySelector(selector);
276 document.querySelectorAll = selector => document.body.querySelectorAll(selector);
277
278 export function setup({attributes = {}, result = {accepted: true}, change = false} = {}) {
279- const sent = [], navigated = [];
280+ const sent = [], navigated = [], previewURLs = [], revokedURLs = [];
281 const secret = generateSecretKey(), pubkey = getPublicKey(secret);
282 document.body.childNodes = [];
283 const content = new FakeNode("div"); content.id = "content"; content.scrollHeight = 500; content.clientHeight = 400; content.scrollTop = 100;
284@@ -78,8 +79,11 @@ export function setup({attributes = {}, result = {accepted: true}, change = fals
285 connectedCallback() { this.form = {requestSubmit() {}}; }
286 report(message) { this.message = message; }
287 }
288+ class TestURL extends URL {}
289+ TestURL.createObjectURL = file => { const url = "blob:room-preview-" + (previewURLs.length + 1); previewURLs.push({file, url}); return url; };
290+ TestURL.revokeObjectURL = url => revokedURLs.push(url);
291 const sandbox = {
292- document, HTMLElement, FormElement, URL, Uint8Array, AbortController, encodeURIComponent, setTimeout, clearTimeout,
293+ document, HTMLElement, FormElement, URL: TestURL, Uint8Array, AbortController, encodeURIComponent, setTimeout, clearTimeout,
294 location: {href: "https://relay.test/r/work/rooms/build", origin: "https://relay.test"},
295 getComputedStyle: () => ({overflowY: "auto"}),
296 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); }}},
297@@ -95,7 +99,7 @@ export function setup({attributes = {}, result = {accepted: true}, change = fals
298 };
299 sandbox.sources = [];
300 const classes = vm.runInNewContext(`${roomsSource}\n({RoomCompose, RoomCreate, RoomAction, RoomLive, rooms: tiny.rooms})`, sandbox);
301- 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; }})};
302+ 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; }})};
303 }
304 const npub = nip19.npubEncode("d".repeat(64));
305 // Values built inside the sandbox belong to another realm; compare them as plain data.
306@@ -143,6 +147,60 @@ const descriptorFor = (file, bytes) => {
307 return {sha256, size: file.size, type: file.type, url: "https://relay.test/r/work/media/" + sha256 + ".png"};
308 };
309
310+test("room-compose previews images, keeps preview URLs across rerenders and revokes removed previews", () => {
311+ const s = setup({attributes: {room: "build"}}), compose = new s.RoomCompose();
312+ const content = new FakeNode("textarea"); content.name = "content";
313+ 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");
314+ compose.append(content, files);
315+ compose.connectedCallback();
316+ compose.form = {elements: {content}};
317+ compose.addFiles([image, svg, pdf]);
318+ assert.equal(s.previewURLs.length, 1, "only safe raster images get local previews");
319+ assert.equal(files.querySelectorAll("img").length, 1);
320+ const previewURL = s.previewURLs[0].url;
321+ assert.equal(files.querySelector("img").src, previewURL);
322+ compose.renderFiles();
323+ assert.equal(s.previewURLs.length, 1, "rerendering must not allocate another object URL");
324+ files.querySelectorAll("button")[0].emit("click", {});
325+ assert.deepEqual(s.revokedURLs, [previewURL]);
326+ assert.equal(compose.pendingFiles.length, 2);
327+ compose.releasePreviews();
328+ assert.equal(s.revokedURLs.length, 1, "releasePreviews must be idempotent after removal");
329+});
330+
331+test("room-compose keeps a failed preview for retry, releases it on send and recreates it after reconnect", async () => {
332+ const s = setup({attributes: {room: "build"}}), compose = new s.RoomCompose(), files = new FakeNode("room-files"), content = new FakeNode("textarea");
333+ content.name = "content";
334+ compose.append(content, files);
335+ compose.connectedCallback();
336+ const form = s.form({content: "a chart"});
337+ compose.form = form;
338+ const file = selectedFile("photo.png");
339+ let publishes = 0;
340+ const publish = s.sandbox.tiny.signedFetch;
341+ s.sandbox.tiny.signedFetch = async (path, method, bytes, options) => {
342+ if (path !== "/events") return Response.json(descriptorFor(file, bytes));
343+ if (++publishes === 1) throw Error("connection lost");
344+ return publish(path, method, bytes, options);
345+ };
346+ compose.addFiles([file]);
347+ const previewURL = compose.pendingFiles[0].preview;
348+ await assert.rejects(compose.submit(form), /connection lost/);
349+ assert.equal(compose.pendingFiles.length, 1);
350+ assert.equal(compose.pendingFiles[0].preview, previewURL);
351+ await compose.submit(form);
352+ assert.deepEqual(s.revokedURLs, [previewURL], "successful send releases the object URL");
353+ assert.equal(compose.pendingFiles.length, 0);
354+
355+ compose.addFiles([file]);
356+ const reconnectedURL = compose.pendingFiles[0].preview;
357+ compose.disconnectedCallback();
358+ assert.deepEqual(s.revokedURLs, [previewURL, reconnectedURL], "disconnect releases pending previews");
359+ compose.connectedCallback();
360+ assert.equal(compose.pendingFiles[0].preview, s.previewURLs[2].url, "reconnect can create a fresh preview for the same file");
361+ assert.notEqual(compose.pendingFiles[0].preview, reconnectedURL);
362+});
363+
364 test("room-compose uploads only on send, then publishes Buzz metadata and an attachment-only message", async () => {
365 const s = setup({attributes: {room: "build"}}), compose = new s.RoomCompose();
366 const uploads = [], publish = s.sandbox.tiny.signedFetch;

About

ownerc01503e8035f
visibilitypublic
head7d1f192de828

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs