From 73c337d4298ff9ffbbf7dd94b920fd40e608f8a2 Mon Sep 17 00:00:00 2001 From: Aki Date: Thu, 3 Sep 2026 20:44:50 +0900 Subject: [PATCH 1/2] feat: server folders in the server list Signed-off-by: Aki --- .../server/roles/ServerRoleEditor.tsx | 228 +----- .../app/menus/ServerContextMenu.tsx | 44 ++ .../app/menus/ServerFolderContextMenu.tsx | 50 ++ .../client/components/app/menus/index.tsx | 1 + packages/client/components/modal/modals.tsx | 3 + .../modal/modals/EditServerFolder.tsx | 77 ++ packages/client/components/modal/types.ts | 5 + packages/client/components/state/index.tsx | 4 + .../components/state/stores/Ordering.ts | 202 +++++- .../components/state/stores/ServerFolders.ts | 303 ++++++++ .../client/components/state/stores/Sync.ts | 8 +- .../client/components/state/stores/index.ts | 2 + .../ui/components/design/ColourPicker.tsx | 258 +++++++ .../components/ui/components/design/index.ts | 1 + packages/client/src/interface/Sidebar.tsx | 1 + .../navigation/servers/ServerList.tsx | 673 +++++++++++++++--- .../interface/navigation/servers/railDrag.ts | 428 +++++++++++ 17 files changed, 1953 insertions(+), 335 deletions(-) create mode 100644 packages/client/components/app/menus/ServerFolderContextMenu.tsx create mode 100644 packages/client/components/modal/modals/EditServerFolder.tsx create mode 100644 packages/client/components/state/stores/ServerFolders.ts create mode 100644 packages/client/components/ui/components/design/ColourPicker.tsx create mode 100644 packages/client/src/interface/navigation/servers/railDrag.ts diff --git a/packages/client/components/app/interface/settings/server/roles/ServerRoleEditor.tsx b/packages/client/components/app/interface/settings/server/roles/ServerRoleEditor.tsx index 750bf0f4dd..ed923c9dd7 100644 --- a/packages/client/components/app/interface/settings/server/roles/ServerRoleEditor.tsx +++ b/packages/client/components/app/interface/settings/server/roles/ServerRoleEditor.tsx @@ -1,4 +1,4 @@ -import { For, Show, createMemo, createSignal } from "solid-js"; +import { For, createMemo } from "solid-js"; import { Trans, useLingui } from "@lingui/solid/macro"; import { createFormControl, createFormGroup } from "solid-forms"; @@ -10,12 +10,11 @@ import { useInstance } from "@revolt/instance"; import { useModals } from "@revolt/modal"; import { useState } from "@revolt/state"; import { - Button, CategoryButton, + ColourPicker, ColouredText, Column, Form2, - Ripple, Text, typography, } from "@revolt/ui"; @@ -25,52 +24,6 @@ import { createMaterialColourVariables } from "@revolt/ui/themes"; import { useSettingsNavigation } from "../../Settings"; import { ChannelPermissionsEditor } from "../../channel/permissions/ChannelPermissionsEditor"; -const ROLE_COLOUR_PALETTE = [ - [ - "#fca5a5", - "#fdba74", - "#fcd34d", - "#86efac", - "#6ee7b7", - "#67e8f9", - "#93c5fd", - "#c4b5fd", - "#f0abfc", - "#f9a8d4", - "#cbd5e1", - ], - [ - "#ef4444", - "#f97316", - "#f59e0b", - "#22c55e", - "#10b981", - "#06b6d4", - "#3b82f6", - "#8b5cf6", - "#d946ef", - "#ec4899", - "#64748b", - ], - [ - "#991b1b", - "#9a3412", - "#92400e", - "#166534", - "#065f46", - "#155e75", - "#1e40af", - "#5b21b6", - "#86198f", - "#9d174d", - "#1e293b", - ], -] as const; - -const ROLE_COLOUR_VALUES: ReadonlySet = new Set( - ROLE_COLOUR_PALETTE.flat(), -); - function RoleColourPicker(props: { colour: string | null; roleName: string; @@ -78,7 +31,6 @@ function RoleColourPicker(props: { }) { const { t } = useLingui(); const state = useState(); - const [pickerRef, setPickerRef] = createSignal(); const colourPreviews = createMemo(() => { const theme = state.theme.activeTheme; @@ -101,90 +53,14 @@ function RoleColourPicker(props: { ]; }); - function isSelectedColour(colour: string) { - return props.colour?.toLowerCase() === colour; - } - - function isCustomColour() { - const colour = props.colour?.toLowerCase(); - return !!colour && !ROLE_COLOUR_VALUES.has(colour); - } - return ( - - - Role Colour - - - - - {(row) => ( - - {(colour) => ( - props.onChange(colour)} - > - - - )} - - )} - - - - - - props.onChange(event.currentTarget.value)} - style={{ - position: "absolute", - opacity: 0, - width: "0px", - height: "0px", - padding: 0, - border: "none", - }} - /> - - - + Role Colour} + colour={props.colour} + swatchLabel={(colour) => t`Set role colour to ${colour}`} + onChange={props.onChange} + /> @@ -390,27 +266,6 @@ export const Divider = styled("div", { }, }); -const NoColourIndicator = styled("span", { - base: { - width: "18px", - height: "18px", - flexShrink: 0, - borderRadius: "var(--borderRadius-full)", - border: "2px dashed var(--md-sys-color-on-surface-variant)", - marginRight: "var(--gap-sm)", - }, -}); - -const CustomColourIndicator = styled("span", { - base: { - width: "18px", - height: "18px", - flexShrink: 0, - borderRadius: "50%", - marginRight: "var(--gap-sm)", - }, -}); - const RoleColourControls = styled("div", { base: { width: "100%", @@ -421,14 +276,6 @@ const RoleColourControls = styled("div", { }, }); -const ColourSelector = styled(Column, { - base: { - width: "fit-content", - maxWidth: "100%", - flexShrink: 0, - }, -}); - const ColourPreview = styled("div", { base: { width: "220px", @@ -493,62 +340,3 @@ const PreviewBody = styled("span", { ...typography.raw({ class: "_messages" }), }, }); - -const ColourPalette = styled("div", { - base: { - width: "100%", - display: "grid", - gridTemplateColumns: "repeat(11, minmax(0, 36px))", - gap: "var(--gap-sm)", - }, -}); - -const ColourSwatch = styled("button", { - base: { - width: "100%", - aspectRatio: "1 / 1", - padding: 0, - border: 0, - borderRadius: "50%", - cursor: "pointer", - position: "relative", - overflow: "hidden", - transition: "border-radius 200ms cubic-bezier(0.2, 0, 0, 1)", - - _focusVisible: { - outline: "2px solid var(--md-sys-color-on-surface)", - outlineOffset: "2px", - }, - }, - variants: { - selected: { - true: { - borderRadius: "var(--borderRadius-md)", - }, - }, - }, -}); - -const ColourActions = styled("div", { - base: { - width: "100%", - display: "grid", - gridTemplateColumns: "repeat(2, minmax(0, 1fr))", - gap: "var(--gap-sm)", - position: "relative", - }, -}); - -const ColourActionContent = styled("span", { - base: { - display: "flex", - alignItems: "center", - justifyContent: "center", - gap: "var(--gap-sm)", - - "& svg": { - width: "20px", - height: "20px", - }, - }, -}); diff --git a/packages/client/components/app/menus/ServerContextMenu.tsx b/packages/client/components/app/menus/ServerContextMenu.tsx index feac345e2c..47c80404a9 100644 --- a/packages/client/components/app/menus/ServerContextMenu.tsx +++ b/packages/client/components/app/menus/ServerContextMenu.tsx @@ -1,3 +1,4 @@ +import MdFolder from "@material-design-icons/svg/outlined/folder.svg?component-solid"; import { For, Show } from "solid-js"; import { Trans } from "@lingui/solid/macro"; @@ -11,7 +12,9 @@ import { Column, Text, Time } from "@revolt/ui"; import MdAlternateEmail from "@material-design-icons/svg/outlined/alternate_email.svg?component-solid"; import MdBadge from "@material-design-icons/svg/outlined/badge.svg?component-solid"; +import MdCreateNewFolder from "@material-design-icons/svg/outlined/create_new_folder.svg?component-solid"; import MdFace from "@material-design-icons/svg/outlined/face.svg?component-solid"; +import MdFolderOff from "@material-design-icons/svg/outlined/folder_off.svg?component-solid"; import MdLogout from "@material-design-icons/svg/outlined/logout.svg?component-solid"; import MdMarkChatRead from "@material-design-icons/svg/outlined/mark_chat_read.svg?component-solid"; import MdNotificationsActive from "@material-design-icons/svg/outlined/notifications_active.svg?component-solid"; @@ -233,6 +236,47 @@ export function ServerContextMenu(props: { server: Server }) { + + state.ordering.createFolder("New Folder", [props.server.id]) + } + icon={MdFolder} + buttonContent={Add to folder} + > + + {(folder) => ( + + state.ordering.addToFolder(folder.id, props.server.id) + } + > + Unnamed folder}> + {folder.name} + + + )} + + + state.ordering.createFolder("New Folder", [props.server.id]) + } + > + New folder + + + + + state.ordering.removeFromFolder(props.server.id)} + > + Remove from folder + + + + + Notifications} diff --git a/packages/client/components/app/menus/ServerFolderContextMenu.tsx b/packages/client/components/app/menus/ServerFolderContextMenu.tsx new file mode 100644 index 0000000000..5bdca43a48 --- /dev/null +++ b/packages/client/components/app/menus/ServerFolderContextMenu.tsx @@ -0,0 +1,50 @@ +import { Trans } from "@lingui/solid/macro"; + +import { useModals } from "@revolt/modal"; +import { ServerFolder, useState } from "@revolt/state"; + +import MdDelete from "@material-design-icons/svg/outlined/delete.svg?component-solid"; +import MdDriveFileRenameOutline from "@material-design-icons/svg/outlined/drive_file_rename_outline.svg?component-solid"; + +import { + ContextMenu, + ContextMenuButton, + ContextMenuDivider, +} from "./ContextMenu"; + +/** + * Context menu for server folders + */ +export function ServerFolderContextMenu(props: { folder: ServerFolder }) { + const state = useState(); + const { openModal } = useModals(); + + /** + * Rename the folder + */ + function rename() { + openModal({ + type: "edit_server_folder", + folder: props.folder, + }); + } + + /** + * Remove the folder, keeping its servers + */ + function remove() { + state.ordering.deleteFolder(props.folder.id); + } + + return ( + + + Edit folder + + + + Delete folder + + + ); +} diff --git a/packages/client/components/app/menus/index.tsx b/packages/client/components/app/menus/index.tsx index 5a08df1936..12866efdce 100644 --- a/packages/client/components/app/menus/index.tsx +++ b/packages/client/components/app/menus/index.tsx @@ -2,5 +2,6 @@ export { CategoryContextMenu } from "./CategoryContextMenu"; export { ChannelContextMenu } from "./ChannelContextMenu"; export { MessageContextMenu } from "./MessageContextMenu"; export { ServerContextMenu } from "./ServerContextMenu"; +export { ServerFolderContextMenu } from "./ServerFolderContextMenu"; export { ServerSidebarContextMenu } from "./ServerSidebarContextMenu"; export { UserContextMenu } from "./UserContextMenu"; diff --git a/packages/client/components/modal/modals.tsx b/packages/client/components/modal/modals.tsx index 718d624d3d..345b9fcdb9 100644 --- a/packages/client/components/modal/modals.tsx +++ b/packages/client/components/modal/modals.tsx @@ -32,6 +32,7 @@ import { EditBotUsernameModal } from "./modals/EditBotUsername"; import { EditCategoryModal } from "./modals/EditCategory"; import { EditEmailModal } from "./modals/EditEmail"; import { EditPasswordModal } from "./modals/EditPassword"; +import { EditServerFolderModal } from "./modals/EditServerFolder"; import { EditUsernameModal } from "./modals/EditUsername"; import { EmojiPreviewModal } from "./modals/EmojiPreview"; import { Error2Modal } from "./modals/Error2"; @@ -188,6 +189,8 @@ export function RenderModal(props: ActiveModal & { onClose: () => void }) { return ; case "edit_category": return ; + case "edit_server_folder": + return ; case "remove_member": return ; case "pin_message": diff --git a/packages/client/components/modal/modals/EditServerFolder.tsx b/packages/client/components/modal/modals/EditServerFolder.tsx new file mode 100644 index 0000000000..a10e333474 --- /dev/null +++ b/packages/client/components/modal/modals/EditServerFolder.tsx @@ -0,0 +1,77 @@ +import { createSignal } from "solid-js"; + +import { Trans } from "@lingui/solid/macro"; + +import { t } from "@lingui/core/macro"; +import { useState } from "@revolt/state"; +import { ColourPicker, Column, Dialog, DialogProps, Form2 } from "@revolt/ui"; +import { createFormControl, createFormGroup } from "solid-forms"; +import { useModals } from ".."; +import { Modals } from "../types"; + +/** + * Modal to rename a server folder or change its colour + */ +export function EditServerFolderModal( + props: DialogProps & Modals & { type: "edit_server_folder" }, +) { + const state = useState(); + const { showError } = useModals(); + + /* eslint-disable solid/reactivity */ + const group = createFormGroup({ + text: createFormControl(props.folder.name), + }); + const [colour, setColour] = createSignal(props.folder.colour ?? null); + /* eslint-enable solid/reactivity */ + + async function onSubmit() { + try { + state.ordering.editFolder(props.folder.id, { + name: group.controls.text.value, + colour: colour() ?? undefined, + }); + + props.onClose(); + } catch (error) { + showError(error); + } + } + + const submit = Form2.useSubmitHandler(group, onSubmit); + + return ( + Edit folder} + actions={[ + { text: Cancel }, + { + text: Save, + onClick: () => { + onSubmit(); + return false; + }, + }, + ]} + isDisabled={!Form2.canSubmit(group)} + > +
+ + + Folder colour} + colour={colour()} + swatchLabel={(value) => t`Set folder colour to ${value}`} + onChange={setColour} + /> + +
+
+ ); +} diff --git a/packages/client/components/modal/types.ts b/packages/client/components/modal/types.ts index bdc07a095c..4577556c95 100644 --- a/packages/client/components/modal/types.ts +++ b/packages/client/components/modal/types.ts @@ -23,6 +23,7 @@ import { import type { SettingsConfigurations } from "@revolt/app"; import { CategoryData } from "@revolt/app/menus/CategoryContextMenu"; +import { ServerFolder } from "@revolt/state/stores/ServerFolders"; import { ScreenShareQualityName } from "@revolt/state/stores/Voice"; import type { ChangelogResponse } from "./modals/Changelog"; @@ -318,6 +319,10 @@ export type Modals = server: Server; category: CategoryData; } + | { + type: "edit_server_folder"; + folder: ServerFolder; + } | { type: "remove_member"; group: Channel; diff --git a/packages/client/components/state/index.tsx b/packages/client/components/state/index.tsx index c94c5dc793..83264c432e 100644 --- a/packages/client/components/state/index.tsx +++ b/packages/client/components/state/index.tsx @@ -28,6 +28,7 @@ import { Locale } from "./stores/Locale"; import { NotificationOptions } from "./stores/NotificationOptions"; import { Ordering } from "./stores/Ordering"; import { ReleaseNotes } from "./stores/ReleaseNotes"; +import { ServerFolders } from "./stores/ServerFolders"; import { Settings } from "./stores/Settings"; import { Sounds } from "./stores/Sounds"; import { Sync } from "./stores/Sync"; @@ -35,6 +36,8 @@ import { Theme } from "./stores/Theme"; import { Voice } from "./stores/Voice"; export { ALLOWED_IMAGE_TYPES } from "./stores/Draft"; +export type { ResolvedEntry } from "./stores/Ordering"; +export type { ServerFolder } from "./stores/ServerFolders"; export type { Sounds, TypeSounds } from "./stores/Sounds"; export { SyncWorker } from "./SyncWorker"; @@ -84,6 +87,7 @@ export class State { locale = new Locale(this); notifications = new NotificationOptions(this); ordering = new Ordering(this); + "server-folders" = new ServerFolders(this); "release-notes" = new ReleaseNotes(this); settings = new Settings(this); sync = new Sync(this); diff --git a/packages/client/components/state/stores/Ordering.ts b/packages/client/components/state/stores/Ordering.ts index cd9464c8b7..e8a34ddc7a 100644 --- a/packages/client/components/state/stores/Ordering.ts +++ b/packages/client/components/state/stores/Ordering.ts @@ -1,12 +1,24 @@ -import { Client } from "stoat.js"; +import { Client, Server } from "stoat.js"; import { State } from ".."; import { AbstractStore } from "."; +import { ServerFolder } from "./ServerFolders"; + +/** + * A resolved entry, ready to render + */ +export type ResolvedEntry = + | { type: "server"; id: string; server: Server } + | { type: "folder"; id: string; folder: ServerFolder; servers: Server[] }; export interface TypeOrdering { /** * Ordered list of server IDs + * + * Servers inside folders stay in this list, and a folder is drawn where the + * first of its servers sits. Kept as plain ids so that clients without + * folder support can read and write this key without losing anything. */ servers: string[]; } @@ -22,6 +34,12 @@ export class Ordering extends AbstractStore<"ordering", TypeOrdering> { constructor(state: State) { super(state, "ordering"); this.setServerOrder = this.setServerOrder.bind(this); + this.createFolder = this.createFolder.bind(this); + this.editFolder = this.editFolder.bind(this); + this.deleteFolder = this.deleteFolder.bind(this); + this.toggleFolder = this.toggleFolder.bind(this); + this.addToFolder = this.addToFolder.bind(this); + this.removeFromFolder = this.removeFromFolder.bind(this); } /** @@ -51,49 +69,199 @@ export class Ordering extends AbstractStore<"ordering", TypeOrdering> { /** * Validate the given data to see if it is compliant and return a compliant object + * + * Accepts the flat `string[]` written by clients without folder support, and + * drops any server appearing more than once so it cannot render twice. */ clean(input: Partial): TypeOrdering { - const ordering: TypeOrdering = this.default(); + const servers: string[] = []; + const seen = new Set(); if (Array.isArray(input.servers)) { - for (const serverId of input.servers) { - if (typeof serverId === "string") { - ordering.servers.push(serverId); + for (const entry of input.servers) { + // folders used to be written inline here, so keep hold of their + // members rather than dropping the servers along with the folder + const ids = + typeof entry === "string" + ? [entry] + : Array.isArray((entry as ServerFolder)?.servers) + ? (entry as ServerFolder).servers + : []; + + for (const id of ids) { + if (typeof id === "string" && id && !seen.has(id)) { + seen.add(id); + servers.push(id); + } } } } - return ordering; + return { servers }; } /** - * All known servers with ordering applied + * All known servers with ordering applied, folder contents inlined + * + * Used where a flat list is wanted, such as the emoji picker and keyboard + * navigation, so it follows what is actually on screen. * @returns List of Server objects */ orderedServers(client: Client) { + return this.orderedEntries(client).flatMap((entry) => + entry.type === "folder" ? entry.servers : [entry.server], + ); + } + + /** + * All known servers grouped into their folders, ready to render + * + * A folder is drawn where the first of its servers sits in the ordering. + * Servers which are not yet ordered are appended at the end, matching the + * behaviour of {@link orderedServers}. + * @returns List of resolved entries + */ + orderedEntries(client: Client): ResolvedEntry[] { const known = new Set(client?.servers.keys() ?? []); - const ordered = [...this.get().servers]; + const folderOf = new Map(); - const out = []; - for (const id of ordered) { - if (known.delete(id)) { - out.push(client!.servers.get(id)!); + for (const folder of this.folders()) { + for (const serverId of folder.servers) { + if (!folderOf.has(serverId)) { + folderOf.set(serverId, folder); + } } } - for (const id of known) { - out.push(client!.servers.get(id)!); - } + const out: ResolvedEntry[] = []; + const drawn = new Set(); + + const take = (id: string) => { + const folder = folderOf.get(id); + + if (folder) { + if (drawn.has(folder.id)) return; + drawn.add(folder.id); + + const servers = folder.servers + .filter((serverId) => known.delete(serverId)) + .map((serverId) => client!.servers.get(serverId)!); + + // a folder whose servers have all gone is not worth rendering + if (servers.length) { + out.push({ type: "folder", id: folder.id, folder, servers }); + } + + return; + } + + if (known.delete(id)) { + out.push({ type: "server", id, server: client!.servers.get(id)! }); + } + }; + + this.get().servers.forEach(take); + [...known].forEach(take); return out; } /** - * Set server ordering + * Set the order of top-level entries + * + * Ids may refer to either servers or folders; a folder is written out as the + * servers it holds, so what gets stored stays a plain list of server ids. * @param ids List of IDs */ setServerOrder(ids: string[]) { - this.set("servers", ids); + const folders = new Map( + this.folders().map((folder) => [folder.id, folder] as const), + ); + + this.set( + "servers", + ids.flatMap((id) => folders.get(id)?.servers ?? [id]), + ); + } + + /** + * Create a new folder containing the given servers + * @param name Folder name + * @param serverIds Servers to place in the folder + * @returns The new folder's id + */ + createFolder(name: string, serverIds: string[] = []) { + return this.state["server-folders"].create(name, serverIds); + } + + /** + * Change a folder's name or colour + * @param id Folder id + * @param changes Fields to change + */ + editFolder( + id: string, + changes: Partial>, + ) { + this.state["server-folders"].edit(id, changes); + } + + /** + * Remove a folder, keeping its servers in place + * @param id Folder id + */ + deleteFolder(id: string) { + this.state["server-folders"].remove(id); + } + + /** + * Collapse or expand a folder + * @param id Folder id + */ + toggleFolder(id: string) { + this.state["server-folders"].toggle(id); + } + + /** + * Move a server into a folder, removing it from wherever it was + * @param folderId Folder id + * @param serverId Server to move + */ + addToFolder(folderId: string, serverId: string) { + this.state["server-folders"].addServer(folderId, serverId); + } + + /** + * Put a server at a given position within a folder + * @param folderId Folder to place it in + * @param serverId Server to move + * @param before Server it should sit in front of + */ + placeInFolder(folderId: string, serverId: string, before?: string) { + this.state["server-folders"].place(folderId, serverId, before); + } + + /** + * Move a server out of its folder, back to the top level + * @param serverId Server to move + */ + removeFromFolder(serverId: string) { + this.state["server-folders"].removeServer(serverId); + } + + /** + * Find the folder a server currently belongs to + * @param serverId Server id + */ + folderOf(serverId: string): ServerFolder | undefined { + return this.state["server-folders"].of(serverId); + } + + /** + * All folders which currently exist + */ + folders(): ServerFolder[] { + return this.state["server-folders"].list(); } /** diff --git a/packages/client/components/state/stores/ServerFolders.ts b/packages/client/components/state/stores/ServerFolders.ts new file mode 100644 index 0000000000..f1453af44a --- /dev/null +++ b/packages/client/components/state/stores/ServerFolders.ts @@ -0,0 +1,303 @@ +import { ulid } from "ulid"; + +import { State } from ".."; + +import { AbstractStore } from "."; + +/** + * A group of servers shown as a single entry in the server list + */ +export interface ServerFolder { + /** + * Unique id, prefixed to never collide with a server id + */ + id: string; + + /** + * Folder name + */ + name: string; + + /** + * Colour used for the folder, any valid CSS colour + */ + colour?: string; + + /** + * Whether the folder is currently collapsed + */ + collapsed?: boolean; + + /** + * Ordered list of server IDs within this folder + */ + servers: string[]; +} + +export interface TypeServerFolders { + /** + * Every folder which exists + * + * These are not in any particular order; where a folder appears in the + * server list is decided by the ordering store, which knows nothing about + * folders beyond the ids of the servers inside them. + */ + folders: ServerFolder[]; +} + +/** + * Prefix applied to folder ids so they can never collide with a server id + */ +const FOLDER_PREFIX = "folder-"; + +/** + * Folders are kept in their own synced key rather than inside `ordering`. + * + * Clients without folder support read `ordering.servers` as a plain list of + * server ids and write it back the same way, so anything else stored in there + * is dropped the first time one of them reorders a server. Keeping folders + * under a key those clients never ask for leaves them untouched. + */ +export class ServerFolders extends AbstractStore< + "server-folders", + TypeServerFolders +> { + /** + * Construct store + * @param state State + */ + constructor(state: State) { + super(state, "server-folders"); + this.create = this.create.bind(this); + this.edit = this.edit.bind(this); + this.remove = this.remove.bind(this); + this.toggle = this.toggle.bind(this); + this.addServer = this.addServer.bind(this); + this.removeServer = this.removeServer.bind(this); + } + + /** + * Get this store's value + * + * Reexported to allow equals checking for syncing + */ + get() { + return super.get(); + } + + /** + * Hydrate external context + */ + hydrate(): void { + /** nothing needs to be done */ + } + + /** + * Generate default values + */ + default(): TypeServerFolders { + return { + folders: [], + }; + } + + /** + * Validate the given data to see if it is compliant and return a compliant object + * + * A server may only belong to one folder, so any later claim on it is + * dropped rather than rendering the server twice. + */ + clean(input: Partial): TypeServerFolders { + const folders: ServerFolder[] = []; + const seenFolders = new Set(); + const seenServers = new Set(); + + if (Array.isArray(input.folders)) { + for (const entry of input.folders) { + if ( + typeof entry !== "object" || + entry === null || + typeof entry.id !== "string" || + !entry.id || + seenFolders.has(entry.id) + ) { + continue; + } + + seenFolders.add(entry.id); + + const servers: string[] = []; + if (Array.isArray(entry.servers)) { + for (const serverId of entry.servers) { + if (typeof serverId === "string" && !seenServers.has(serverId)) { + seenServers.add(serverId); + servers.push(serverId); + } + } + } + + const folder: ServerFolder = { + id: entry.id, + name: typeof entry.name === "string" ? entry.name : "", + servers, + }; + + if (typeof entry.colour === "string") { + folder.colour = entry.colour; + } + + if (entry.collapsed === true) { + folder.collapsed = true; + } + + folders.push(folder); + } + } + + return { folders }; + } + + /** + * Every folder which currently exists + */ + list(): ServerFolder[] { + return this.get().folders; + } + + /** + * Find the folder a server currently belongs to + * @param serverId Server id + */ + of(serverId: string): ServerFolder | undefined { + return this.list().find((folder) => folder.servers.includes(serverId)); + } + + /** + * Create a new folder containing the given servers + * @param name Folder name + * @param serverIds Servers to place in the folder + * @returns The new folder's id + */ + create(name: string, serverIds: string[] = []) { + const id = `${FOLDER_PREFIX}${ulid()}`; + const claimed = new Set(serverIds); + + this.#write([ + ...this.list().map((folder) => ({ + ...folder, + servers: folder.servers.filter((sid) => !claimed.has(sid)), + })), + { id, name, servers: [...serverIds] }, + ]); + + return id; + } + + /** + * Change a folder's name or colour + * @param id Folder id + * @param changes Fields to change + */ + edit(id: string, changes: Partial>) { + this.#write( + this.list().map((folder) => + folder.id === id ? { ...folder, ...changes } : folder, + ), + ); + } + + /** + * Remove a folder, leaving its servers where they are in the list + * @param id Folder id + */ + remove(id: string) { + this.#write(this.list().filter((folder) => folder.id !== id)); + } + + /** + * Collapse or expand a folder + * @param id Folder id + */ + toggle(id: string) { + this.#write( + this.list().map((folder) => + folder.id === id ? { ...folder, collapsed: !folder.collapsed } : folder, + ), + ); + } + + /** + * Move a server into a folder, taking it out of whichever folder held it + * @param folderId Folder id + * @param serverId Server to move + */ + addServer(folderId: string, serverId: string) { + this.#write( + this.list().map((folder) => { + if (folder.id === folderId) { + return folder.servers.includes(serverId) + ? folder + : { ...folder, servers: [...folder.servers, serverId] }; + } + + return { + ...folder, + servers: folder.servers.filter((sid) => sid !== serverId), + }; + }), + ); + } + + /** + * Put a server at a given position in a folder, taking it out of wherever + * it was; used for dropping into a folder and for reordering within one + * @param folderId Folder to place it in + * @param serverId Server to move + * @param before Server it should sit in front of, or the end of the folder + */ + place(folderId: string, serverId: string, before?: string) { + this.#write( + this.list().map((folder) => { + const servers = folder.servers.filter((sid) => sid !== serverId); + + if (folder.id !== folderId) return { ...folder, servers }; + + const at = before ? servers.indexOf(before) : -1; + const index = at === -1 ? servers.length : at; + + return { + ...folder, + servers: [ + ...servers.slice(0, index), + serverId, + ...servers.slice(index), + ], + }; + }), + ); + } + + /** + * Move a server out of its folder + * @param serverId Server to move + */ + removeServer(serverId: string) { + this.#write( + this.list().map((folder) => ({ + ...folder, + servers: folder.servers.filter((sid) => sid !== serverId), + })), + ); + } + + /** + * Store the given folders, forgetting any which have been left empty + * @param folders Folders + */ + #write(folders: ServerFolder[]) { + this.set( + "folders", + folders.filter((folder) => folder.servers.length), + ); + } +} diff --git a/packages/client/components/state/stores/Sync.ts b/packages/client/components/state/stores/Sync.ts index 110e25ee7b..bac10e458e 100644 --- a/packages/client/components/state/stores/Sync.ts +++ b/packages/client/components/state/stores/Sync.ts @@ -11,12 +11,17 @@ import { TypeNotificationOptions } from "./NotificationOptions"; import { TypeOrdering } from "./Ordering"; import { TypeReleaseNotes } from "./ReleaseNotes"; -type SynchronisedStores = "ordering" | "notifications" | "release-notes"; +type SynchronisedStores = + | "ordering" + | "notifications" + | "release-notes" + | "server-folders"; const STORE_KEYS: SynchronisedStores[] = [ "ordering", "notifications", "release-notes", + "server-folders", ]; export interface TypeSynchronisation { @@ -61,6 +66,7 @@ export class Sync extends AbstractStore<"sync", TypeSynchronisation> { ordering: 0, notifications: 0, "release-notes": 0, + "server-folders": 0, }, }; } diff --git a/packages/client/components/state/stores/index.ts b/packages/client/components/state/stores/index.ts index c17d19ebe7..ebd9e4583f 100644 --- a/packages/client/components/state/stores/index.ts +++ b/packages/client/components/state/stores/index.ts @@ -12,6 +12,7 @@ import { TypeLocale } from "./Locale"; import { TypeNotificationOptions } from "./NotificationOptions"; import { TypeOrdering } from "./Ordering"; import { TypeReleaseNotes } from "./ReleaseNotes"; +import { TypeServerFolders } from "./ServerFolders"; import { TypeSettings } from "./Settings"; import { TypeSounds } from "./Sounds"; import { TypeSynchronisation } from "./Sync"; @@ -29,6 +30,7 @@ export type Store = { notifications: TypeNotificationOptions; ordering: TypeOrdering; "release-notes": TypeReleaseNotes; + "server-folders": TypeServerFolders; settings: TypeSettings; sounds: TypeSounds; sync: TypeSynchronisation; diff --git a/packages/client/components/ui/components/design/ColourPicker.tsx b/packages/client/components/ui/components/design/ColourPicker.tsx new file mode 100644 index 0000000000..bf06242eb3 --- /dev/null +++ b/packages/client/components/ui/components/design/ColourPicker.tsx @@ -0,0 +1,258 @@ +import { For, JSX, Show } from "solid-js"; + +import { Trans, useLingui } from "@lingui/solid/macro"; +import { styled } from "styled-system/jsx"; + +import { Column } from "../layout"; +import { Symbol } from "../utils/Symbol"; + +import { Button } from "./Button"; +import { Ripple } from "./Ripple"; +import { Text } from "./Text"; + +/** + * Colours offered before anyone reaches for the custom picker + */ +export const COLOUR_PALETTE = [ + [ + "#fca5a5", + "#fdba74", + "#fcd34d", + "#86efac", + "#6ee7b7", + "#67e8f9", + "#93c5fd", + "#c4b5fd", + "#f0abfc", + "#f9a8d4", + "#cbd5e1", + ], + [ + "#ef4444", + "#f97316", + "#eab308", + "#22c55e", + "#10b981", + "#06b6d4", + "#3b82f6", + "#8b5cf6", + "#d946ef", + "#ec4899", + "#64748b", + ], + [ + "#991b1b", + "#9a3412", + "#854d0e", + "#166534", + "#065f46", + "#155e75", + "#1e40af", + "#5b21b6", + "#86198f", + "#9d174d", + "#334155", + ], +] as const; + +const PALETTE_VALUES: ReadonlySet = new Set(COLOUR_PALETTE.flat()); + +/** + * Pick a colour from a palette, from the system picker, or not at all + */ +export function ColourPicker(props: { + /** + * What is being coloured + */ + label: JSX.Element; + + /** + * Currently chosen colour, or null for whatever the default is + */ + colour: string | null; + + /** + * How to describe a swatch to a screen reader + */ + swatchLabel?: (colour: string) => string; + + onChange: (colour: string | null) => void; +}) { + const { t } = useLingui(); + + function isSelected(colour: string) { + return props.colour?.toLowerCase() === colour; + } + + function isCustom() { + const colour = props.colour?.toLowerCase(); + return !!colour && !PALETTE_VALUES.has(colour); + } + + return ( + + {props.label} + + + + {(row) => ( + + {(colour) => ( + props.onChange(colour)} + > + + + )} + + )} + + + + +
+ + + {/* the input sits over the button so that opening the browser's own + picker is a real click on it, rather than something we ask for */} + props.onChange(event.currentTarget.value)} + style={{ + position: "absolute", + inset: 0, + width: "100%", + height: "100%", + padding: 0, + border: "none", + opacity: 0, + cursor: "pointer", + }} + /> +
+ +
+
+ ); +} + +const ColourSelector = styled(Column, { + base: { + width: "fit-content", + maxWidth: "100%", + flexShrink: 0, + }, +}); + +const ColourPalette = styled("div", { + base: { + width: "100%", + display: "grid", + gridTemplateColumns: "repeat(11, minmax(0, 36px))", + gap: "var(--gap-sm)", + }, +}); + +const ColourSwatch = styled("button", { + base: { + width: "100%", + aspectRatio: "1 / 1", + padding: 0, + border: 0, + borderRadius: "50%", + cursor: "pointer", + position: "relative", + overflow: "hidden", + transition: "border-radius 200ms cubic-bezier(0.2, 0, 0, 1)", + + _focusVisible: { + outline: "2px solid var(--md-sys-color-on-surface)", + outlineOffset: "2px", + }, + }, + variants: { + selected: { + true: { + borderRadius: "var(--borderRadius-md)", + }, + }, + }, +}); + +const ColourActions = styled("div", { + base: { + width: "100%", + display: "grid", + gridTemplateColumns: "repeat(2, minmax(0, 1fr))", + gap: "var(--gap-sm)", + position: "relative", + }, +}); + +const ColourActionContent = styled("span", { + base: { + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: "var(--gap-sm)", + + "& svg": { + width: "20px", + height: "20px", + }, + }, +}); + +const ColourIndicator = styled("span", { + base: { + width: "18px", + height: "18px", + flexShrink: 0, + borderRadius: "50%", + marginRight: "var(--gap-sm)", + }, +}); + +const NoColourIndicator = styled(ColourIndicator, { + base: { + border: "2px solid var(--md-sys-color-outline)", + }, +}); diff --git a/packages/client/components/ui/components/design/index.ts b/packages/client/components/ui/components/design/index.ts index 0e3b2a5dd3..c43cb2ca0c 100644 --- a/packages/client/components/ui/components/design/index.ts +++ b/packages/client/components/ui/components/design/index.ts @@ -30,6 +30,7 @@ export { Badge } from "./Badge"; export { Button } from "./Button"; export { type CategorySelectOption, CategoryButton } from "./CategoryButton"; export { Checkbox } from "./Checkbox"; +export { COLOUR_PALETTE, ColourPicker } from "./ColourPicker"; export { DataTable } from "./DataTable"; export { type DialogProps, Dialog } from "./Dialog"; export { Fab } from "./Fab"; diff --git a/packages/client/src/interface/Sidebar.tsx b/packages/client/src/interface/Sidebar.tsx index 6bd287351c..dd8897167c 100644 --- a/packages/client/src/interface/Sidebar.tsx +++ b/packages/client/src/interface/Sidebar.tsx @@ -51,6 +51,7 @@ export const Sidebar = (props: { void; @@ -117,6 +133,117 @@ export const ServerList = (props: Props) => { // Ref for floating menu const [menuButton, setMenuButton] = createSignal(); + let rail!: HTMLDivElement; + + /** + * Fold one entry into another, making a folder out of two servers or + * dropping a server into the folder it was let go over + * @param target Entry dropped onto + * @param incoming Server being dragged + */ + function fold(target: string, incoming: string) { + const entry = props.orderedEntries.find((item) => item.id === target); + if (!entry) return; + + if (entry.type === "folder") { + state.ordering.addToFolder(entry.folder.id, incoming); + } else { + state.ordering.createFolder("New Folder", [target, incoming]); + } + } + + /** + * Apply a drop which reorders rather than folds + * @param moved Entry being dragged + * @param before Entry it should sit in front of + * @param parent Folder it should land inside, if any + */ + function place(moved: string, before?: string, parent?: string) { + if (parent) { + state.ordering.placeInFolder(parent, moved, before); + return; + } + + // out of a folder and back into the list proper + if (state.ordering.folderOf(moved)) { + state.ordering.removeFromFolder(moved); + } + + const ids = props.orderedEntries + .map((entry) => entry.id) + .filter((id) => id !== moved); + + const at = before ? ids.indexOf(before) : -1; + const index = at === -1 ? ids.length : at; + + props.setServerOrder([...ids.slice(0, index), moved, ...ids.slice(index)]); + } + + const drag = createRailDrag({ + entries: (): RailEntry[] => + props.orderedEntries.flatMap((entry) => + entry.type === "folder" + ? [ + { id: entry.id, kind: "folder" as const }, + // members are only draggable while you can see them + ...(entry.folder.collapsed + ? [] + : entry.servers.map((server) => ({ + id: server.id, + kind: "server" as const, + parent: entry.id, + }))), + ] + : [{ id: entry.id, kind: "server" as const }], + ), + container: () => rail, + label: (id) => { + for (const entry of props.orderedEntries) { + if (entry.id === id) + return entry.type === "folder" + ? entry.folder.name || "Folder" + : entry.server.name; + + if (entry.type !== "folder") continue; + + const member = entry.servers.find((server) => server.id === id); + if (member) return member.name; + } + }, + //TODO - No server ordering on mobile due to usability issue + //Consider adding a way to enable reordering in user settings + disabled: () => isMobile, + onFold: fold, + onMove: (moved, before, parent) => place(moved, before, parent), + }); + + /** + * Entry currently being dragged, if any + */ + const heldEntry = () => { + if (!drag.pointer()) return undefined; + + const id = drag.dragging(); + const top = props.orderedEntries.find((entry) => entry.id === id); + if (top) return top; + + // a server being dragged out of an expanded folder + for (const entry of props.orderedEntries) { + if (entry.type !== "folder") continue; + + const server = entry.servers.find((member) => member.id === id); + if (server) return { type: "server", id, server } as ResolvedEntry; + } + }; + + /** + * Whether the drop would land after every entry + */ + const insertionAtEnd = () => { + const intent = drag.intent(); + return intent?.type === "move" && intent.before === undefined; + }; + return (
@@ -210,100 +337,75 @@ export const ServerList = (props: Props) => { - - {(entry) => ( - ( - - - {entry.item.name} - {" "} - - - Muted} - > - - Muted until{" "} - - - - - - )} - aria={entry.item.name} +
+ + {(entry) => ( + + + {(item) => ( + + )} + + + {(item) => ( + + )} + + + )} + + +
+ +
+
+ {drag.status()} +
+ + {(held) => ( +
- - + + + {(item) => ( + + )} + + + {(item) => ( + + } + /> + )} + + +
)} - +
{ /** * Server list container */ +/** + * A single server in the list + */ +function ServerEntry(props: { + server: Server; + selectedServer: Accessor; + menuGenerator: (target: Server | Channel) => JSX.Directives["floating"]; + drag: RailDrag; +}) { + const state = useState(); + + return ( + ( + + + {props.server.name} + {" "} + + + Muted} + > + + Muted until{" "} + + + + + + )} + aria={props.server.name} + > +
props.drag.register(props.server.id, el)} + onPointerDown={(e) => props.drag.press(props.server.id, e)} + onKeyDown={(e) => props.drag.keys(props.server.id, e)} + class={entryContainer({ + indicator: + props.selectedServer() === props.server.id + ? "selected" + : props.server.unread && + !state.notifications.isMuted(props.server) + ? "alert" + : undefined, + })} + classList={{ + [railFolding]: isFoldTarget(props.drag, props.server.id), + [railHeld]: + props.drag.dragging() === props.server.id || + props.drag.carrying() === props.server.id, + }} + use:floating={props.menuGenerator(props.server)} + > + + + + ); +} + +/** + * A folder of servers, collapsible in place + */ +function FolderEntry(props: { + entry: Extract; + selectedServer: Accessor; + menuGenerator: (target: Server | Channel) => JSX.Directives["floating"]; + drag: RailDrag; +}) { + const state = useState(); + + const collapsed = () => props.entry.folder.collapsed ?? false; + + const mentions = () => + props.entry.servers.reduce( + (count, server) => count + server.mentions.length, + 0, + ); + + const unread = () => + props.entry.servers.some( + (server) => server.unread && !state.notifications.isMuted(server), + ); + + const holdsSelected = () => + props.entry.servers.some((server) => server.id === props.selectedServer()); + + return ( + + +
props.drag.register(props.entry.id, el)} + onPointerDown={(e) => props.drag.press(props.entry.id, e)} + onKeyDown={(e) => props.drag.keys(props.entry.id, e)} + class={entryContainer({ + indicator: + collapsed() && holdsSelected() + ? "selected" + : collapsed() && unread() + ? "alert" + : undefined, + })} + classList={{ + [railFolding]: isFoldTarget(props.drag, props.entry.id), + [railHeld]: + props.drag.dragging() === props.entry.id || + props.drag.carrying() === props.entry.id, + }} + use:floating={{ + contextMenu: () => ( + + ), + }} + style={{ color: props.entry.folder.colour ?? undefined }} + > + + + + + + {(server) => ( + + )} + + + + ); +} + +type RailDrag = ReturnType; + +/** + * Holds the ring which shows a collapsed folder's colour + */ +const FolderIcon = styled("div", { + base: { + display: "grid", + placeItems: "center", + // the padding is what holds the ring off the icon itself + padding: "2px", + borderRadius: "var(--borderRadius-circle)", + }, +}); + +/** + * Announcements are for screen readers, not for looking at + */ +const srOnly = css({ + position: "absolute", + width: "1px", + height: "1px", + overflow: "hidden", + clip: "rect(0 0 0 0)", + whiteSpace: "nowrap", +}); + +/** + * Whether dropping now would fold into this entry + * @param drag Rail drag + * @param id Entry id + */ +function isFoldTarget(drag: RailDrag, id: string) { + const intent = drag.intent(); + return intent?.type === "fold" && intent.target === id; +} + +/** + * Whether the drop would land immediately above this entry + * @param drag Rail drag + * @param id Entry id + */ +function isInsertionBefore(drag: RailDrag, id: string) { + const intent = drag.intent(); + return intent?.type === "move" && intent.before === id; +} + +/** + * Ring drawn around the entry a drop would fold into + */ +const railFolding = css({ + "&:after": { + content: "' '", + position: "absolute", + top: "3px", + left: "3px", + width: "50px", + height: "50px", + borderRadius: "var(--borderRadius-circle)", + outline: "2px solid var(--md-sys-color-primary)", + }, +}); + +/** + * The entry being dragged stays in place, faded, so the list never moves + */ +const railHeld = css({ + opacity: 0.35, +}); + +/** + * Line showing where a reorder would drop the entry + */ +const railInsertion = css({ + position: "absolute", + top: "-2px", + left: "6px", + right: "6px", + height: "4px", + borderRadius: "2px", + background: "var(--md-sys-color-primary)", +}); + +/** + * The dragged entry, following the cursor + */ +const railGhost = css({ + position: "fixed", + pointerEvents: "none", + opacity: 0.85, + zIndex: 1000, +}); + +/** + * The first few servers in a folder, shown on the folder itself while it is + * collapsed so you can still tell what is inside + */ +function FolderPreview(props: { servers: Server[] }) { + return ( + + + {(server) => ( + + )} + + + ); +} + +/** + * Lays the preview icons out two to a row, centred for folders holding fewer + * than four servers + */ +const FolderPreviewBase = styled("div", { + base: { + width: "100%", + height: "100%", + + display: "flex", + flexWrap: "wrap", + gap: "2px", + alignContent: "center", + justifyContent: "center", + }, +}); + const ServerListBase = styled("div", { base: { display: "flex", @@ -402,6 +857,30 @@ const entryContainer = cva({ }, }); +/** + * Group a folder together with its servers while expanded + */ +const FolderGroup = styled("div", { + base: { + display: "flex", + flexDirection: "column", + alignItems: "center", + flexShrink: 0, + }, + variants: { + expanded: { + true: { + paddingBlock: "6px", + borderRadius: "var(--borderRadius-lg)", + background: + "color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent)", + }, + false: {}, + }, + }, + defaultVariants: { expanded: false }, +}); + /** * Divider line between two lists */ diff --git a/packages/client/src/interface/navigation/servers/railDrag.ts b/packages/client/src/interface/navigation/servers/railDrag.ts new file mode 100644 index 0000000000..48adf62949 --- /dev/null +++ b/packages/client/src/interface/navigation/servers/railDrag.ts @@ -0,0 +1,428 @@ +import { createSignal, onCleanup } from "solid-js"; + +import { useLingui } from "@lingui/solid/macro"; + +/** + * How far the pointer must travel before a press turns into a drag, so that + * clicking a server still navigates to it + */ +const DRAG_THRESHOLD_PX = 5; + +/** + * Portion of an entry, measured from its middle, which folds rather than + * reorders when you drop on it + */ +const FOLD_BAND = 0.5; + +export type RailEntry = { + id: string; + + /** + * Folders can be reordered but not folded into one another + */ + kind: "server" | "folder"; + + /** + * Folder this entry sits inside, when it is a member of an expanded one + */ + parent?: string; +}; + +/** + * How far to the side of the rail the pointer has to go before a drop is + * treated as "somewhere else" and abandoned + */ +const CANCEL_MARGIN_PX = 80; + +export type RailIntent = + | { type: "fold"; target: string } + | { + type: "move"; + + /** + * Entry the dragged one would land in front of, or the end of the list + */ + before: string | undefined; + + /** + * Folder it would land inside, if any + */ + parent: string | undefined; + }; + +/** + * Drag handling for the server rail + * + * The rail deliberately does not rearrange itself while you drag: entries stay + * where they are and an indicator shows where the drop will land. That is what + * makes dropping *onto* an entry possible at all, since an entry which steps + * out of the way as you approach can never be a target. + */ +export function createRailDrag(options: { + entries: () => RailEntry[]; + container: () => HTMLElement | undefined; + disabled?: () => boolean; + /** + * Name to read out when announcing what is being moved + */ + label?: (id: string) => string | undefined; + onFold: (target: string, incoming: string) => void; + onMove: ( + moved: string, + before: string | undefined, + parent: string | undefined, + ) => void; +}) { + const { t } = useLingui(); + + const elements = new Map(); + + const [dragging, setDragging] = createSignal(); + const [carrying, setCarrying] = createSignal(); + const [status, setStatus] = createSignal(""); + const [intent, setIntent] = createSignal(); + const [pointer, setPointer] = createSignal<{ x: number; y: number }>(); + + let armed: { id: string; x: number; y: number } | undefined; + + /** + * Work out what dropping at the given position would do + * + * Boxes are measured fresh each time so that scrolling the rail mid-drag + * doesn't leave us aiming at where things used to be. + * @param y Pointer position + * @param held Entry being dragged + */ + function intentAt( + x: number, + y: number, + held: string, + ): RailIntent | undefined { + const rail = options.container()?.getBoundingClientRect(); + if ( + rail && + (x < rail.left - CANCEL_MARGIN_PX || x > rail.right + CANCEL_MARGIN_PX) + ) { + return undefined; + } + + const source = options.entries().find((entry) => entry.id === held); + + const boxes = options + .entries() + .map((entry) => ({ entry, el: elements.get(entry.id) })) + .filter((row): row is { entry: RailEntry; el: HTMLElement } => !!row.el) + .map((row) => ({ ...row, rect: row.el.getBoundingClientRect() })); + + if (!boxes.length) + return { type: "move", before: undefined, parent: undefined }; + + // a folder can be moved around the list but never into anything + const canFold = source?.kind === "server"; + const canNest = source?.kind === "server"; + + /** + * Work out what landing in front of the given position means + * @param index Position within the visible list + */ + const insertAt = (index: number): RailIntent => { + let neighbour = boxes[index]; + + // a folder dropped among another folder's servers belongs after them + while (neighbour && !canNest && neighbour.entry.parent) { + neighbour = boxes[boxes.indexOf(neighbour) + 1]; + } + + return { + type: "move", + before: neighbour?.entry.id, + parent: neighbour?.entry.parent, + }; + }; + + for (const [index, { entry, rect }] of boxes.entries()) { + if (y < rect.top || y > rect.bottom) continue; + + const offset = (y - rect.top) / rect.height; + + // the middle of an entry folds, the edges insert either side of it; + // servers already inside a folder are only ever reordered + if ( + entry.id !== held && + canFold && + !entry.parent && + Math.abs(offset - 0.5) < FOLD_BAND / 2 + ) { + return { type: "fold", target: entry.id }; + } + + return insertAt(offset >= 0.5 ? index + 1 : index); + } + + // above the first entry, or below the last + return y < boxes[0].rect.top + ? insertAt(0) + : { type: "move", before: undefined, parent: undefined }; + } + + /** + * Finish the drag, applying whatever the indicator was promising + */ + function drop() { + const held = dragging(); + const landed = intent(); + + reset(); + + if (!held || !landed) return; + + if (landed.type === "fold") { + if (landed.target !== held) options.onFold(landed.target, held); + return; + } + + const source = options.entries().find((entry) => entry.id === held); + + // dropping something back exactly where it came from isn't a move + if ( + landed.parent === source?.parent && + (landed.before === held || sitsBefore(held, landed.before)) + ) { + return; + } + + options.onMove(held, landed.before, landed.parent); + } + + /** + * Whether the entry already sits directly in front of the given one + * @param id Entry + * @param before Entry it would land in front of + */ + function sitsBefore(id: string, before: string | undefined) { + const ids = options.entries().map((entry) => entry.id); + const at = ids.indexOf(id); + + return before === undefined + ? at === ids.length - 1 + : ids[at + 1] === before; + } + + /** + * Entries which sit alongside the given one, ie. the list it can move within + * @param parent Folder the entries belong to, if any + */ + function siblings(parent: string | undefined) { + return options.entries().filter((entry) => entry.parent === parent); + } + + /** + * Announce something to anyone listening + * @param message Message + */ + function announce(message: string) { + setStatus(message); + } + + /** + * Describe an entry for an announcement + * @param id Entry id + */ + function name(id: string) { + return options.label?.(id) ?? t`Server`; + } + + /** + * Put focus back on an entry once the list has redrawn around it + * @param id Entry id + */ + function refocus(id: string) { + requestAnimationFrame(() => { + const el = elements.get(id); + const focusable = el?.querySelector("a, button") ?? el; + focusable?.focus(); + }); + } + + /** + * Move an entry one place within the list it belongs to + * @param id Entry id + * @param direction Whether to move it up or down + */ + function shift(id: string, direction: -1 | 1) { + const parent = options.entries().find((entry) => entry.id === id)?.parent; + const list = siblings(parent); + const at = list.findIndex((entry) => entry.id === id); + + const to = at + direction; + if (at === -1 || to < 0 || to >= list.length) { + announce( + direction < 0 + ? t`${name(id)} is already at the top` + : t`${name(id)} is already at the bottom`, + ); + return; + } + + // moving down means landing in front of whatever follows the entry we + // are stepping over, which may be the end of the list + const before = direction < 0 ? list[to].id : list[to + 1]?.id; + + options.onMove(id, before, parent); + announce(t`${name(id)} moved to position ${to + 1} of ${list.length}`); + refocus(id); + } + + function reset() { + armed = undefined; + setDragging(undefined); + setIntent(undefined); + setPointer(undefined); + } + + function onPointerMove(e: PointerEvent) { + if (armed && !dragging()) { + if ( + Math.hypot(e.clientX - armed.x, e.clientY - armed.y) < DRAG_THRESHOLD_PX + ) + return; + + setDragging(armed.id); + } + + const held = dragging(); + if (!held) return; + + e.preventDefault(); + setPointer({ x: e.clientX, y: e.clientY }); + setIntent(intentAt(e.clientX, e.clientY, held)); + } + + function onPointerUp() { + if (dragging()) { + // the icon is a link, and letting go over it would otherwise navigate + window.addEventListener("click", swallowClick, { capture: true }); + drop(); + } else { + reset(); + } + } + + /** + * Eat the click which follows the pointer release that ended a drag + * @param e Click event + */ + function swallowClick(e: MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + window.removeEventListener("click", swallowClick, { capture: true }); + } + + function onKeyDown(e: KeyboardEvent) { + if (e.key === "Escape" && dragging()) { + e.preventDefault(); + reset(); + } + } + + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", onPointerUp); + window.addEventListener("pointercancel", onPointerUp); + window.addEventListener("keydown", onKeyDown); + + onCleanup(() => { + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + window.removeEventListener("pointercancel", onPointerUp); + window.removeEventListener("keydown", onKeyDown); + }); + + return { + /** + * Entry currently being dragged, if any + */ + dragging, + + /** + * Entry picked up with the keyboard, if any + */ + carrying, + + /** + * Running commentary for a live region + */ + status, + + /** + * Handle a key press on an entry, giving keyboard users a way to reorder + * @param id Entry id + * @param e Keyboard event + */ + keys(id: string, e: KeyboardEvent) { + if (options.disabled?.()) return; + + const held = carrying(); + + if (e.key === " ") { + e.preventDefault(); + + if (held === id) { + setCarrying(undefined); + announce(t`${name(id)} dropped`); + } else { + setCarrying(id); + announce( + t`${name(id)} picked up, use the arrow keys to move it and space to drop it`, + ); + } + + return; + } + + if (held !== id) return; + + if (e.key === "Escape") { + e.preventDefault(); + setCarrying(undefined); + announce(t`${name(id)} dropped`); + return; + } + + if (e.key === "ArrowUp" || e.key === "ArrowDown") { + e.preventDefault(); + shift(id, e.key === "ArrowUp" ? -1 : 1); + } + }, + + /** + * What dropping right now would do + */ + intent, + + /** + * Where the dragged entry should be drawn + */ + pointer, + + /** + * Track an entry's element so it can be measured + * @param id Entry id + * @param el Element + */ + register(id: string, el: HTMLElement) { + elements.set(id, el); + onCleanup(() => elements.delete(id)); + }, + + /** + * Begin watching for a drag on the given entry + * @param id Entry id + * @param e Pointer event + */ + press(id: string, e: PointerEvent) { + if (options.disabled?.() || e.button) return; + + armed = { id, x: e.clientX, y: e.clientY }; + }, + }; +} From 2d7f7cda6bb735d921f6b094ef600a9b1420bd92 Mon Sep 17 00:00:00 2001 From: Aki Date: Mon, 14 Sep 2026 20:18:40 +0900 Subject: [PATCH 2/2] fix: address PR review feedback and migrate sidebar ordering to serverSidebar Signed-off-by: Aki --- .../components/state/stores/Ordering.ts | 101 ++++++++++++------ .../components/state/stores/ServerFolders.ts | 8 +- .../ui/components/design/ColourPicker.tsx | 2 +- .../navigation/servers/ServerList.tsx | 74 +++++-------- 4 files changed, 100 insertions(+), 85 deletions(-) diff --git a/packages/client/components/state/stores/Ordering.ts b/packages/client/components/state/stores/Ordering.ts index e8a34ddc7a..f0971dbcf3 100644 --- a/packages/client/components/state/stores/Ordering.ts +++ b/packages/client/components/state/stores/Ordering.ts @@ -16,11 +16,43 @@ export interface TypeOrdering { /** * Ordered list of server IDs * - * Servers inside folders stay in this list, and a folder is drawn where the - * first of its servers sits. Kept as plain ids so that clients without - * folder support can read and write this key without losing anything. + * Legacy key, read only. Provides the starting order until serverSidebar + * is written, and is left as older clients last saw it. */ servers: string[]; + + /** + * Ordered list of server and folder IDs for the sidebar + */ + serverSidebar?: string[]; +} + +/** + * Extract and deduplicate valid IDs from ordering input + * @param input Stored value + * @returns List of IDs + */ +function cleanIds(input: unknown): string[] { + const out: string[] = []; + const seen = new Set(); + + for (const entry of Array.isArray(input) ? input : []) { + const ids = + typeof entry === "string" + ? [entry] + : Array.isArray((entry as ServerFolder)?.servers) + ? (entry as ServerFolder).servers + : []; + + for (const id of ids) { + if (typeof id === "string" && id && !seen.has(id)) { + seen.add(id); + out.push(id); + } + } + } + + return out; } /** @@ -71,33 +103,25 @@ export class Ordering extends AbstractStore<"ordering", TypeOrdering> { * Validate the given data to see if it is compliant and return a compliant object * * Accepts the flat `string[]` written by clients without folder support, and - * drops any server appearing more than once so it cannot render twice. + * drops any id appearing more than once so it cannot render twice. */ clean(input: Partial): TypeOrdering { - const servers: string[] = []; - const seen = new Set(); - - if (Array.isArray(input.servers)) { - for (const entry of input.servers) { - // folders used to be written inline here, so keep hold of their - // members rather than dropping the servers along with the folder - const ids = - typeof entry === "string" - ? [entry] - : Array.isArray((entry as ServerFolder)?.servers) - ? (entry as ServerFolder).servers - : []; - - for (const id of ids) { - if (typeof id === "string" && id && !seen.has(id)) { - seen.add(id); - servers.push(id); - } - } - } + const data: TypeOrdering = { servers: cleanIds(input.servers) }; + + if (Array.isArray(input.serverSidebar)) { + data.serverSidebar = cleanIds(input.serverSidebar); } - return { servers }; + return data; + } + + /** + * The stored order the sidebar is drawn from, falling back to servers + * @returns List of IDs + */ + #sidebar(): string[] { + const { servers, serverSidebar } = this.get(); + return serverSidebar ?? servers; } /** @@ -116,16 +140,19 @@ export class Ordering extends AbstractStore<"ordering", TypeOrdering> { /** * All known servers grouped into their folders, ready to render * - * A folder is drawn where the first of its servers sits in the ordering. - * Servers which are not yet ordered are appended at the end, matching the - * behaviour of {@link orderedServers}. + * A folder is drawn where its id sits in the ordering, falling back to + * the position of its first member. Servers which are not yet ordered are + * appended at the end, matching the behaviour of {@link orderedServers}. * @returns List of resolved entries */ orderedEntries(client: Client): ResolvedEntry[] { const known = new Set(client?.servers.keys() ?? []); + const byId = new Map(); const folderOf = new Map(); for (const folder of this.folders()) { + byId.set(folder.id, folder); + for (const serverId of folder.servers) { if (!folderOf.has(serverId)) { folderOf.set(serverId, folder); @@ -137,7 +164,7 @@ export class Ordering extends AbstractStore<"ordering", TypeOrdering> { const drawn = new Set(); const take = (id: string) => { - const folder = folderOf.get(id); + const folder = byId.get(id) ?? folderOf.get(id); if (folder) { if (drawn.has(folder.id)) return; @@ -160,7 +187,7 @@ export class Ordering extends AbstractStore<"ordering", TypeOrdering> { } }; - this.get().servers.forEach(take); + this.#sidebar().forEach(take); [...known].forEach(take); return out; @@ -169,8 +196,8 @@ export class Ordering extends AbstractStore<"ordering", TypeOrdering> { /** * Set the order of top-level entries * - * Ids may refer to either servers or folders; a folder is written out as the - * servers it holds, so what gets stored stays a plain list of server ids. + * Ids may refer to either servers or folders. Writes to serverSidebar only, + * preserving the legacy servers list for older clients. * @param ids List of IDs */ setServerOrder(ids: string[]) { @@ -179,8 +206,12 @@ export class Ordering extends AbstractStore<"ordering", TypeOrdering> { ); this.set( - "servers", - ids.flatMap((id) => folders.get(id)?.servers ?? [id]), + "serverSidebar", + // members trail the folder so they keep their place when it unpacks + ids.flatMap((id) => { + const folder = folders.get(id); + return folder ? [id, ...folder.servers] : [id]; + }), ); } diff --git a/packages/client/components/state/stores/ServerFolders.ts b/packages/client/components/state/stores/ServerFolders.ts index f1453af44a..3a012d0e30 100644 --- a/packages/client/components/state/stores/ServerFolders.ts +++ b/packages/client/components/state/stores/ServerFolders.ts @@ -54,9 +54,11 @@ const FOLDER_PREFIX = "folder-"; * Folders are kept in their own synced key rather than inside `ordering`. * * Clients without folder support read `ordering.servers` as a plain list of - * server ids and write it back the same way, so anything else stored in there - * is dropped the first time one of them reorders a server. Keeping folders - * under a key those clients never ask for leaves them untouched. + * server ids and write the whole `ordering` key back the same way, so anything + * else stored in there is dropped the first time one of them reorders a + * server. Keeping folders under a key those clients never ask for leaves them + * untouched; only their position in `ordering.serverSidebar` is lost, which + * falls back to member server positions. */ export class ServerFolders extends AbstractStore< "server-folders", diff --git a/packages/client/components/ui/components/design/ColourPicker.tsx b/packages/client/components/ui/components/design/ColourPicker.tsx index bf06242eb3..0fc307c418 100644 --- a/packages/client/components/ui/components/design/ColourPicker.tsx +++ b/packages/client/components/ui/components/design/ColourPicker.tsx @@ -253,6 +253,6 @@ const ColourIndicator = styled("span", { const NoColourIndicator = styled(ColourIndicator, { base: { - border: "2px solid var(--md-sys-color-outline)", + border: "2px dashed var(--md-sys-color-on-surface-variant)", }, }); diff --git a/packages/client/src/interface/navigation/servers/ServerList.tsx b/packages/client/src/interface/navigation/servers/ServerList.tsx index c1b0cc960a..b4433f4164 100644 --- a/packages/client/src/interface/navigation/servers/ServerList.tsx +++ b/packages/client/src/interface/navigation/servers/ServerList.tsx @@ -26,7 +26,7 @@ import { VoiceStatus } from "@revolt/ui/components/design/VoiceStatus"; import MdAdd from "@material-design-icons/svg/filled/add.svg?component-solid"; import MdExplore from "@material-design-icons/svg/filled/explore.svg?component-solid"; -import MdFolder from "@material-design-icons/svg/filled/folder.svg?component-solid"; +import MdFolderOpen from "@material-design-icons/svg/filled/folder_open.svg?component-solid"; import MdHome from "@material-design-icons/svg/filled/home.svg?component-solid"; import MdSettings from "@material-design-icons/svg/filled/settings.svg?component-solid"; @@ -508,7 +508,10 @@ function ServerEntry(props: { @@ -672,19 +667,6 @@ function FolderEntry(props: { type RailDrag = ReturnType; -/** - * Holds the ring which shows a collapsed folder's colour - */ -const FolderIcon = styled("div", { - base: { - display: "grid", - placeItems: "center", - // the padding is what holds the ring off the icon itself - padding: "2px", - borderRadius: "var(--borderRadius-circle)", - }, -}); - /** * Announcements are for screen readers, not for looking at */