diff --git a/src/components/common/messaging/MessageBox.tsx b/src/components/common/messaging/MessageBox.tsx index c6b07383..409f2439 100644 --- a/src/components/common/messaging/MessageBox.tsx +++ b/src/components/common/messaging/MessageBox.tsx @@ -48,11 +48,11 @@ export type UploadState = | { type: "none" } | { type: "attached"; files: File[] } | { - type: "uploading"; - files: File[]; - percent: number; - cancel: CancelTokenSource; - } + type: "uploading"; + files: File[]; + percent: number; + cancel: CancelTokenSource; + } | { type: "sending"; files: File[] } | { type: "failed"; files: File[]; error: string }; @@ -173,9 +173,9 @@ export default observer(({ channel }: Props) => { const text = action === "quote" ? `${content - .split("\n") - .map((x) => `> ${x}`) - .join("\n")}\n\n` + .split("\n") + .map((x) => `> ${x}`) + .join("\n")}\n\n` : `${content} `; if (!draft || draft.length === 0) { @@ -225,8 +225,8 @@ export default observer(({ channel }: Props) => { toReplace == "" ? msg.content.toString() + newText : msg.content - .toString() - .replace(new RegExp(toReplace, flags), newText); + .toString() + .replace(new RegExp(toReplace, flags), newText); if (newContent != msg.content) { if (newContent.length == 0) { @@ -305,10 +305,10 @@ export default observer(({ channel }: Props) => { files, percent: Math.round( (i * 100 + (100 * e.loaded) / e.total) / - Math.min( - files.length, - CAN_UPLOAD_AT_ONCE, - ), + Math.min( + files.length, + CAN_UPLOAD_AT_ONCE, + ), ), cancel, }), @@ -398,6 +398,7 @@ export default observer(({ channel }: Props) => { } } + // TODO: change to useDebounceCallback // eslint-disable-next-line const debouncedStopTyping = useCallback( debounce(stopTyping as (...args: unknown[]) => void, 1000), @@ -553,13 +554,13 @@ export default observer(({ channel }: Props) => { placeholder={ channel.channel_type === "DirectMessage" ? translate("app.main.channel.message_who", { - person: channel.recipient?.username, - }) + person: channel.recipient?.username, + }) : channel.channel_type === "SavedMessages" - ? translate("app.main.channel.message_saved") - : translate("app.main.channel.message_where", { - channel_name: channel.name ?? undefined, - }) + ? translate("app.main.channel.message_saved") + : translate("app.main.channel.message_where", { + channel_name: channel.name ?? undefined, + }) } disabled={ uploadState.type === "uploading" || diff --git a/src/components/ui/SaveStatus.tsx b/src/components/ui/SaveStatus.tsx new file mode 100644 index 00000000..9267b166 --- /dev/null +++ b/src/components/ui/SaveStatus.tsx @@ -0,0 +1,32 @@ +import { Check, CloudUpload } from "@styled-icons/boxicons-regular"; +import { Pencil } from "@styled-icons/boxicons-solid"; +import styled from "styled-components"; + +const StatusBase = styled.div` + gap: 4px; + padding: 4px; + display: flex; + align-items: center; + text-transform: capitalize; +`; + +export type EditStatus = "saved" | "editing" | "saving"; +interface Props { + status: EditStatus; +} + +export default function SaveStatus({ status }: Props) { + return ( + + {status === "saved" ? ( + + ) : status === "editing" ? ( + + ) : ( + + )} + {/* FIXME: add i18n */} + {status} + + ); +} diff --git a/src/lib/debounce.ts b/src/lib/debounce.ts index 007e7626..997924e7 100644 --- a/src/lib/debounce.ts +++ b/src/lib/debounce.ts @@ -1,3 +1,7 @@ +import isEqual from "lodash.isequal"; + +import { Inputs, useCallback, useEffect, useRef } from "preact/hooks"; + export function debounce(cb: (...args: unknown[]) => void, duration: number) { // Store the timer variable. let timer: NodeJS.Timeout; @@ -13,3 +17,60 @@ export function debounce(cb: (...args: unknown[]) => void, duration: number) { }, duration); }; } + +export function useDebounceCallback( + cb: (...args: unknown[]) => void, + inputs: Inputs, + duration = 1000, +) { + // eslint-disable-next-line + return useCallback( + debounce(cb as (...args: unknown[]) => void, duration), + inputs, + ); +} + +export function useAutosaveCallback( + cb: (...args: unknown[]) => void, + inputs: Inputs, + duration = 1000, +) { + const ref = useRef(cb); + + // eslint-disable-next-line + const callback = useCallback( + debounce(() => ref.current(), duration), + [], + ); + + useEffect(() => { + ref.current = cb; + callback(); + // eslint-disable-next-line + }, [cb, callback, ...inputs]); +} + +export function useAutosave( + cb: () => void, + dependency: T, + initialValue: T, + onBeginChange?: () => void, + duration?: number, +) { + if (onBeginChange) { + // eslint-disable-next-line + useEffect( + () => { + !isEqual(dependency, initialValue) && onBeginChange(); + }, + // eslint-disable-next-line + [dependency], + ); + } + + return useAutosaveCallback( + () => !isEqual(dependency, initialValue) && cb(), + [dependency], + duration, + ); +} diff --git a/src/pages/settings/ServerSettings.tsx b/src/pages/settings/ServerSettings.tsx index 639eae2e..ca31d122 100644 --- a/src/pages/settings/ServerSettings.tsx +++ b/src/pages/settings/ServerSettings.tsx @@ -50,6 +50,7 @@ export default observer(() => { title: ( ), + hideTitle: true, }, { id: "members", diff --git a/src/pages/settings/server/Categories.tsx b/src/pages/settings/server/Categories.tsx index 38cdbee9..8cf65834 100644 --- a/src/pages/settings/server/Categories.tsx +++ b/src/pages/settings/server/Categories.tsx @@ -1,17 +1,22 @@ +import { Check } from "@styled-icons/boxicons-regular"; import isEqual from "lodash.isequal"; import { observer } from "mobx-react-lite"; import { DragDropContext, Draggable, Droppable } from "react-beautiful-dnd"; import { Category } from "revolt-api/types/Servers"; import { Server } from "revolt.js/dist/maps/Servers"; -import styled from "styled-components"; +import styled, { css } from "styled-components"; import { ulid } from "ulid"; -import { useState } from "preact/hooks"; +import { Text } from "preact-i18n"; +import { useEffect, useErrorBoundary, useState } from "preact/hooks"; + +import { useAutosave, useAutosaveCallback } from "../../../lib/debounce"; import ChannelIcon from "../../../components/common/ChannelIcon"; import Button from "../../../components/ui/Button"; import ComboBox from "../../../components/ui/ComboBox"; import InputBox from "../../../components/ui/InputBox"; +import SaveStatus, { EditStatus } from "../../../components/ui/SaveStatus"; import Tip from "../../../components/ui/Tip"; /* interface CreateCategoryProps { @@ -25,42 +30,52 @@ function CreateCategory({ callback }: CreateCategoryProps) { } */ const KanbanEntry = styled.div` - display: flex; - align-items: center; - justify-content: center; + padding: 2px 4px; - gap: 4px; - margin: 4px; - height: 40px; - padding: 8px; - flex-shrink: 0; - font-size: 0.9em; - background: var(--primary-background); + > .inner { + display: flex; + align-items: center; - img { + gap: 4px; + height: 40px; + padding: 8px; flex-shrink: 0; - } + font-size: 0.9em; + background: var(--primary-background); - span { - min-width: 0; + img { + flex-shrink: 0; + } - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; + span { + min-width: 0; + + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } } `; -const KanbanList = styled.div` - gap: 8px; - width: 180px; - display: flex; - flex-shrink: 0; - overflow-y: auto; - flex-direction: column; - background: var(--secondary-background); +const KanbanList = styled.div<{ last: boolean }>` + ${(props) => + !props.last && + css` + padding-inline-end: 4px; + `} - > [data-rbd-droppable-id] { - min-height: 24px; + > .inner { + width: 180px; + display: flex; + flex-shrink: 0; + overflow-y: auto; + padding-bottom: 2px; + flex-direction: column; + background: var(--secondary-background); + + > [data-rbd-droppable-id] { + min-height: 24px; + } } `; @@ -71,13 +86,13 @@ const KanbanListTitle = styled.div` `; const KanbanBoard = styled.div` - gap: 8px; display: flex; flex-direction: row; `; const FullSize = styled.div` flex-grow: 1; + min-height: 0; > * { height: 100%; @@ -85,191 +100,238 @@ const FullSize = styled.div` } `; +const Header = styled.div` + display: flex; + + h1 { + flex-grow: 1; + } +`; + interface Props { server: Server; } export const Categories = observer(({ server }: Props) => { + const [status, setStatus] = useState("saved"); const [categories, setCategories] = useState( server.categories ?? [], ); + useAutosave( + async () => { + setStatus("saving"); + await server.edit({ categories }); + setStatus("saved"); + }, + categories, + server.categories, + () => setStatus("editing"), + ); + return ( - { - const { destination, source, draggableId, type } = target; + <> +
+

+ +

+ +
+ { + const { destination, source, draggableId, type } = target; - if ( - !destination || - (destination.droppableId === source.droppableId && - destination.index === source.index) - ) { - return; - } + if ( + !destination || + (destination.droppableId === source.droppableId && + destination.index === source.index) + ) { + return; + } - if (type === "column") { - // Remove from array. - const cat = categories.find((x) => x.id === draggableId); - const arr = categories.filter((x) => x.id !== draggableId); + if (type === "column") { + // Remove from array. + const cat = categories.find( + (x) => x.id === draggableId, + ); + const arr = categories.filter( + (x) => x.id !== draggableId, + ); - // Insert at new position. - arr.splice(destination.index, 0, cat!); - setCategories(arr); - } else { - setCategories( - categories.map((category) => { - if (category.id === destination.droppableId) { - const channels = category.channels.filter( - (x) => x !== draggableId, - ); - - channels.splice( - destination.index, - 0, - draggableId, - ); - - return { - ...category, - channels, - }; - } else if (category.id === source.droppableId) { - return { - ...category, - channels: category.channels.filter( + // Insert at new position. + arr.splice(destination.index, 0, cat!); + setCategories(arr); + } else { + setCategories( + categories.map((category) => { + if (category.id === destination.droppableId) { + const channels = category.channels.filter( (x) => x !== draggableId, - ), - }; - } + ); - return category; - }), - ); - } - }}> - - - {(provided) => - ( -
- - {categories.map((category, index) => ( - - {(provided) => - ( -
- - - - { - category.title - } - - - x !== draggableId, + ), + }; + } + + return category; + }), + ); + } + }}> + + + {(provided) => + ( +
+ + {categories.map((category, index) => ( + + {(provided) => + ( +
+ - {(provided) => - ( -
- {category.channels.map( - ( - x, - index, - ) => { - const channel = - server.client.channels.get( - x, - ); - if ( - !channel - ) - return null; - - return ( - - {( - provided, - ) => - ( -
- - - - { - channel.name - } - - -
- ) as any - } -
- ); - }, - )} +
+ + { - provided.placeholder + category.title } -
- ) as any - } - - -
- ) as any - } - - ))} - {provided.placeholder} - -
- ) as any - } - - - + + + + {( + provided, + ) => + ( +
+ {category.channels.map( + ( + x, + index, + ) => { + const channel = + server.client.channels.get( + x, + ); + if ( + !channel + ) + return null; + + return ( + + {( + provided, + ) => + ( +
+ +
+ + + { + channel.name + } + +
+
+
+ ) as any + } +
+ ); + }, + )} + { + provided.placeholder + } +
+ ) as any + } +
+
+
+
+ ) as any + } +
+ ))} + {provided.placeholder} +
+
+ ) as any + } +
+
+
+ ); });