| 1 | diff --git a/internal/webui/components.js b/internal/webui/components.js |
| 2 | index 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; } |
| 99 | diff --git a/internal/webui/rooms.html b/internal/webui/rooms.html |
| 100 | index 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}}> |
| 129 | diff --git a/internal/webui/style.css b/internal/webui/style.css |
| 130 | index 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 | } |
| 177 | diff --git a/scripts/qa/room-attachments-browser.js b/scripts/qa/room-attachments-browser.js |
| 178 | index 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 { |
| 255 | diff --git a/scripts/rooms.test.mjs b/scripts/rooms.test.mjs |
| 256 | index 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; |