Conversation
…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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)
After (Files tab with folders, bulk select, drag-and-drop)
What it does
Related issue
None found — this is a new feature.
Testing