Skip to content

feat: server folders in the server list - #1589

Open
GalaxyAkiii wants to merge 2 commits into
stoatchat:mainfrom
GalaxyAkiii:server-folders
Open

GalaxyAkiii wants to merge 2 commits into
stoatchat:mainfrom
GalaxyAkiii:server-folders

Conversation

@GalaxyAkiii

Copy link
Copy Markdown
Contributor

Adds collapsible folder grouping to the server list sidebar.

Resolves #721
Addresses stoatchat/stoatchat#577 (and duplicate #933)

What changes

  • Store & sync (Ordering.ts):
    • Expanded the ordering data model from string[] to (string | ServerFolder)[].
    • Stays inside SynchronisedStores, so folders sync across devices with zero backend/schema changes.
    • clean() handles deduping and migration when dealing with flat arrays written by older clients.
    • orderedServers() still returns a flattened array with folder items inlined so keyboard shortcuts, navigation, and the emoji picker don't break.
    • Sidebar consumes the new orderedEntries() accessor for the nested layout.
  • Sidebar UI (ServerList.tsx):
    • Split out ServerEntry from ServerList.
    • Added FolderEntry with expand/collapse toggle and aggregated unread/mention counts when folded.
    • Added FolderGroup with a theme-aware color-mix(...) background so the expanded rail stands out cleanly across themes.
  • Context menus & modals:
    • Added "Add To folder" submenu and "Remove From folder" to ServerContextMenu.
    • Added ServerFolderContextMenu to rename or unpack/delete folders.
    • Added EditServerFolder modal for renaming.

Notes & caveats

  • No nested DnD yet: The current solid-dnd-directive setup runs on a single flat container. Teaching it nested drop zones is a much bigger refactor, so adding/removing from folders is context-menu only for now.
  • Older clients: If an older client without folder support writes to the sync store, its clean() step strips non-string IDs. Server order survives, but folders get unpacked back to flat entries on their write.
  • Folder visuals: Folder colors are in the model and render if set, but there's no color picker in the modal yet. Icon is a plain glyph rather than a grid preview.

How was this PR tested?

  • Created folders, moved servers in/out via context menu
  • Deleting a folder unpacks member servers back to the root list in place
  • Unread and mention badges bubble up to the folder icon when collapsed
  • Tested color-mix surface contrast across dark, light, and high-contrast themes
  • Keyboard navigation still traverses through all servers in order
  • pnpm exec tsc --noEmit clean across all feature files
  • pnpm exec eslint and pnpm exec prettier --check clean (0 errors, 0 warnings across all 10 files)

Screenshots & Screencasts (if appropriate)

Create Folder/Add Server to existing folder
Screenshot 2026-09-03 at 11 57 26 AM
Screenshot 2026-09-03 at 9 34 43 PM
Folder in server list open
Screenshot 2026-09-03 at 7 35 52 PM
Rename Folder
Screenshot 2026-09-03 at 7 35 25 PM
Folder Management
Screenshot 2026-09-03 at 7 35 09 PM
Folder Closed
Screenshot 2026-09-03 at 9 27 46 PM

Checklist:

  • I have carefully read the contributing guidelines
  • I have performed a self-review of my own code
  • I have made corresponding changes to the documentation if applicable
  • I have no unrelated changes in the PR
  • I have confirmed that any new dependencies are strictly necessary
  • I have written tests for new code (if applicable)
  • I have followed naming conventions/patterns in the surrounding code

Please declare, if any, LLM usage involved in creating this PR

None

@GalaxyAkiii
GalaxyAkiii requested review from a team as code owners September 3, 2026 12:41

@Dadadah Dadadah left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent first draft. Needs a few changes, but overall I'm happy with how this looks code-wise. Do note that in order to merge this we have to make sure the android client supports it (or atleast doesn't stomp the folders).

This change will likely need buy-in from the core team, so expect merging this to take a little bit.

I believe in it's current state it would not pass UI review, and that is primarily due to the folders not showing their contents when collapsed. I left a comment regarding this.

Comment on lines +276 to +278
const id = `${FOLDER_PREFIX}${Date.now().toString(36)}${Math.random()
.toString(36)
.slice(2, 8)}`;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have access to ULID generation in the client via the ulid package.

import { ulid } from "ulid";

const id = `${FOLDER_PREFIX}${ulid()}`;

fallback={entry.item.name}
interactive
<Switch>
<Match when={entry.item.type === "server" && entry.item}>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't entry.item be before the equality check here? Otherwise if it's falsy the first check might error.

</Tooltip>
)}
</Match>
<Match when={entry.item.type === "folder" && entry.item}>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't entry.item be before the equality check here? Otherwise if it's falsy the first check might error.

Comment on lines +437 to +448
<a onClick={() => state.ordering.toggleFolder(props.entry.folder.id)}>
<Avatar
size={42}
holepunch={collapsed() && mentions() ? "top-right" : "none"}
overlay={
<Show when={collapsed() && mentions()}>
<Unreads.Graphic count={mentions()} unread />
</Show>
}
fallback={<MdFolder />}
/>
</a>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than falling back to the MdFolder icon, could it display the icons of the servers inside? This may require making a new container to hold avatars in a smaller state, but I think it will make it more clear what's in each folder if it shows them.

@Dadadah

Dadadah commented Sep 7, 2026

Copy link
Copy Markdown
Member

To make dnd work you will need to:

  • Wrap every server and folder in a container
    • The container wrapping servers should turn into a folder container on consider (this may require updating Draggable.tsx to accept an onConsider function)
    • The onChange handler should create a folder for servers, and add the server to the folder for folders
  • Wrap each of these containers in a Draggable with type=servers
  • Disable morphing on all the dragables on the servers list (this will require updating Draggable.tsx)

It's possible I may be missing something with this, but this should be it. This may be a tall request, so let me know if you run into any issues and I can take a look at it and help implement it.

@GalaxyAkiii
GalaxyAkiii force-pushed the server-folders branch 4 times, most recently from 5798782 to a95f3a0 Compare September 8, 2026 16:28
@GalaxyAkiii

Copy link
Copy Markdown
Contributor Author

Hey @Dadadah, thanks for the feedback.

Couldn't sleep last night (or really tonight so far) so ended up staying up overnight hacking away at this even before you left your comment and all of yesterday before practice. A good chunk of what you brought up was actually stuff I had started looking into right after putting up the initial PR, so I'm glad to say it's pretty much all sorted now. I do apologise, by my own standards this was a half done job that shouldn't have been put out in the first place.

Regarding the Android client, that's a really great point about not stomping over folders, thank you for bringing it up. Having tinkered with and tried developing an alternative iOS client in the past (sitting in the ether for now), I know the client codebases fairly well and went back to check specifically from your point today. If folders were stored directly inside ordering.servers, any client without folder support (like the Android app) would just see a flat list of server IDs and overwrite the whole array the first time someone reorders a server on their phone, losing all the folder data. So I spent some time today moving folder storage into its own "server-folders" sync key instead. That way ordering.servers stays as plain string IDs, and older/other clients can read and write to it without accidentally touching any folder settings.

On the drag-and-drop side, I actually started out trying to adapt Draggable.tsx originally with onConsider and disabling morphing like you had suggested much earlier, but solid-dnd-directive kept shifting the DOM elements around mid-drag. Because items kept hopping out of the way as you approached them, trying to drop onto a server to create or join a folder felt super finicky.

I ended up writing a dedicated drag handler for the server rail (railDrag.ts) instead. The rail items stay put while dragging and an insertion line shows where you're dropping. If you drop onto the middle of a server it folds them into a folder, dropping above or below reorders, dragging off to the side cancels, and it includes full keyboard navigation (Space to pick up/drop, arrows to move) plus localized screen reader announcements.

I also added the mini-grid preview on collapsed folders so you can see avatars of what's inside instead of just a generic folder icon. You'll also notice ServerRoleEditor.tsx in the diff: rather than copy-pasting the ~200 lines of palette and picker code that was private to the role editor, I lifted it into a shared ColourPicker component in @revolt/ui for both to use. As a bonus, it also fixes an old bug where clicking 'Custom colour' on roles in Chrome literally did nothing and never showed up (Chrome blocks .click() on hidden color inputs, so the shared component overlays the input over the button so native clicks work).

Video of Folders (File was too big for GitHub)
https://youtu.be/silABip5PPo?si=gUiecyHi4Ii9I7Q3

Folder with Colour Picker
Folder with Colour Picker

Mix of collapsed and coloured folders
Mix of collapsed + coloured + etc

Folders with notifications
Screenshot 2026-09-09 at 1 24 58 AM

That should be all from me. Definitely understand the need for patience in this. Please let me know if you need anything further from me. I have tried to go over everything, but if I have missed anything at all please do let me know.

@GalaxyAkiii
GalaxyAkiii requested a review from Dadadah September 8, 2026 16:44
@Dadadah

Dadadah commented Sep 10, 2026

Copy link
Copy Markdown
Member

Please drop the i18n folder from this pr.

This PR grew in scope a bit so I'll sit down and review it some time over the next few days. Thanks for the write up!

Signed-off-by: Aki <aki@akiworks.xyz>
@GalaxyAkiii

Copy link
Copy Markdown
Contributor Author

All done, Thank you.

@Dadadah

Dadadah commented Sep 11, 2026

Copy link
Copy Markdown
Member

I've spoken with the core team about this, and it's come to our attention that we are unable to modify the existing schema of synced store keys. This means that for this pr, it will need to include a migration from the ordering.servers string array to a new value, ordering.serverSidebar will work. It should read from ordering.servers if ordering.serverSidebar is undefined, but write to ordering.serverSidebar only. This will allow the original server list to work across old clients.


const NoColourIndicator = styled(ColourIndicator, {
base: {
border: "2px solid var(--md-sys-color-outline)",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs to be dashed, not solid

Image

@GalaxyAkiii

Copy link
Copy Markdown
Contributor Author

Fair enough, I will continue working on this tonight or tomorrow on the flight. Thank you both.

@GalaxyAkiii

Copy link
Copy Markdown
Contributor Author

@Dadadah checking this still applies, since the storage changed on the 9th.

Folders no longer live inside ordering.servers. They moved to a separate synced key (server-folders), and ordering.servers stays a plain string[], so its schema is unchanged from main.

What the PR does still do is write to ordering.servers when the order changes. If that's the part you want gone, I'll move writes to ordering.serverSidebar and fall back to ordering.servers when it's undefined.

Two ways I can read that:

  1. serverSidebar holds the whole folder-aware structure, folders inline and server-folders goes away.
  2. serverSidebar holds the ordering with folder ids in it, and folder metadata stays in its own key.

Which did you have in mind? And is a separate synced key okay at all, or should all of this sit under ordering?

(@infi's dashed border I'll pick up in the same pass.)

@amycatgirl amycatgirl left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was testing this locally and ran into an issue when dragging servers around with the custom draggable implementation. Left a comment about it.

<Show when={isInsertionBefore(props.drag, props.server.id)}>
<div class={railInsertion} />
</Show>
<a href={state.layout.getLastActiveServerPath(props.server.id)}>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This anchor needs to have the draggable="false" attribute to prevent it from taking precedence over the draggable container.

fallback={
<Show
when={collapsed() && props.entry.servers.length}
fallback={<MdFolder />}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Wouldn't it make more sense to use FolderOpen here?

Preferably, this would use the much clearer folder_open icon from material symbols, but this could be done in a separate PR.

style={{
// a box shadow rather than an outline, so that the unread
// badge still draws on top of it
"box-shadow":

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I should probably leave this for UX Review, but I think that an outline looks out of place with the rest of the UI.

Maybe a background using the colour plus some transparency on top of it could be better? Like when the folder is expanded.

…rSidebar

Signed-off-by: Aki <aki@akiworks.xyz>
@GalaxyAkiii

Copy link
Copy Markdown
Contributor Author

Went with option 2 for the migration, keeping ordering.servers untouched and handling the sidebar list under a new field on ordering.

Summary of where it stands:

  • ordering.servers is now read-only fallback. Nothing writes to it anymore, so its schema is untouched and older clients keep the list they already had.
  • ordering.serverSidebar is a flat string[] holding server IDs and folder IDs. A reorder is the only operation that writes to it. Reads fall back to ordering.servers while it is undefined, which carries an existing server order across the first time this client touches the list.
  • server-folders still holds the folder metadata (name, colour, collapsed state, members).

A folder's members are kept in serverSidebar directly after the folder's own ID. This preserves placement when unpacking a folder or dragging a server out of one, keeping everything in place rather than dropping it to the bottom of the rail.

orderedEntries() resolves a folder at its ID in serverSidebar, falling back to the position of its first member otherwise. That fallback also covers an older client overwriting the ordering key: folder data in server-folders survives, and relative rail position is recovered from where the members sit.

That leaves the question of whether a second synced key is acceptable. If the core team would rather everything sat under ordering, folding server-folders in is a contained change and I'm happy to do it. Just let me know.

Also in this pass:

  • @infi: Restored the dashed border (2px dashed var(--md-sys-color-on-surface-variant)) on the default swatch.
  • @amycatgirl: Added draggable="false" on the server anchor. Links are draggable by default, so the native drag could take the pointer before the rail saw it.
  • @amycatgirl: Swapped the expanded folder glyph to folder_open. Left the material symbols change for a separate PR as you suggested.
  • @amycatgirl: Dropped the ring on collapsed folders. The colour now uses the same translucent background the expanded state already had, so the two match rather than using different treatments for the same thing. Happy to put it back if the ring reads better to you or whoever, since you flagged it as a design call.
Screenshot 2026-09-14 at 8 48 51 PM

@amycatgirl amycatgirl left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems to work completely fine now.

Left a comment about the server folder background while not expanded since there seemed to be a misunderstanding between what I meant and the code I highlighted in the review.

But again, UX review is not my thing, you can feel free to ignore my comment since it's better to leave this to Jennifer.

LGTM

<FolderGroup
expanded={!collapsed()}
style={{
background: props.entry.folder.colour

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant as a background for the avatar container but close enough.

I meant something more akin to this:

Image

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair enough, I will take that into consideration and do it as well although I will await UX review and go with which one is selected. Thank you.

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.

[Feature] Server folders

4 participants