mirror of
https://github.com/revoltchat/revite.git
synced 2024-12-26 23:42:11 -05:00
Add collapsible section component.
Can now collapse server categories. Client remembers collapse state, incl. advanced appearance settings.
This commit is contained in:
parent
098e28113b
commit
1768264272
13 changed files with 157 additions and 78 deletions
52
src/components/common/CollapsibleSection.tsx
Normal file
52
src/components/common/CollapsibleSection.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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 };
|
||||||
|
|
37
src/redux/reducers/section_toggle.ts
Normal file
37
src/redux/reducers/section_toggle.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue