diff --git a/crates/web/src/app.rs b/crates/web/src/app.rs index b5539c73..47d8c00f 100644 --- a/crates/web/src/app.rs +++ b/crates/web/src/app.rs @@ -8,7 +8,7 @@ use willow_client::{ClientConfig, ClientEvent, ClientHandle, DisplayMessage, Voi use crate::components::{ AddServerPanel, CallPage, ChannelSidebar, CommandPalette, Composer, FileShareButton, GroveRail, JoinPage, MainPaneHeader, MessageList, MobileShell, ReadOnlyBanner, RightRail, RightRailWhich, - SettingsPanel, ToastStackView, WelcomeScreen, + SettingsPanel, ToastStackView, UploadDialog, WelcomeScreen, }; use crate::event_processing::process_event_batch; use crate::handlers; @@ -185,6 +185,13 @@ pub fn App() -> impl IntoView { provide_context(write); provide_context(trust_store.clone()); + // Phase 3b — `` queue context. Provided once at + // the shell so the composer attach button (which flips + // `queue.open`), the dialog itself (which renders + drives the + // queue), and any future drag-overlay or paste handler all see + // the same `UploadQueue`. + provide_context(crate::upload_state::UploadQueue::new()); + // Phase 3c.3 — per-channel reaction recency. Drives the picker's // "recent" shelf in both the composer's emoji button and the // row's "more reactions" toolbar. The Resource refreshes when @@ -1258,6 +1265,13 @@ pub fn App() -> impl IntoView { }) /> + // Phase 3b T8 — modal upload sheet, opened by + // (or future drag/paste handlers) + // via the shared UploadQueue context. Mounted at + // the chat-pane scope so `current_channel` is in + // scope; visibility is owned by `queue.open` and + // CSS positions it as a fixed overlay. + }.into_any() } diff --git a/crates/web/src/components/file_share.rs b/crates/web/src/components/file_share.rs index 12ec8db1..9e6af3d4 100644 --- a/crates/web/src/components/file_share.rs +++ b/crates/web/src/components/file_share.rs @@ -1,144 +1,31 @@ //! `` — composer attach affordance. //! -//! Phase 3b: routes uploads through the typed `EventKind::FileMessage` -//! path (`upload_attachment` + `send_attachment_message`) instead of -//! the legacy 256 KB base64 inline-body hack. Bytes go to the iroh -//! blob store; the wire event carries only the content-addressed -//! hash + metadata. Receivers fetch via `BlobStore::get`. +//! Phase 3b.5: clicking the paperclip flips the spec'd +//! `` open via the [`crate::upload_state::UploadQueue`] +//! context. The dialog owns the multi-file pick + per-file status + +//! batch send flow per `docs/specs/2026-04-19-ui-design/files-inline.md` +//! §Upload dialog. The button keeps the spec's `aria-label="attach +//! file"` so screen readers read it the same way they did before. //! //! The legacy `parse_inline_file` reader (below) stays alive so //! historical `[file:NAME:base64]` messages from pre-3b peers still -//! render. Senders no longer emit the format. +//! render. Senders no longer emit that format. use leptos::prelude::*; -use wasm_bindgen::closure::Closure; -use wasm_bindgen::JsCast; -use crate::app::WebClientHandle; use crate::icons; +use crate::upload_state::use_upload_queue; -/// Soft cap on individual attachment size (25 MB) per spec -/// `docs/specs/2026-04-19-ui-design/files-inline.md` §File constraints. -/// Files above this trigger an alert; the blob transport itself can -/// handle larger payloads, so the cap exists to protect mobile peers -/// from accidentally sharing multi-hundred-MB files until the upload -/// dialog (T8) lands with a real progress UI. -const MAX_ATTACHMENT_SIZE: u64 = 25 * 1024 * 1024; - -/// Attachment button that opens a native file picker and uploads the -/// selected file through the typed `EventKind::FileMessage` flow. -/// -/// Click → hidden `` → file → `FileReader` → bytes -/// → `ClientHandle::upload_attachment` → `(BlobHash, size)` → -/// `ClientHandle::send_attachment_message` with the user-facing -/// metadata. Image dimension extraction is deferred to T8/T9 (the -/// upload dialog gets the browser `Image` API surface for that); -/// this minimal path always sends `width: None, height: None`, -/// which is fine for non-images and mostly fine for images (the -/// receiver renderer falls back to natural sizing). +/// Composer paperclip — flips `UploadQueue::open` so the +/// `` mounts. The `channel` prop is kept for backward +/// compat; the dialog reads its own channel signal from context. #[component] pub fn FileShareButton(channel: ReadSignal) -> impl IntoView { - let handle = use_context::().unwrap(); - - // Hidden file input is triggered by button click. - let input_ref = NodeRef::::new(); - - let on_click = move |_| { - if let Some(input) = input_ref.get() { - let el: &web_sys::HtmlInputElement = &input; - el.set_value(""); - el.click(); - } - }; - - let handle_change = handle.clone(); - let on_change = move |_ev: web_sys::Event| { - let Some(input) = input_ref.get() else { - return; - }; - let el: &web_sys::HtmlInputElement = &input; - - let Some(files) = el.files() else { - return; - }; - let Some(file) = files.get(0) else { - return; - }; - - let size = file.size() as u64; - if size > MAX_ATTACHMENT_SIZE { - if let Some(window) = web_sys::window() { - let _ = window.alert_with_message( - "File is too large. Maximum size is 25 MB while the \ - upload dialog with progress is in development.", - ); - } - return; - } - - let filename = file.name(); - let mime_type = file.type_(); - let ch = channel.get_untracked(); - let handle_inner = handle_change.clone(); - - let Ok(reader) = web_sys::FileReader::new() else { - tracing::error!("FileShareButton: FileReader::new failed"); - return; - }; - let reader_clone = reader.clone(); - - let cb = Closure::once(move || { - let result = match reader_clone.result() { - Ok(r) => r, - Err(e) => { - let msg = e.as_string().unwrap_or_else(|| format!("{e:?}")); - tracing::error!(error = %msg, "FileReader result failure"); - return; - } - }; - let array_buf = match result.dyn_into::() { - Ok(b) => b, - Err(_) => { - tracing::error!("FileReader result was not an ArrayBuffer"); - return; - } - }; - let uint8 = js_sys::Uint8Array::new(&array_buf); - let data = uint8.to_vec(); - - wasm_bindgen_futures::spawn_local(async move { - let upload = match handle_inner.upload_attachment(data).await { - Ok(pair) => pair, - Err(e) => { - if let Some(window) = web_sys::window() { - let _ = window - .alert_with_message(&format!("Failed to upload attachment: {e}")); - } - return; - } - }; - let (hash, size_bytes) = upload; - if let Err(e) = handle_inner - .send_attachment_message( - &ch, &hash, &filename, &mime_type, size_bytes, None, None, "", None, - ) - .await - { - if let Some(window) = web_sys::window() { - let _ = - window.alert_with_message(&format!("Failed to send attachment: {e}")); - } - } - }); - }); - - reader.set_onloadend(Some(cb.as_ref().unchecked_ref())); - let _ = reader.read_as_array_buffer(&file); - // Intentional leak: the FileReader callback must outlive this - // scope. File picks are infrequent so the leak is acceptable. - cb.forget(); + let _ = channel; // dialog reads channel via its own context + let queue = use_upload_queue(); + let on_click = move |_ev: web_sys::MouseEvent| { + queue.open.set(true); }; - view! { - } } diff --git a/crates/web/src/components/mod.rs b/crates/web/src/components/mod.rs index 30534990..bed79e53 100644 --- a/crates/web/src/components/mod.rs +++ b/crates/web/src/components/mod.rs @@ -139,6 +139,7 @@ mod temp_channel_create; mod toast; mod trust_badge; mod unread_badge; +mod upload_dialog; mod voice; mod welcome; mod welcome_back_banner; @@ -198,6 +199,7 @@ pub use temp_channel_create::{TempChannelCreateForm, TEMP_CAP_DAYS, TEMP_DEFAULT pub use toast::*; pub use trust_badge::*; pub use unread_badge::*; +pub use upload_dialog::UploadDialog; pub use voice::*; pub use welcome::*; pub use welcome_back_banner::*; diff --git a/crates/web/src/components/upload_dialog.rs b/crates/web/src/components/upload_dialog.rs new file mode 100644 index 00000000..ab7dc91f --- /dev/null +++ b/crates/web/src/components/upload_dialog.rs @@ -0,0 +1,324 @@ +//! `` — modal sheet driven by the +//! [`crate::upload_state::UploadQueue`] context. +//! +//! Spec: `docs/specs/2026-04-19-ui-design/files-inline.md` +//! §Upload dialog. +//! +//! v1 layout per spec: modal sheet on `--bg-1` with `--line` border, +//! radius 12 px, `--shadow-2`; scrim behind the sheet absorbs +//! click-away dismissal. Picker row exposes a `choose files` button +//! (`--moss-1` border, `--ink-0` text) plus an `or drop files here` +//! hint, driving a hidden multi-``. Per-file rows +//! show file glyph, filename, size, status (uploading / done / failed), +//! and a cancel IconBtn. Footer offers `cancel all` plus `attach to +//! message`; the confirm button stays disabled until at least one +//! upload resolves. +//! +//! Out of scope for v1: per-file progress bars (iroh blob store has +//! no incremental progress hook yet — drop-in once that lands), drag +//! highlight on the picker row (T10 page-level overlay), and paste +//! routing (T12). All three feed the same `UploadQueue` context. + +use leptos::prelude::*; +use wasm_bindgen::closure::Closure; +use wasm_bindgen::JsCast; + +use crate::app::WebClientHandle; +use crate::icons; +use crate::upload_state::{use_upload_queue, UploadStatus}; + +const MAX_ATTACHMENT_SIZE: u64 = 25 * 1024 * 1024; + +/// Format `bytes` as a human-readable size (`1.2 KB`, `7.0 MB`). +fn format_size(bytes: u64) -> String { + if bytes < 1024 { + format!("{bytes} B") + } else if bytes < 1024 * 1024 { + format!("{:.1} KB", bytes as f64 / 1024.0) + } else { + format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) + } +} + +/// Modal sheet that drives the multi-file upload + send flow. +/// +/// Reads `UploadQueue` + `WebClientHandle` from context. Visibility +/// is owned by `queue.open` — flip it from any other surface (the +/// composer attach button, a future drag overlay, a paste handler) +/// to mount the dialog with the queue's current contents. +/// +/// `channel` is the active channel name; the confirm action posts +/// each completed entry as a `FileMessage` event in this channel. +#[component] +pub fn UploadDialog(channel: ReadSignal) -> impl IntoView { + let queue = use_upload_queue(); + let handle = use_context::(); + + // Confirm button enabled when at least one entry has resolved. + let confirm_disabled = Memo::new(move |_| { + queue.entries.with(|entries| { + !entries + .iter() + .any(|e| matches!(e.status.get(), UploadStatus::Done(_))) + }) + }); + + view! { + {move || { + if !queue.open.get() { + return None; + } + // Re-create per-render so each handler is freshly Fn. + let handle_change = handle.clone(); + let handle_attach = handle.clone(); + let input_ref = NodeRef::::new(); + + let on_browse = move |_ev: web_sys::MouseEvent| { + if let Some(input) = input_ref.get() { + let el: &web_sys::HtmlInputElement = &input; + el.set_value(""); + el.click(); + } + }; + + let on_change = move |_ev: web_sys::Event| { + let Some(input) = input_ref.get() else { + return; + }; + let el: &web_sys::HtmlInputElement = &input; + let Some(files) = el.files() else { + return; + }; + for i in 0..files.length() { + let Some(file) = files.get(i) else { + continue; + }; + let size = file.size() as u64; + if size > MAX_ATTACHMENT_SIZE { + if let Some(window) = web_sys::window() { + let _ = window.alert_with_message(&format!( + "{} is too large (max 25 MB while the upload \ + dialog is in progress).", + file.name(), + )); + } + continue; + } + let filename = file.name(); + let mime = file.type_(); + let (id, status) = queue.push(filename.clone(), mime.clone(), size); + spawn_upload(handle_change.clone(), id.clone(), status, file); + } + }; + + let on_cancel_all = move |_ev: web_sys::MouseEvent| { + queue.cancel_all(); + }; + + let on_attach = move |_ev: web_sys::MouseEvent| { + let Some(handle) = handle_attach.clone() else { + tracing::warn!("UploadDialog attach: WebClientHandle missing"); + return; + }; + // Snapshot the channel at click time. If the user + // switches channels mid-upload, the file lands in + // wherever they confirmed from — matches the standard + // chat-app mental model ("attach to current channel") + // and avoids subscribing this handler to channel-switch + // re-renders. + let channel_now = channel.get_untracked(); + let entries = queue.entries.get_untracked(); + for entry in entries { + let UploadStatus::Done(hash) = entry.status.get_untracked() else { + continue; + }; + let handle = handle.clone(); + let channel = channel_now.clone(); + let filename = entry.filename.clone(); + let mime = entry.mime.clone(); + let size = entry.size; + wasm_bindgen_futures::spawn_local(async move { + if let Err(e) = handle + .send_attachment_message( + &channel, &hash, &filename, &mime, size, None, None, "", None, + ) + .await + { + tracing::warn!( + filename = %filename, + error = ?e, + "UploadDialog: send_attachment_message failed", + ); + } + }); + } + queue.cancel_all(); + }; + + Some(view! { +
+ + }) + }} + } +} + +/// Read the picked file's bytes and call `upload_attachment`, +/// updating the entry's status to [`UploadStatus::Done`] on success +/// or [`UploadStatus::Failed`] on error. Mirrors the bytes-pump path +/// in `` but writes to the queue's row signal +/// instead of triggering a send directly. +fn spawn_upload( + handle: Option, + _id: String, + status: RwSignal, + file: web_sys::File, +) { + let Some(handle) = handle else { + status.set(UploadStatus::Failed("network not connected".to_string())); + return; + }; + + let Ok(reader) = web_sys::FileReader::new() else { + status.set(UploadStatus::Failed("FileReader::new failed".to_string())); + return; + }; + let reader_clone = reader.clone(); + let cb = Closure::once(move || { + let result = match reader_clone.result() { + Ok(r) => r, + Err(e) => { + let msg = e.as_string().unwrap_or_else(|| format!("{e:?}")); + status.set(UploadStatus::Failed(format!("read failed: {msg}"))); + return; + } + }; + let array_buf = match result.dyn_into::() { + Ok(b) => b, + Err(_) => { + status.set(UploadStatus::Failed( + "FileReader returned non-ArrayBuffer".to_string(), + )); + return; + } + }; + let uint8 = js_sys::Uint8Array::new(&array_buf); + let data = uint8.to_vec(); + let handle_inner = handle.clone(); + wasm_bindgen_futures::spawn_local(async move { + match handle_inner.upload_attachment(data).await { + Ok((hash, _size)) => { + status.set(UploadStatus::Done(hash)); + } + Err(e) => { + status.set(UploadStatus::Failed(format!("{e}"))); + } + } + }); + }); + // Hand the closure to JS so the FileReader keeps it alive until + // `onloadend` fires. `into_js_value` is the leak-free counterpart + // to `forget()`: JS owns the reference, so once the reader drops + // (after the spawn_local future resolves) the GC reclaims it. + let cb_value = cb.into_js_value(); + reader.set_onloadend(Some(cb_value.unchecked_ref())); + let _ = reader.read_as_array_buffer(&file); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_size_thresholds() { + assert_eq!(format_size(0), "0 B"); + assert_eq!(format_size(1024), "1.0 KB"); + assert_eq!(format_size(1024 * 1024), "1.0 MB"); + assert_eq!(format_size(1024 * 1024 * 7), "7.0 MB"); + } +} diff --git a/crates/web/src/lib.rs b/crates/web/src/lib.rs index 2fe7d482..8bd53240 100644 --- a/crates/web/src/lib.rs +++ b/crates/web/src/lib.rs @@ -24,5 +24,6 @@ pub mod state_bridge; #[cfg(feature = "test-hooks")] pub mod test_hooks; pub mod trust_store; +pub mod upload_state; pub mod util; pub mod voice; diff --git a/crates/web/src/upload_state.rs b/crates/web/src/upload_state.rs new file mode 100644 index 00000000..a87e0793 --- /dev/null +++ b/crates/web/src/upload_state.rs @@ -0,0 +1,162 @@ +//! Upload-queue context for the ``. +//! +//! Phase 3b shipped `` as a single-file path +//! straight to `upload_attachment` + `send_attachment_message`. The +//! spec (`docs/specs/2026-04-19-ui-design/files-inline.md` +//! §Upload dialog) calls for a modal sheet with a multi-file queue, +//! per-file progress + cancel, and a footer that batch-sends on +//! confirm. This module owns the session-scoped queue state — a +//! Leptos context shared between the composer attach button (which +//! flips the dialog open), the dialog itself (which renders + drives +//! the queue), and any future drag-overlay or paste handler that +//! enqueues files. + +use leptos::prelude::*; +use willow_network::BlobHash; + +/// Per-file upload progression state. +/// +/// Today the underlying iroh blob store doesn't surface incremental +/// progress events — `upload_attachment` resolves once the whole +/// blob lands. The dialog renders the row as `Uploading` until the +/// future resolves, then flips to `Done(_)` or `Failed(_)`. A future +/// blob-store-progress hook can swap `Uploading` for a 0..1 ratio +/// without changing this module's surface. +#[derive(Debug, Clone, PartialEq)] +pub enum UploadStatus { + /// Upload in flight; no progress signal yet. + Uploading, + /// Bytes landed in the blob store; ready to attach. Carries the + /// content-addressed hash for the eventual `FileMessage` send. + Done(BlobHash), + /// Upload failed; error string surfaces in the row. + Failed(String), +} + +/// One entry in the upload queue. The `status` signal is independent +/// per entry so the dialog can re-render a single row without +/// touching the rest of the queue. +#[derive(Debug, Clone)] +pub struct UploadEntry { + /// Stable session-local id used for `` keying + cancel. + pub id: String, + pub filename: String, + pub mime: String, + pub size: u64, + pub status: RwSignal, +} + +/// Session-scoped upload queue + dialog visibility. Provided once at +/// the app-shell layer and consumed by the composer attach button + +/// the `` itself. +#[derive(Clone, Copy)] +pub struct UploadQueue { + /// Whether the `` is currently mounted. + pub open: RwSignal, + /// Pending + completed entries, oldest at the front. + pub entries: RwSignal>, +} + +impl UploadQueue { + pub fn new() -> Self { + Self { + open: RwSignal::new(false), + entries: RwSignal::new(Vec::new()), + } + } + + /// Push a fresh entry into the queue. Returns the new entry's + /// id so the caller can spawn the upload future and update its + /// status when bytes resolve. + pub fn push( + &self, + filename: String, + mime: String, + size: u64, + ) -> (String, RwSignal) { + let id = next_entry_id(); + let status = RwSignal::new(UploadStatus::Uploading); + let entry = UploadEntry { + id: id.clone(), + filename, + mime, + size, + status, + }; + self.entries.update(|v| v.push(entry)); + (id, status) + } + + /// Remove an entry by id. Used by the per-row cancel button. + pub fn remove(&self, id: &str) { + self.entries.update(|v| v.retain(|e| e.id != id)); + } + + /// Clear the queue + close the dialog. Used by the footer + /// `cancel all` action. + /// + /// Does **not** abort in-flight uploads — the `iroh-blobs` upload + /// future has no cancel hook today, so completing the network + /// transfer is unavoidable. The user-visible effect ("file isn't + /// sent") is preserved: the row is gone before the future + /// resolves, so no `FileMessage` is emitted. If the spec ever + /// pins down hard cancellation, `UploadEntry` needs a cancel + /// flag the upload future polls. + pub fn cancel_all(&self) { + self.entries.update(|v| v.clear()); + self.open.set(false); + } +} + +impl Default for UploadQueue { + fn default() -> Self { + Self::new() + } +} + +/// Read the queue from context, providing a fresh one if absent +/// (e.g. unit-test mounts that don't construct the full app shell). +/// In production the app shell provides exactly one queue so the +/// composer button + the dialog see the same state. +/// +/// Logs a warning when the fallback fires — in production this means +/// the consumer mounted before the shell ran `provide_context`, which +/// would silently split the queue between consumers (the paperclip +/// would flip a queue the dialog never reads). The warning surfaces +/// the wiring bug instead of presenting it as a silent UX dead-end. +pub fn use_upload_queue() -> UploadQueue { + use_context::().unwrap_or_else(|| { + tracing::warn!( + "UploadQueue not in context; allocating a detached fallback. \ + This usually means a consumer mounted above the shell's \ + provide_context — both consumers must share one queue." + ); + let queue = UploadQueue::new(); + provide_context(queue); + queue + }) +} + +/// Monotonic counter for entry ids. Session-scoped and best-effort +/// — collisions are impossible in practice (uploads are user-driven +/// and can't fire faster than the counter increments). +fn next_entry_id() -> String { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(1); + format!("upload-{}", COUNTER.fetch_add(1, Ordering::Relaxed)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn next_entry_id_is_unique_within_session() { + // Same module → same atomic counter; ids are guaranteed to + // diverge across calls. + let a = next_entry_id(); + let b = next_entry_id(); + assert_ne!(a, b); + assert!(a.starts_with("upload-")); + } +} diff --git a/crates/web/style.css b/crates/web/style.css index 76e85283..62d2b913 100644 --- a/crates/web/style.css +++ b/crates/web/style.css @@ -1321,6 +1321,181 @@ select:focus-visible { padding: 6px 10px; } +/* ── Upload Dialog ───────────────────────────────────────────────── */ +/* Spec: docs/specs/2026-04-19-ui-design/files-inline.md §Upload dialog. + Modal sheet on --bg-1, --line border, radius 12 px, --shadow-2. + Scrim absorbs click-away dismissal. */ + +.upload-dialog__scrim { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + z-index: 900; +} + +.upload-dialog { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 901; + background: var(--bg-1); + border: 1px solid var(--line); + border-radius: 12px; + box-shadow: var(--shadow-2); + padding: 16px; + min-width: 360px; + max-width: 520px; + width: calc(100vw - 32px); + max-height: calc(100vh - 64px); + display: flex; + flex-direction: column; + gap: 12px; +} + +.upload-dialog__picker { + display: flex; + align-items: center; + gap: 12px; +} + +.upload-dialog__browse { + background: transparent; + color: var(--ink-0); + border: 1px solid var(--moss-1); + border-radius: 8px; + padding: 8px 14px; + cursor: pointer; + font-size: 14px; + -webkit-tap-highlight-color: transparent; + transition: background var(--transition-fast); +} + +.upload-dialog__browse:hover { + background: rgba(66, 92, 61, 0.18); +} + +.upload-dialog__hint { + color: var(--ink-2); + font-size: 13px; +} + +.upload-dialog__list { + list-style: none; + margin: 0; + padding: 0; + overflow-y: auto; + max-height: 50vh; + display: flex; + flex-direction: column; + gap: 6px; +} + +.upload-dialog__list:empty { + display: none; +} + +.upload-dialog__row { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(255, 255, 255, 0.02); +} + +.upload-dialog__icon { + color: var(--ink-2); + font-size: 18px; + flex-shrink: 0; +} + +.upload-dialog__filename { + flex: 1; + min-width: 0; + color: var(--ink-1); + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.upload-dialog__size { + color: var(--ink-2); + font-size: 12px; + flex-shrink: 0; +} + +.upload-dialog__status { + color: var(--ink-2); + font-size: 12px; + flex-shrink: 0; + min-width: 70px; + text-align: right; +} + +.upload-dialog__cancel { + background: transparent; + border: none; + color: var(--ink-2); + cursor: pointer; + padding: 4px 6px; + border-radius: 6px; + flex-shrink: 0; + transition: color var(--transition-fast), background var(--transition-fast); +} + +.upload-dialog__cancel:hover { + color: var(--ink-0); + background: rgba(255, 255, 255, 0.06); +} + +.upload-dialog__footer { + display: flex; + align-items: center; + gap: 12px; + padding-top: 8px; + border-top: 1px solid var(--line); +} + +.upload-dialog__spacer { + flex: 1; +} + +.upload-dialog__cancel-all, +.upload-dialog__confirm { + background: transparent; + border: 1px solid var(--line); + color: var(--ink-1); + border-radius: 8px; + padding: 8px 14px; + cursor: pointer; + font-size: 14px; + -webkit-tap-highlight-color: transparent; + transition: background var(--transition-fast), border-color var(--transition-fast), + color var(--transition-fast); +} + +.upload-dialog__cancel-all:hover { + color: var(--ink-0); + background: rgba(255, 255, 255, 0.04); +} + +.upload-dialog__confirm { + border-color: var(--moss-1); + color: var(--ink-0); +} + +.upload-dialog__confirm:hover:not([disabled]) { + background: rgba(66, 92, 61, 0.18); +} + +.upload-dialog__confirm[disabled] { + opacity: 0.5; + cursor: not-allowed; +} + /* ── Member List (right sidebar) ─────────────────────────────────── */ /* On desktop, the wrapper is transparent -- member-list renders normally */ diff --git a/crates/web/tests/browser.rs b/crates/web/tests/browser.rs index e9873955..7d6323b8 100644 --- a/crates/web/tests/browser.rs +++ b/crates/web/tests/browser.rs @@ -15023,3 +15023,111 @@ mod phase_3a_composer { ); } } + +/// Phase 3b T8 — `` mount and `confirm_disabled` flip. +/// +/// Spec: `docs/specs/2026-04-19-ui-design/files-inline.md` §Upload dialog. +/// Lowest-tier coverage per CLAUDE.md: a single-client DOM render that +/// asserts (a) visibility tracks `queue.open`, (b) the picker / scrim / +/// footer all mount, and (c) the `attach to message` button's +/// `disabled` state flips when at least one entry resolves to +/// `Done(_)`. +mod phase_3b_upload_dialog { + use super::*; + use willow_network::BlobHash; + use willow_web::components::UploadDialog; + use willow_web::upload_state::{UploadQueue, UploadStatus}; + + fn mount_dialog(queue: UploadQueue, channel: ReadSignal) -> web_sys::HtmlElement { + mount_test(move || { + provide_context(queue); + view! { } + }) + } + + #[wasm_bindgen_test] + async fn dialog_hidden_until_queue_opens() { + let queue = UploadQueue::new(); + let (channel, _) = signal("general".to_string()); + let container = mount_dialog(queue, channel); + tick().await; + + // Closed: no scrim, no sheet. + assert!(query(&container, ".upload-dialog__scrim").is_none()); + assert!(query(&container, ".upload-dialog").is_none()); + + // Flip open and confirm the picker, scrim, and footer appear. + queue.open.set(true); + tick().await; + assert!(query(&container, ".upload-dialog__scrim").is_some()); + assert!(query(&container, ".upload-dialog").is_some()); + assert!( + query(&container, ".upload-dialog__browse").is_some(), + "picker browse button must mount" + ); + assert!( + query(&container, ".upload-dialog__cancel-all").is_some(), + "footer cancel-all must mount" + ); + assert!( + query(&container, ".upload-dialog__confirm").is_some(), + "footer confirm must mount" + ); + + // Flip closed and confirm everything tears down. + queue.open.set(false); + tick().await; + assert!(query(&container, ".upload-dialog__scrim").is_none()); + assert!(query(&container, ".upload-dialog").is_none()); + } + + #[wasm_bindgen_test] + async fn confirm_button_enables_when_an_entry_completes() { + let queue = UploadQueue::new(); + let (channel, _) = signal("general".to_string()); + let container = mount_dialog(queue, channel); + + queue.open.set(true); + tick().await; + let confirm = query(&container, ".upload-dialog__confirm") + .expect("confirm button must mount when dialog is open") + .dyn_into::() + .unwrap(); + assert!( + confirm.disabled(), + "confirm starts disabled with an empty queue" + ); + + // Push an entry — still uploading, still disabled. + let (_id, status) = queue.push("notes.txt".to_string(), "text/plain".to_string(), 42); + tick().await; + assert!( + confirm.disabled(), + "confirm stays disabled while the only entry is still Uploading" + ); + + // Flip the row to Done — confirm enables. + let dummy_hash = BlobHash::from_bytes([0u8; 32]); + status.set(UploadStatus::Done(dummy_hash)); + tick().await; + assert!( + !confirm.disabled(), + "confirm enables once at least one entry resolves to Done" + ); + + // A row landing in Failed should not re-enable confirm by + // itself. Push a second entry, mark it Failed; confirm stays + // tied to the first row. + let (_id2, status2) = queue.push( + "broken.bin".to_string(), + "application/octet-stream".to_string(), + 7, + ); + status2.set(UploadStatus::Failed("nope".to_string())); + tick().await; + assert!( + !confirm.disabled(), + "confirm stays enabled because the first entry is still Done" + ); + } +} diff --git a/docs/specs/2026-04-19-ui-design/files-inline.md b/docs/specs/2026-04-19-ui-design/files-inline.md index 0b9dc017..c65db8b5 100644 --- a/docs/specs/2026-04-19-ui-design/files-inline.md +++ b/docs/specs/2026-04-19-ui-design/files-inline.md @@ -218,10 +218,12 @@ easing. Drag overlay crossfades in under reduced motion. - [ ] Voice notes render the waveform + play / pause + mm:ss timer card, and starting one pauses any other. *(Placeholder ships in phase 3b; full surface in T6.)* -- [ ] Upload dialog opens from the composer attach button with a - picker row, per-file progress + cancel, and the footer actions - in §Copy. *(Phase 3b ships a single-file direct upload via the - paperclip; full dialog is T8.)* +- [x] Upload dialog opens from the composer attach button with a + picker row, per-file rows + cancel, and the footer actions in + §Copy. *(`` + `UploadQueue` context — phase 3b T8. + v1 ships binary `uploading` / `done` / `failed` status; the + progress bar drops in once `iroh-blobs` exposes incremental + progress hooks.)* - [ ] Drag-and-drop anywhere in the desktop window opens the upload dialog with dropped files enqueued; the overlay uses the copy in §Copy. *(T10.)* @@ -229,8 +231,9 @@ easing. Drag overlay crossfades in under reduced motion. upload dialog instead of inserting text. *(T12.)* - [ ] Every interactive element has an ARIA label per §Accessibility. *(`download {filename}` + `attach file` shipped in phase 3b; - voice-note + upload-cancel + drag-overlay labels land with - T6 / T8 / T10.)* + `cancel upload of {filename}` + dialog `upload attachments` role + shipped in T8; voice-note + drag-overlay labels land with + T6 / T10.)* ## Open questions