Add collapsible section component.

Can now collapse server categories.
Client remembers collapse state, incl. advanced appearance settings.
This commit is contained in:
Paul 2021-07-04 15:53:06 +01:00
parent 098e28113b
commit 1768264272
13 changed files with 157 additions and 78 deletions

View file

@ -0,0 +1,52 @@
import Details from "../ui/Details";
import { State, store } from "../../redux";
import { Action } from "../../redux/reducers";
import { Children } from "../../types/Preact";
import { ChevronDown } from "@styled-icons/boxicons-regular";
interface Props {
id: string;
defaultValue: boolean;
sticky?: boolean;
large?: boolean;
summary: Children;
children: Children;
}
export default function CollapsibleSection({ id, defaultValue, summary, children, ...detailsProps }: Props) {
const state: State = store.getState();
function setState(state: boolean) {
if (state === defaultValue) {
store.dispatch({
type: 'SECTION_TOGGLE_UNSET',
id
} as Action);
} else {
store.dispatch({
type: 'SECTION_TOGGLE_SET',
id,
state
} as Action);
}
}
return (
<Details
open={state.sectionToggle[id] ?? defaultValue}
onToggle={e => setState(e.currentTarget.open)}
{...detailsProps}>
<summary>
<ChevronDown size={20} />
{ summary }
{/*<Overline type="subtle" className="overline">*/}
{/*<div className="title">*/}
{/*</div>*/}
{/*</Overline>*/}
</summary>
{ children }
</Details>
)
}

View file

@ -1,4 +1,4 @@
import { Localizer, Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useContext, useEffect } from "preact/hooks"; import { useContext, useEffect } from "preact/hooks";
import { Home, UserDetail, Wrench, Notepad } from "@styled-icons/boxicons-solid"; import { Home, UserDetail, Wrench, Notepad } from "@styled-icons/boxicons-solid";
@ -105,16 +105,9 @@ function HomeSidebar(props: Props) {
</ButtonItem> </ButtonItem>
</Link> </Link>
)} )}
<Localizer> <Category
<Category text={<Text id="app.main.categories.conversations" />}
text={ action={() => openScreen({ id: "special_input", type: "create_group" })} />
(
<Text id="app.main.categories.conversations" />
) as any
}
action={() => openScreen({ id: "special_input", type: "create_group" })}
/>
</Localizer>
{channelsArr.length === 0 && <img src={placeholderSVG} />} {channelsArr.length === 0 && <img src={placeholderSVG} />}
{channelsArr.map(x => { {channelsArr.map(x => {
let user; let user;

View file

@ -14,6 +14,7 @@ import ServerHeader from "../../common/ServerHeader";
import { useEffect } from "preact/hooks"; import { useEffect } from "preact/hooks";
import Category from "../../ui/Category"; import Category from "../../ui/Category";
import ConditionalLink from "../../../lib/ConditionalLink"; import ConditionalLink from "../../../lib/ConditionalLink";
import CollapsibleSection from "../../common/CollapsibleSection";
interface Props { interface Props {
unreads: Unreads; unreads: Unreads;
@ -69,6 +70,7 @@ function ServerSidebar(props: Props & WithDispatcher) {
let uncategorised = new Set(server.channels); let uncategorised = new Set(server.channels);
let elements = []; let elements = [];
function addChannel(id: string) { function addChannel(id: string) {
const entry = channels.find(x => x._id === id); const entry = channels.find(x => x._id === id);
if (!entry) return; if (!entry) return;
@ -76,9 +78,8 @@ function ServerSidebar(props: Props & WithDispatcher) {
const active = channel?._id === entry._id; const active = channel?._id === entry._id;
return ( return (
<ConditionalLink active={active} to={`/server/${server!._id}/channel/${entry._id}`}> <ConditionalLink key={entry._id} active={active} to={`/server/${server!._id}/channel/${entry._id}`}>
<ChannelButton <ChannelButton
key={entry._id}
channel={entry} channel={entry}
active={active} active={active}
alert={entry.unread} alert={entry.unread}
@ -90,16 +91,24 @@ function ServerSidebar(props: Props & WithDispatcher) {
if (server.categories) { if (server.categories) {
for (let category of server.categories) { for (let category of server.categories) {
elements.push(<Category text={category.title} />); let channels = [];
for (let id of category.channels) { for (let id of category.channels) {
uncategorised.delete(id); uncategorised.delete(id);
elements.push(addChannel(id)); channels.push(addChannel(id));
} }
elements.push(
<CollapsibleSection
id={`category_${category.id}`}
defaultValue
summary={<Category text={category.title} />}>
{ channels }
</CollapsibleSection>
);
} }
} }
for (let id of uncategorised) { for (let id of Array.from(uncategorised).reverse()) {
elements.unshift(addChannel(id)); elements.unshift(addChannel(id));
} }

View file

@ -31,7 +31,7 @@ const CategoryBase = styled.div<Pick<Props, 'variant'>>`
` } ` }
`; `;
type Props = Omit<JSX.HTMLAttributes<HTMLDivElement>, 'children' | 'as'> & { type Props = Omit<JSX.HTMLAttributes<HTMLDivElement>, 'children' | 'as' | 'action'> & {
text: Children; text: Children;
action?: () => void; action?: () => void;
variant?: 'default' | 'uniform'; variant?: 'default' | 'uniform';

View file

@ -1,16 +1,29 @@
import styled from "styled-components"; import styled, { css } from "styled-components";
export default styled.details` export default styled.details<{ sticky?: boolean, large?: boolean }>`
summary { summary {
${ props => props.sticky && css`
top: -1px;
z-index: 10;
position: sticky;
` }
${ props => props.large && css`
padding: 5px 0;
` }
outline: none; outline: none;
display: flex;
cursor: pointer;
list-style: none; list-style: none;
align-items: center;
transition: .2s opacity; transition: .2s opacity;
&::marker, &::-webkit-details-marker { &::marker, &::-webkit-details-marker {
display: none; display: none;
} }
svg { > svg {
flex-shrink: 0; flex-shrink: 0;
transition: .2s ease transform; transition: .2s ease transform;
} }

View file

@ -5,6 +5,7 @@ import { Text } from 'preact-i18n';
type Props = Omit<JSX.HTMLAttributes<HTMLDivElement>, 'children' | 'as'> & { type Props = Omit<JSX.HTMLAttributes<HTMLDivElement>, 'children' | 'as'> & {
error?: string; error?: string;
block?: boolean; block?: boolean;
spaced?: boolean;
children?: Children; children?: Children;
type?: "default" | "subtle" | "error"; type?: "default" | "subtle" | "error";
} }
@ -12,7 +13,10 @@ type Props = Omit<JSX.HTMLAttributes<HTMLDivElement>, 'children' | 'as'> & {
const OverlineBase = styled.div<Omit<Props, "children" | "error">>` const OverlineBase = styled.div<Omit<Props, "children" | "error">>`
display: inline; display: inline;
margin: 0.4em 0; margin: 0.4em 0;
margin-top: 0.8em;
${ props => props.spaced && css`
margin-top: 0.8em;
` }
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;

View file

@ -150,12 +150,6 @@ function Locale({ children, locale }: Props) {
return; return;
} }
if (lang.i18n === "hardcore") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setDefinition({} as any);
return;
}
import(`../../external/lang/${lang.i18n}.json`).then( import(`../../external/lang/${lang.i18n}.json`).then(
async (lang_file) => { async (lang_file) => {
const defn = transformLanguage(lang_file.default); const defn = transformLanguage(lang_file.default);

View file

@ -15,30 +15,6 @@
user-select: none; user-select: none;
overflow-y: scroll; overflow-y: scroll;
summary {
position: sticky;
z-index: 10;
top: -1px;
}
.overline {
display: flex;
align-items: center;
background: var(--primary-background);
padding: 5px 0;
cursor: pointer;
.title {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
svg {
margin-inline-end: 4px;
}
}
&[data-empty="true"] { &[data-empty="true"] {
img { img {
height: 120px; height: 120px;

View file

@ -15,6 +15,7 @@ import { UserDetail, MessageAdd, UserPlus } from "@styled-icons/boxicons-solid";
import { TextReact } from "../../lib/i18n"; import { TextReact } from "../../lib/i18n";
import { Children } from "../../types/Preact"; import { Children } from "../../types/Preact";
import Details from "../../components/ui/Details"; import Details from "../../components/ui/Details";
import CollapsibleSection from "../../components/common/CollapsibleSection";
export default function Friends() { export default function Friends() {
const { openScreen } = useIntermediate(); const { openScreen } = useIntermediate();
@ -30,17 +31,17 @@ export default function Friends() {
) ], ) ],
[ 'app.special.friends.sent', users.filter(x => [ 'app.special.friends.sent', users.filter(x =>
x.relationship === Users.Relationship.Outgoing x.relationship === Users.Relationship.Outgoing
) ], ), 'outgoing' ],
[ 'app.status.online', friends.filter(x => [ 'app.status.online', friends.filter(x =>
x.online && x.status?.presence !== Users.Presence.Invisible x.online && x.status?.presence !== Users.Presence.Invisible
) ], ), 'online' ],
[ 'app.status.offline', friends.filter(x => [ 'app.status.offline', friends.filter(x =>
!x.online || x.status?.presence === Users.Presence.Invisible !x.online || x.status?.presence === Users.Presence.Invisible
) ], ), 'offline' ],
[ 'app.special.friends.blocked', friends.filter(x => [ 'app.special.friends.blocked', users.filter(x =>
x.relationship === Users.Relationship.Blocked x.relationship === Users.Relationship.Blocked
) ] ), 'blocked' ]
] as [ string, User[] ][]; ] as [ string, User[], string ][];
const incoming = lists[0][1]; const incoming = lists[0][1];
const userlist: Children[] = incoming.map(x => <b>{ x.username }</b>); const userlist: Children[] = incoming.map(x => <b>{ x.username }</b>);
@ -108,22 +109,18 @@ export default function Friends() {
</div> } </div> }
{ {
lists.map(([i18n, list], index) => { lists.map(([i18n, list, section_id], index) => {
if (index === 0) return; if (index === 0) return;
if (list.length === 0) return; if (list.length === 0) return;
return ( return (
<Details open> <CollapsibleSection
<summary> id={`friends_${section_id}`}
<Overline className={styles.overline} type="subtle"> defaultValue={true}
<ChevronDown size={20} /> sticky large
<div className={styles.title}> summary={<Overline type="subtle" className="overline"><Text id={i18n} /> { list.length }</Overline>}>
<Text id={i18n} /> { list.length }
</div>
</Overline>
</summary>
{ list.map(x => <Friend key={x._id} user={x} />) } { list.map(x => <Friend key={x._id} user={x} />) }
</Details> </CollapsibleSection>
) )
}) })
} }

View file

@ -22,6 +22,7 @@ import mutantSVG from '../assets/mutant_emoji.svg';
import notoSVG from '../assets/noto_emoji.svg'; import notoSVG from '../assets/noto_emoji.svg';
import openmojiSVG from '../assets/openmoji_emoji.svg'; import openmojiSVG from '../assets/openmoji_emoji.svg';
import twemojiSVG from '../assets/twemoji_emoji.svg'; import twemojiSVG from '../assets/twemoji_emoji.svg';
import CollapsibleSection from "../../../components/common/CollapsibleSection";
interface Props { interface Props {
settings: Settings; settings: Settings;
@ -171,11 +172,7 @@ export function Component(props: Props & WithDispatcher) {
</div> </div>
</div> </div>
<details> <CollapsibleSection id="settings_advanced_appearance" defaultValue={false} summary={<Text id="app.settings.pages.appearance.advanced" />}>
<summary>
<Text id="app.settings.pages.appearance.advanced" />
<div className={styles.divider}></div>
</summary>
<h3> <h3>
<Text id="app.settings.pages.appearance.overrides" /> <Text id="app.settings.pages.appearance.overrides" />
</h3> </h3>
@ -272,7 +269,7 @@ export function Component(props: Props & WithDispatcher) {
code code
value={css} value={css}
onChange={ev => setCSS(ev.currentTarget.value)} /> onChange={ev => setCSS(ev.currentTarget.value)} />
</details> </CollapsibleSection>
</div> </div>
); );
} }

View file

@ -14,6 +14,7 @@ import { QueuedMessage } from "./reducers/queue";
import { ExperimentOptions } from "./reducers/experiments"; import { ExperimentOptions } from "./reducers/experiments";
import { LastOpened } from "./reducers/last_opened"; import { LastOpened } from "./reducers/last_opened";
import { Notifications } from "./reducers/notifications"; import { Notifications } from "./reducers/notifications";
import { SectionToggle } from "./reducers/section_toggle";
export type State = { export type State = {
config: Core.RevoltNodeConfiguration, config: Core.RevoltNodeConfiguration,
@ -28,6 +29,7 @@ export type State = {
experiments: ExperimentOptions; experiments: ExperimentOptions;
lastOpened: LastOpened; lastOpened: LastOpened;
notifications: Notifications; notifications: Notifications;
sectionToggle: SectionToggle;
}; };
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -56,7 +58,8 @@ store.subscribe(() => {
sync, sync,
experiments, experiments,
lastOpened, lastOpened,
notifications notifications,
sectionToggle
} = store.getState() as State; } = store.getState() as State;
localForage.setItem("state", { localForage.setItem("state", {
@ -70,6 +73,7 @@ store.subscribe(() => {
sync, sync,
experiments, experiments,
lastOpened, lastOpened,
notifications notifications,
sectionToggle
}); });
}); });

View file

@ -13,6 +13,7 @@ import { sync, SyncAction } from "./sync";
import { experiments, ExperimentsAction } from "./experiments"; import { experiments, ExperimentsAction } from "./experiments";
import { lastOpened, LastOpenedAction } from "./last_opened"; import { lastOpened, LastOpenedAction } from "./last_opened";
import { notifications, NotificationsAction } from "./notifications"; import { notifications, NotificationsAction } from "./notifications";
import { sectionToggle, SectionToggleAction } from "./section_toggle";
export default combineReducers({ export default combineReducers({
config, config,
@ -26,7 +27,8 @@ export default combineReducers({
sync, sync,
experiments, experiments,
lastOpened, lastOpened,
notifications notifications,
sectionToggle
}); });
export type Action = export type Action =
@ -42,6 +44,7 @@ export type Action =
| ExperimentsAction | ExperimentsAction
| LastOpenedAction | LastOpenedAction
| NotificationsAction | NotificationsAction
| SectionToggleAction
| { type: "__INIT"; state: State }; | { type: "__INIT"; state: State };
export type WithDispatcher = { dispatcher: (action: Action) => void }; export type WithDispatcher = { dispatcher: (action: Action) => void };

View file

@ -0,0 +1,37 @@
export interface SectionToggle {
[key: string]: boolean
}
export type SectionToggleAction =
| { type: undefined }
| {
type: "SECTION_TOGGLE_SET";
id: string;
state: boolean;
}
| {
type: "SECTION_TOGGLE_UNSET";
id: string;
}
| {
type: "RESET";
};
export function sectionToggle(state = {} as SectionToggle, action: SectionToggleAction): SectionToggle {
switch (action.type) {
case "SECTION_TOGGLE_SET": {
return {
...state,
[action.id]: action.state
}
}
case "SECTION_TOGGLE_UNSET": {
const { [action.id]: _, ...newState } = state;
return newState;
}
case "RESET":
return {};
default:
return state;
}
}