Skip to content

feat(desktop): add Files tab with folders, bulk select, and drag-and-drop - #4316

Open
mismai-li wants to merge 1 commit into
block:mainfrom
mismai-li:feat/channel-files-tab
Open

mismai-li wants to merge 1 commit into
block:mainfrom
mismai-li:feat/channel-files-tab

Conversation

@mismai-li

@mismai-li mismai-li commented Aug 2, 2026

Copy link
Copy Markdown

Summary

Adds a Files tab to the channel view for browsing and organizing all file attachments shared in a channel. Includes synced file folders stored as Nostr kind:30078 events, bulk select with always-visible checkboxes, drag-and-drop file organization, and nested folder support.

Screenshots

Before (Chat tab only — no file browser)

Chat tab before

After (Files tab with folders, bulk select, drag-and-drop)

Files tab after

What it does

  • Files tab: List view with file type, size, dimensions, SHA-256 verification, sender info, and message captions
  • Filters & sort: Category tabs (All/Images/Videos/Documents/Other), text search, and sort options
  • Synced folders: Collaborative folders stored as kind:30078 replaceable events — syncs across team members via the relay
  • Drag-and-drop: Drag files into folders, drag folders to nest/un-nest
  • Bulk select: Always-visible checkboxes, shift-click range selection, bulk move to folder, bulk remove from folder

Related issue

None found — this is a new feature.

Testing

  • Verified on Windows desktop build
  • Tested: file list, search, sort, category filters
  • Tested: folder create/delete, drag-and-drop, nested folders
  • Tested: bulk select, shift-click range, bulk move/remove
  • Tested: tab switching (Chat ↔ Files) preserves state

…drop

Adds a Files tab to the channel view for browsing and organizing all
files shared in a channel.

Core Files Tab:
- List view showing filename, type, size, dimensions, date, and sender
- Category filter tabs: All / Images / Videos / Documents / Other
- Search by filename or message caption
- Sort by newest, oldest, name, or size
- SHA-256 verification badge on hashed files
- Message caption (first line of body text) displayed per file
- Jump-to-message navigation (click to scroll to original message)

Synced File Folders (kind:30078):
- Create, delete, and nest folders stored as Nostr replaceable events
- Folders sync across team members via the relay
- Drag-and-drop files into folders
- Drag folders onto other folders to nest them
- Drag nested folders to the main area to un-nest
- Child folders hidden when parent is collapsed
- X button to remove files from folders

Bulk Select:
- Always-visible checkboxes on every file row
- Shift-click for range selection
- Bulk action bar with count, Select All, Clear
- Move to folder dropdown for bulk organization
- Remove from folder for bulk un-filing

The tab bar (Chat | Files) sits below the channel header chrome,
rendered once as a shared element for both tabs.

Signed-off-by: mismai-li <mi@mismai.li>
@mismai-li
mismai-li requested a review from a team as a code owner August 2, 2026 13:21
mismai-li added a commit to mismai-li/buzz that referenced this pull request Aug 2, 2026
github-merge-queue Bot pushed a commit to ZeroSum-Solutions/buzz that referenced this pull request Sep 5, 2026
* feat(desktop): add Files tab with folders, bulk select, and drag-and-drop

Adds a Files tab to the channel view for browsing and organizing all
files shared in a channel.

Core Files Tab:
- List view showing filename, type, size, dimensions, date, and sender
- Category filter tabs: All / Images / Videos / Documents / Other
- Search by filename or message caption
- Sort by newest, oldest, name, or size
- SHA-256 verification badge on hashed files
- Message caption (first line of body text) displayed per file
- Jump-to-message navigation (click to scroll to original message)

Synced File Folders (kind:30078):
- Create, delete, and nest folders stored as Nostr replaceable events
- Folders sync across team members via the relay
- Drag-and-drop files into folders
- Drag folders onto other folders to nest them
- Drag nested folders to the main area to un-nest
- Child folders hidden when parent is collapsed
- X button to remove files from folders

Bulk Select:
- Always-visible checkboxes on every file row
- Shift-click for range selection
- Bulk action bar with count, Select All, Clear
- Move to folder dropdown for bulk organization
- Remove from folder for bulk un-filing

The tab bar (Chat | Files) sits below the channel header chrome,
rendered once as a shared element for both tabs.

Signed-off-by: mismai-li <mi@mismai.li>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>

* refactor(desktop): extract channel-files pure helpers for testability

Pull the imeta-parsing loop and the folder tag-mutation logic out of
useChannelFiles/useFileFolders' hook bodies into exported pure
functions (parseChannelFiles, buildFileFolderMap, withFileAddedToFolder,
etc.). The hooks call these directly, so tests bind to the same
production code path instead of a reimplementation.

Also fixes drift against the current relayClient/signRelayEvent APIs
(publishEvent now takes timeout/error messages, fetchEvents replaces
the now-private requestHistory) surfaced by tsc after the port/4316
cherry-pick, awaits every publish before invalidating the folder query
so a failed write can't leave a stale cache, and surfaces every
publish failure to the user via toast instead of swallowing it.

Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>

* test(desktop): add channel-files unit and e2e coverage

useChannelFiles.test.mjs covers imeta parsing, the Markdown-file-labeled-
by-filename-not-.bin case, newest-first ordering, caption extraction,
sortFiles and categorizeFile.

useFileFolders.test.mjs covers the folder assignment round trip (create
-> parse -> add file -> parse -> remove file -> parse) plus multi-file
merge/no-op, rename d-tag changes, parent set/clear, and the file-folder
map grouping.

channel-files-tab.spec.ts (registered in the smoke allow-list) drives
the ported UI end to end through the mock bridge: switches to the Files
tab, asserts a Markdown attachment is labeled by its imeta filename
rather than its .bin raw URL, asserts a plain message without an
attachment is excluded, and follows "Jump to message" back to the Chat
tab and the original message.

Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>

* fix(desktop): cap relay-sourced file/folder strings and counts at the DTO

parseChannelFiles and parseFolder now cap every relay-sourced string
(filename, caption, mime type, folder name, folder d-tag) and every
relay-sourced count (imeta attachments parsed from one event, file ids
exposed from one folder) at the DTO boundary, so a hostile or buggy
event can't hand the Files tab an unbounded string to render or an
unbounded list to enumerate.

withFilesAddedToFolder now derives its existing-id set from the
folder's raw tags rather than the (capped-for-display) fileEventIds
field — merging against the truncated view would have treated a
pre-existing id past the cap as new, and worse, silently dropped real
"e" tags past the cap out of the filtered result.

Each cap has a guard test proving both that oversized input is capped
and that removing the cap fails the test (verified locally by
temporarily reverting the cap constants and confirming three
assertions fail with the exact expected message).

Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>

* fix(desktop): use truncatePubkey for the Files tab avatar fallback

check-pubkey-truncation.mjs flagged a hand-rolled file.pubkey.slice(0, 8)
in FileCard.tsx; route through the canonical helper instead.

Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>

* docs(desktop): add doc comments to the newly exported folder helpers

folderSlug and folderDTag became public API when the folder tag-mutation
logic was extracted for testability; give them the doc comments new
public API needs.

Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>

* fix(desktop): fix Files tab bulk-remove race, folder-nest cycle, and header/tab-strip layout

Consolidated fix round for T3 (port/4316) review findings:

- ChannelFilesTab.tsx: bulk "Remove from folder" looped one publish per
  selected file from a stale folder reference, so each iteration re-added
  files the previous one had just removed. Added a bulk primitive
  (withFilesRemovedFromFolder + removeFilesFromFolder in useFileFolders.ts,
  onRemoveFilesFromFolder prop) that publishes one kind:30078 event for the
  whole selection.
- ChannelFilesTab.tsx: dragging a folder onto its own descendant only
  checked for direct self-nest, letting a cyclic parent chain strand both
  folders and their files. Added wouldCreateFolderCycle (useFileFolders.ts)
  and use it in the drop handler.
- ChannelScreen.tsx: the Files tab strip rendered above wherever the channel
  header ended up, pushing the header (and, during a huddle transcript, the
  transcript content) down by the strip's own height. channelHeader now
  renders once above the tab strip, shared by Chat and Files, hidden during
  a huddle transcript and the narrow single-panel view exactly as
  ChannelPane's own prior gate did. The header is a zero-net-height overlay
  (own negative margin cancels its flow contribution), so the tab strip
  needed a matching top-clearing margin (channelChrome.clearHeaderMargin,
  chromeLayout.ts) or it was visually covered by the header, intercepting
  clicks meant for the Files tab.
- useChannelFiles.test.mjs: added the missing revert-verified cap test for
  MAX_MIME_TYPE_LENGTH.

Signed-off-by: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>

* fix(desktop): render the channel tab strip inside the header box

The Files tab port put its tab strip between the channel header and
ChannelPane. The fork's header is a measured overlay: its chrome wrapper
carries a negative bottom margin sized to its own measured height, and
everything below it re-adds exactly that one measured offset --
MessageTimeline's contentPadding, the sticky day divider's
stickyTimelineTop, and the shared blur band. A strip in between adds a
second, unmeasured offset those all miss, so they stacked below it:
channels.spec.ts:1974 measured the sticky day pill 94px (42px strip +
52px header clearance) under the header bottom instead of 8px.

Conditional offsets cannot fix this. The assertion is
stickyPill.y == chatHeaderBox.bottom + 8, so any real flow height
between the header box and the timeline fails by that height. The strip
has to stop being a sibling of the header.

ChatHeader gains an optional `tabs` slot rendered inside the header
element, under the title row, so the strip grows the header's own border
box. `py-2` moves onto a new inner row that carries chromeWrapperRef, so
the measured height stays the title row alone while the header box grows;
<header> keeps its px-5, so its inline padding is unchanged. ChannelScreen
hands the strip to the header, restores header={channelHeader} on
GuardedChannelPane, and gives the Files tab the same header element plus
one contentPadding wrapper. channelChrome.clearHeaderMargin is deleted --
nothing clears a header it no longer sits below, and chromeLayout.ts is
back to its zs/main contents.

The header is now hidden in the single-panel view only while Chat is
active: the Files tab renders no auxiliary panel, so hiding its header
there would strand the user with no way back to Chat.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>

* feat(desktop): surface the relay's OK message to publishers

A replaceable write that loses a same-second race is acknowledged with a
successful OK whose message reads `duplicate:`, so "the publish promise
resolved" is not "the update landed". Thread an optional `onOk` callback
through publishEvent so a caller that maintains a replaceable aggregate
can tell an applied write from a superseded one; every existing caller is
unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>

* feat(desktop): model channel file folders as one bounded snapshot

Replaces the one-replaceable-event-per-folder wire format with a single
snapshot per (user, channel): immutable random folder ids, names as data,
and file assignments in one object, so a rename is a field edit, a delete
cascades inside one write, and a move is one assignment change with
exactly one owner per file at every instant.

Every bound sits on the quantity that costs and is checked before the
payload is materialised: the byte budget runs before JSON.parse, then
folder count, assignment count, name length, and nesting depth. An
over-limit, cyclic, or orphaned payload is rejected as invalid rather
than truncated into something that reads as complete.

Pure module with no relay, query-client, or DOM dependency, so each bound
and each transform is bound by a unit test that fails if the guard goes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>

* fix(desktop): serialise folder writes and stop reporting lost ones as saved

The folder hook now reads and writes one encrypted kind:30078 aggregate
per (user, channel), addressed by a `d` tag that hashes the channel id
instead of embedding it, fetched by that exact coordinate rather than an
arbitrary capped scan of the author's app-data events. Folder names,
topology, and file references no longer travel as plaintext tags.

Every mutation runs through a per-channel queue: it re-reads the head,
applies a pure transform to it, signs with a strictly monotonic
created_at, publishes, and only then updates the cache. A superseded OK
is replayed once against a re-read head and otherwise surfaced as an
error, so a dropped write can never be reported as a saved change.

A failed read is an error state with a refetch, not an authoritative
empty one, and an unreadable payload marks the state invalid and blocks
mutation instead of letting a write build on a partial view.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>

* fix(desktop): bound the Files tab attachment parse at the scan, not after it

The shared imeta parser walks every tag and part of an event and builds
the whole map before any caller-side cap runs, and the Files tab projects
every loaded message, so that scan was the hot path. A dedicated bounded
parser stops after 20 attachments, limits the tags and parts it reads,
validates URL scheme and length, hash grammar, size range, and dimension
format before storing anything, and keeps only the capped copies rather
than the raw parsed entry. Captions come from a bounded content prefix.

Rows now carry a per-attachment key (message id plus a digest of the URL)
so two attachments on one message are two addressable rows, and the
projection caps its total row count and reports truncation instead of
presenting a partial list as the whole set.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>

* fix(desktop): make the Files tab list bounded, recoverable and reachable

Rows, selection, drag payloads and folder membership are all keyed by the
per-attachment file key, so selecting or dragging one attachment no
longer takes its siblings on the same message with it.

Selection is now a mode the user turns on: outside it the filename and
thumbnail links navigate, and inside it Shift extends the range through
both the pointer and the keyboard path. The per-row action cluster
reveals on focus as well as hover, so keyboard focus never lands on an
invisible control. Folder rows expose aria-expanded and carry a "Move
to…" select, including Root, so nesting and un-nesting are reachable
without a pointer.

Every folder mutation goes through one helper that marks its control
pending, awaits the result, and catches — a second click while a write is
in flight is a no-op, and no rejection escapes unhandled. A failed file
load renders a retryable error distinct from "No files yet", a broken
folder payload is surfaced and disables every folder control, and the
list virtualizes past 60 rows so a heavily paged channel cannot mount an
unbounded DOM.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>

* fix(desktop): give the channel tabs the ARIA tabs contract and a geometry test

Extracts the Chat/Files strip into its own component implementing the
ARIA Tabs pattern — roving tabIndex so the strip is one Tab stop,
Arrow/Home/End to move between tabs, and explicit aria-controls and
aria-labelledby links to the mounted panel. The strip still rides inside
the header box, so the measured title-row height that every downstream
offset clears is unchanged.

The Files projection is now gated on the tab having been opened for the
channel, so a Chat-only session never re-parses the whole loaded message
window on each incoming live message.

The e2e spec asserts the stacking order geometrically — title above tabs,
tabs above the first row, no horizontal document overflow — at the
default width and again narrow, so removing the measured header ref or
the Files padding fails the test instead of shipping.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>

* test(desktop): bind the Files tab projection gate and list bounds

Extracts the "has this channel's Files tab been opened" gate into a pure
projection so its false branch is testable, and adds falsifiable tests
for the row-count threshold that turns virtualization on, the plain
render below it, and the focus-within reveal on the row action cluster.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>

* fix(desktop): report a committed folder write as saved, not as a second one

A publish that reaches the relay and is then re-sent after a socket failure
comes back accepted with `duplicate:`, so `isSupersededOk` is true for a write
that actually landed. The retry then replayed the transform against a head that
already held the change: `createFolder` minted a fresh id inside the retryable
transform and published the same folder twice, and `deleteFolder` told the user
"That folder no longer exists." about a delete that had committed.

Mint the folder id once, before `runMutation`, so the transform is the pure
snapshot-to-snapshot function the retry loop assumes. After a superseded OK,
force-read the head and treat `head.id === event.id` as committed. Make
`withFolderCreated` idempotent by id for the narrow case where another device
writes on top of our committed event before its acknowledgement arrives.

The harness now models a committed-then-acknowledged-as-duplicate OK; all four
new tests fail with the guards removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>

* fix(desktop): spend the imeta tag budget on every tag, not only imeta ones

`MAX_IMETA_TAGS_SCANNED` bounded the wrong quantity: the counter was
incremented only after a non-imeta tag had already been skipped, so the 64-tag
budget never bounded the walk its own doc comment describes. A 512 KiB relay
frame (`crates/buzz-relay/src/config.rs:14`) holds tens of thousands of minimal
tags, and the Files tab re-projects every loaded event on each incoming live
message once the tab has been opened.

Bound the loop by its index so every tag the scan touches spends the budget,
and fold the `Array.isArray` guard for a malformed tag into the same line. The
new test counts index reads through a proxy and fails against the old loop.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>

* fix(desktop): measure the whole channel header, tab strip included

The Files tab port moved chromeWrapperRef off the header's outer chrome
wrapper and onto a new inner title row, so
--buzz-channel-content-top-padding measured the title row alone while the
wrapper grew by the 42px tab strip. That variable is also the wrapper's
own negative bottom margin, so the header stopped cancelling its own
height: it contributed 42px of real flow and pushed every sibling in the
channel column down by that much.

Offsets built on the variable stayed correct -- column top + (header
height - var) + var is header height either way -- which is why the
sticky day divider and the Files row stacking still passed. What broke is
anything sized to the column rather than offset inside it: the Chat
column's drop-zone overlay is `absolute inset-0` in that shifted sibling,
so it came out 42px shorter than the drop zone it must cover.
file-attachment.spec.ts:450 caught it on Desktop Smoke E2E shard 2, where
the overlay measured y=83/h=629 against a drop zone of y=41/h=671.

chromeWrapperRef goes back on the outer wrapper, so the measured height
is the whole header box again and the margin cancels it exactly. The
inner py-2 row stays -- it is what keeps `tabs` flush under the title row
and leaves <header>'s own inline padding alone. Every downstream offset
is unchanged by construction; only the column-sized children move back.

Tests: channel-files-tab.spec.ts gains a case that ties the tab strip to
the Chat column -- the measured variable must equal the header box, the
drop overlay must cover the drop zone edge to edge, and the drop must
still reach the composer. ChannelFilesTab.test.mjs gains the Files-tab
half, dragging a file row onto a folder row through the real
dragstart/dragover/drop handlers (also closes a recorded follow-up).
Both fail with chromeWrapperRef back on the inner row.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>

---------

Signed-off-by: mismai-li <mi@mismai.li>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
Signed-off-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: mismai-li <mi@mismai.li>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant