feat(categories): autosave category changes

This commit is contained in:
Paul 2021-10-30 18:20:12 +01:00
parent da47348273
commit bb5509f660
5 changed files with 371 additions and 214 deletions

View file

@ -48,11 +48,11 @@ export type UploadState =
| { type: "none" } | { type: "none" }
| { type: "attached"; files: File[] } | { type: "attached"; files: File[] }
| { | {
type: "uploading"; type: "uploading";
files: File[]; files: File[];
percent: number; percent: number;
cancel: CancelTokenSource; cancel: CancelTokenSource;
} }
| { type: "sending"; files: File[] } | { type: "sending"; files: File[] }
| { type: "failed"; files: File[]; error: string }; | { type: "failed"; files: File[]; error: string };
@ -173,9 +173,9 @@ export default observer(({ channel }: Props) => {
const text = const text =
action === "quote" action === "quote"
? `${content ? `${content
.split("\n") .split("\n")
.map((x) => `> ${x}`) .map((x) => `> ${x}`)
.join("\n")}\n\n` .join("\n")}\n\n`
: `${content} `; : `${content} `;
if (!draft || draft.length === 0) { if (!draft || draft.length === 0) {
@ -225,8 +225,8 @@ export default observer(({ channel }: Props) => {
toReplace == "" toReplace == ""
? msg.content.toString() + newText ? msg.content.toString() + newText
: msg.content : msg.content
.toString() .toString()
.replace(new RegExp(toReplace, flags), newText); .replace(new RegExp(toReplace, flags), newText);
if (newContent != msg.content) { if (newContent != msg.content) {
if (newContent.length == 0) { if (newContent.length == 0) {
@ -305,10 +305,10 @@ export default observer(({ channel }: Props) => {
files, files,
percent: Math.round( percent: Math.round(
(i * 100 + (100 * e.loaded) / e.total) / (i * 100 + (100 * e.loaded) / e.total) /
Math.min( Math.min(
files.length, files.length,
CAN_UPLOAD_AT_ONCE, CAN_UPLOAD_AT_ONCE,
), ),
), ),
cancel, cancel,
}), }),
@ -398,6 +398,7 @@ export default observer(({ channel }: Props) => {
} }
} }
// TODO: change to useDebounceCallback
// eslint-disable-next-line // eslint-disable-next-line
const debouncedStopTyping = useCallback( const debouncedStopTyping = useCallback(
debounce(stopTyping as (...args: unknown[]) => void, 1000), debounce(stopTyping as (...args: unknown[]) => void, 1000),
@ -553,13 +554,13 @@ export default observer(({ channel }: Props) => {
placeholder={ placeholder={
channel.channel_type === "DirectMessage" channel.channel_type === "DirectMessage"
? translate("app.main.channel.message_who", { ? translate("app.main.channel.message_who", {
person: channel.recipient?.username, person: channel.recipient?.username,
}) })
: channel.channel_type === "SavedMessages" : channel.channel_type === "SavedMessages"
? translate("app.main.channel.message_saved") ? translate("app.main.channel.message_saved")
: translate("app.main.channel.message_where", { : translate("app.main.channel.message_where", {
channel_name: channel.name ?? undefined, channel_name: channel.name ?? undefined,
}) })
} }
disabled={ disabled={
uploadState.type === "uploading" || uploadState.type === "uploading" ||

View file

@ -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 (
<StatusBase>
{status === "saved" ? (
<Check size={20} />
) : status === "editing" ? (
<Pencil size={20} />
) : (
<CloudUpload size={20} />
)}
{/* FIXME: add i18n */}
<span>{status}</span>
</StatusBase>
);
}

View file

@ -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) { export function debounce(cb: (...args: unknown[]) => void, duration: number) {
// Store the timer variable. // Store the timer variable.
let timer: NodeJS.Timeout; let timer: NodeJS.Timeout;
@ -13,3 +17,60 @@ export function debounce(cb: (...args: unknown[]) => void, duration: number) {
}, duration); }, 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<T>(
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,
);
}

View file

@ -50,6 +50,7 @@ export default observer(() => {
title: ( title: (
<Text id="app.settings.server_pages.categories.title" /> <Text id="app.settings.server_pages.categories.title" />
), ),
hideTitle: true,
}, },
{ {
id: "members", id: "members",

View file

@ -1,17 +1,22 @@
import { Check } from "@styled-icons/boxicons-regular";
import isEqual from "lodash.isequal"; import isEqual from "lodash.isequal";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { DragDropContext, Draggable, Droppable } from "react-beautiful-dnd"; import { DragDropContext, Draggable, Droppable } from "react-beautiful-dnd";
import { Category } from "revolt-api/types/Servers"; import { Category } from "revolt-api/types/Servers";
import { Server } from "revolt.js/dist/maps/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 { 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 ChannelIcon from "../../../components/common/ChannelIcon";
import Button from "../../../components/ui/Button"; import Button from "../../../components/ui/Button";
import ComboBox from "../../../components/ui/ComboBox"; import ComboBox from "../../../components/ui/ComboBox";
import InputBox from "../../../components/ui/InputBox"; import InputBox from "../../../components/ui/InputBox";
import SaveStatus, { EditStatus } from "../../../components/ui/SaveStatus";
import Tip from "../../../components/ui/Tip"; import Tip from "../../../components/ui/Tip";
/* interface CreateCategoryProps { /* interface CreateCategoryProps {
@ -25,42 +30,52 @@ function CreateCategory({ callback }: CreateCategoryProps) {
} */ } */
const KanbanEntry = styled.div` const KanbanEntry = styled.div`
display: flex; padding: 2px 4px;
align-items: center;
justify-content: center;
gap: 4px; > .inner {
margin: 4px; display: flex;
height: 40px; align-items: center;
padding: 8px;
flex-shrink: 0;
font-size: 0.9em;
background: var(--primary-background);
img { gap: 4px;
height: 40px;
padding: 8px;
flex-shrink: 0; flex-shrink: 0;
} font-size: 0.9em;
background: var(--primary-background);
span { img {
min-width: 0; flex-shrink: 0;
}
overflow: hidden; span {
white-space: nowrap; min-width: 0;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
} }
`; `;
const KanbanList = styled.div` const KanbanList = styled.div<{ last: boolean }>`
gap: 8px; ${(props) =>
width: 180px; !props.last &&
display: flex; css`
flex-shrink: 0; padding-inline-end: 4px;
overflow-y: auto; `}
flex-direction: column;
background: var(--secondary-background);
> [data-rbd-droppable-id] { > .inner {
min-height: 24px; 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` const KanbanBoard = styled.div`
gap: 8px;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
`; `;
const FullSize = styled.div` const FullSize = styled.div`
flex-grow: 1; flex-grow: 1;
min-height: 0;
> * { > * {
height: 100%; height: 100%;
@ -85,191 +100,238 @@ const FullSize = styled.div`
} }
`; `;
const Header = styled.div`
display: flex;
h1 {
flex-grow: 1;
}
`;
interface Props { interface Props {
server: Server; server: Server;
} }
export const Categories = observer(({ server }: Props) => { export const Categories = observer(({ server }: Props) => {
const [status, setStatus] = useState<EditStatus>("saved");
const [categories, setCategories] = useState<Category[]>( const [categories, setCategories] = useState<Category[]>(
server.categories ?? [], server.categories ?? [],
); );
useAutosave(
async () => {
setStatus("saving");
await server.edit({ categories });
setStatus("saved");
},
categories,
server.categories,
() => setStatus("editing"),
);
return ( return (
<DragDropContext <>
onDragEnd={(target) => { <Header>
const { destination, source, draggableId, type } = target; <h1>
<Text id={`app.settings.server_pages.categories.title`} />
</h1>
<SaveStatus status={status} />
</Header>
<DragDropContext
onDragEnd={(target) => {
const { destination, source, draggableId, type } = target;
if ( if (
!destination || !destination ||
(destination.droppableId === source.droppableId && (destination.droppableId === source.droppableId &&
destination.index === source.index) destination.index === source.index)
) { ) {
return; return;
} }
if (type === "column") { if (type === "column") {
// Remove from array. // Remove from array.
const cat = categories.find((x) => x.id === draggableId); const cat = categories.find(
const arr = categories.filter((x) => x.id !== draggableId); (x) => x.id === draggableId,
);
const arr = categories.filter(
(x) => x.id !== draggableId,
);
// Insert at new position. // Insert at new position.
arr.splice(destination.index, 0, cat!); arr.splice(destination.index, 0, cat!);
setCategories(arr); setCategories(arr);
} else { } else {
setCategories( setCategories(
categories.map((category) => { categories.map((category) => {
if (category.id === destination.droppableId) { if (category.id === destination.droppableId) {
const channels = category.channels.filter( 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(
(x) => x !== draggableId, (x) => x !== draggableId,
), );
};
}
return category; channels.splice(
}), destination.index,
); 0,
} draggableId,
}}> );
<FullSize>
<Droppable return {
droppableId="categories" ...category,
direction="horizontal" channels,
type="column"> };
{(provided) => } else if (category.id === source.droppableId) {
( return {
<div ...category,
ref={provided.innerRef} channels: category.channels.filter(
{...provided.droppableProps}> (x) => x !== draggableId,
<KanbanBoard> ),
{categories.map((category, index) => ( };
<Draggable }
key={category.id}
draggableId={category.id} return category;
index={index}> }),
{(provided) => );
( }
<div }}>
{...(provided.draggableProps as any)} <FullSize>
ref={provided.innerRef}> <Droppable
<KanbanList droppableId="categories"
key={category.id}> direction="horizontal"
<KanbanListTitle type="column">
{...(provided.dragHandleProps as any)}> {(provided) =>
<span> (
{ <div
category.title ref={provided.innerRef}
} {...provided.droppableProps}>
</span> <KanbanBoard>
</KanbanListTitle> {categories.map((category, index) => (
<Droppable <Draggable
droppableId={ key={category.id}
category.id draggableId={category.id}
index={index}>
{(provided) =>
(
<div
{...(provided.draggableProps as any)}
ref={
provided.innerRef
}>
<KanbanList
last={
index ===
categories.length -
1
} }
key={ key={
category.id category.id
}> }>
{(provided) => <div class="inner">
( <KanbanListTitle
<div {...(provided.dragHandleProps as any)}>
ref={ <span>
provided.innerRef
}
{...provided.droppableProps}>
{category.channels.map(
(
x,
index,
) => {
const channel =
server.client.channels.get(
x,
);
if (
!channel
)
return null;
return (
<Draggable
key={
x
}
draggableId={
x
}
index={
index
}>
{(
provided,
) =>
(
<div
{...(provided.draggableProps as any)}
{...provided.dragHandleProps}
ref={
provided.innerRef
}>
<KanbanEntry>
<ChannelIcon
target={
channel
}
size={
24
}
/>
<span>
{
channel.name
}
</span>
</KanbanEntry>
</div>
) as any
}
</Draggable>
);
},
)}
{ {
provided.placeholder category.title
} }
</div> </span>
) as any </KanbanListTitle>
} <Droppable
</Droppable> droppableId={
</KanbanList> category.id
</div> }
) as any key={
} category.id
</Draggable> }>
))} {(
{provided.placeholder} provided,
</KanbanBoard> ) =>
</div> (
) as any <div
} ref={
</Droppable> provided.innerRef
</FullSize> }
</DragDropContext> {...provided.droppableProps}>
{category.channels.map(
(
x,
index,
) => {
const channel =
server.client.channels.get(
x,
);
if (
!channel
)
return null;
return (
<Draggable
key={
x
}
draggableId={
x
}
index={
index
}>
{(
provided,
) =>
(
<div
{...(provided.draggableProps as any)}
{...provided.dragHandleProps}
ref={
provided.innerRef
}>
<KanbanEntry>
<div class="inner">
<ChannelIcon
target={
channel
}
size={
24
}
/>
<span>
{
channel.name
}
</span>
</div>
</KanbanEntry>
</div>
) as any
}
</Draggable>
);
},
)}
{
provided.placeholder
}
</div>
) as any
}
</Droppable>
</div>
</KanbanList>
</div>
) as any
}
</Draggable>
))}
{provided.placeholder}
</KanbanBoard>
</div>
) as any
}
</Droppable>
</FullSize>
</DragDropContext>
</>
); );
}); });