Fix: Icons collapsing in flex.

Feature: Remember what channel was opened last.
Channels: ESC to focus message box / cancel editing.
This commit is contained in:
Paul 2021-06-24 16:22:45 +01:00
parent 558ec17726
commit 363789c825
12 changed files with 137 additions and 34 deletions

View file

@ -14,6 +14,8 @@ interface IconModifiers {
} }
export default styled.svg<IconModifiers>` export default styled.svg<IconModifiers>`
flex-shrink: 0;
img { img {
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -26,6 +28,7 @@ export default styled.svg<IconModifiers>`
`; `;
export const ImageIconBase = styled.img<IconModifiers>` export const ImageIconBase = styled.img<IconModifiers>`
flex-shrink: 0;
object-fit: cover; object-fit: cover;
${ props => !props.square && css` ${ props => !props.square && css`

View file

@ -1,5 +1,5 @@
import { Localizer, Text } from "preact-i18n"; import { Localizer, Text } from "preact-i18n";
import { useContext } from "preact/hooks"; import { useContext, useEffect } from "preact/hooks";
import { Home, Users, Tool, Save } from "@styled-icons/feather"; import { Home, Users, Tool, Save } from "@styled-icons/feather";
import Category from '../../ui/Category'; import Category from '../../ui/Category';
@ -10,6 +10,7 @@ import { connectState } from "../../../redux/connector";
import ConnectionStatus from '../items/ConnectionStatus'; import ConnectionStatus from '../items/ConnectionStatus';
import { WithDispatcher } from "../../../redux/reducers"; import { WithDispatcher } from "../../../redux/reducers";
import { Unreads } from "../../../redux/reducers/unreads"; import { Unreads } from "../../../redux/reducers/unreads";
import ConditionalLink from "../../../lib/ConditionalLink";
import { mapChannelWithUnread, useUnreads } from "./common"; import { mapChannelWithUnread, useUnreads } from "./common";
import { Users as UsersNS } from 'revolt.js/dist/api/objects'; import { Users as UsersNS } from 'revolt.js/dist/api/objects';
import ButtonItem, { ChannelButton } from '../items/ButtonItem'; import ButtonItem, { ChannelButton } from '../items/ButtonItem';
@ -37,6 +38,16 @@ function HomeSidebar(props: Props) {
if (channel && !obj) return <Redirect to="/" />; if (channel && !obj) return <Redirect to="/" />;
if (obj) useUnreads({ ...props, channel: obj }); if (obj) useUnreads({ ...props, channel: obj });
useEffect(() => {
if (!channel) return;
props.dispatcher({
type: 'LAST_OPENED_SET',
parent: 'home',
child: channel
});
}, [ channel ]);
const channelsArr = channels const channelsArr = channels
.filter(x => x.channel_type !== 'SavedMessages') .filter(x => x.channel_type !== 'SavedMessages')
.map(x => mapChannelWithUnread(x, props.unreads)); .map(x => mapChannelWithUnread(x, props.unreads));
@ -55,13 +66,13 @@ function HomeSidebar(props: Props) {
<GenericSidebarList> <GenericSidebarList>
{!isTouchscreenDevice && ( {!isTouchscreenDevice && (
<> <>
<Link to="/"> <ConditionalLink active={pathname === "/"} to="/">
<ButtonItem active={pathname === "/"}> <ButtonItem active={pathname === "/"}>
<Home size={20} /> <Home size={20} />
<span><Text id="app.navigation.tabs.home" /></span> <span><Text id="app.navigation.tabs.home" /></span>
</ButtonItem> </ButtonItem>
</Link> </ConditionalLink>
<Link to="/friends"> <ConditionalLink active={pathname === "/friends"} to="/friends">
<ButtonItem <ButtonItem
active={pathname === "/friends"} active={pathname === "/friends"}
alert={ alert={
@ -75,15 +86,15 @@ function HomeSidebar(props: Props) {
<Users size={20} /> <Users size={20} />
<span><Text id="app.navigation.tabs.friends" /></span> <span><Text id="app.navigation.tabs.friends" /></span>
</ButtonItem> </ButtonItem>
</Link> </ConditionalLink>
</> </>
)} )}
<Link to="/open/saved"> <ConditionalLink active={obj?.channel_type === "SavedMessages"} to="/open/saved">
<ButtonItem active={obj?.channel_type === "SavedMessages"}> <ButtonItem active={obj?.channel_type === "SavedMessages"}>
<Save size={20} /> <Save size={20} />
<span><Text id="app.navigation.tabs.saved" /></span> <span><Text id="app.navigation.tabs.saved" /></span>
</ButtonItem> </ButtonItem>
</Link> </ConditionalLink>
{import.meta.env.DEV && ( {import.meta.env.DEV && (
<Link to="/dev"> <Link to="/dev">
<ButtonItem active={pathname === "/dev"}> <ButtonItem active={pathname === "/dev"}>
@ -115,7 +126,7 @@ function HomeSidebar(props: Props) {
} }
return ( return (
<Link to={`/channel/${x._id}`}> <ConditionalLink active={x._id === channel} to={`/channel/${x._id}`}>
<ChannelButton <ChannelButton
user={user} user={user}
channel={x} channel={x}
@ -123,7 +134,7 @@ function HomeSidebar(props: Props) {
alertCount={x.alertCount} alertCount={x.alertCount}
active={x._id === channel} active={x._id === channel}
/> />
</Link> </ConditionalLink>
); );
})} })}
<PaintCounter /> <PaintCounter />

View file

@ -8,9 +8,11 @@ import { PlusCircle } from "@styled-icons/feather";
import PaintCounter from "../../../lib/PaintCounter"; import PaintCounter from "../../../lib/PaintCounter";
import { attachContextMenu } from 'preact-context-menu'; import { attachContextMenu } from 'preact-context-menu';
import { connectState } from "../../../redux/connector"; import { connectState } from "../../../redux/connector";
import { useLocation, useParams } from "react-router-dom";
import { Unreads } from "../../../redux/reducers/unreads"; import { Unreads } from "../../../redux/reducers/unreads";
import ConditionalLink from "../../../lib/ConditionalLink";
import { Channel, Servers } from "revolt.js/dist/api/objects"; import { Channel, Servers } from "revolt.js/dist/api/objects";
import { Link, useLocation, useParams } from "react-router-dom"; import { LastOpened } from "../../../redux/reducers/last_opened";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { useIntermediate } from "../../../context/intermediate/Intermediate";
import { useChannels, useForceUpdate, useServers } from "../../../context/revoltjs/hooks"; import { useChannels, useForceUpdate, useServers } from "../../../context/revoltjs/hooks";
@ -104,9 +106,10 @@ const ServerEntry = styled.div<{ active: boolean, invert?: boolean }>`
interface Props { interface Props {
unreads: Unreads; unreads: Unreads;
lastOpened: LastOpened;
} }
export function ServerListSidebar({ unreads }: Props) { export function ServerListSidebar({ unreads, lastOpened }: Props) {
const ctx = useForceUpdate(); const ctx = useForceUpdate();
const activeServers = useServers(undefined, ctx) as Servers.Server[]; const activeServers = useServers(undefined, ctx) as Servers.Server[];
const channels = (useChannels(undefined, ctx) as Channel[]) const channels = (useChannels(undefined, ctx) as Channel[])
@ -148,31 +151,36 @@ export function ServerListSidebar({ unreads }: Props) {
} }
if (alertCount > 0) homeUnread = 'mention'; if (alertCount > 0) homeUnread = 'mention';
const homeActive = typeof server === 'undefined' && !path.startsWith('/invite');
return ( return (
<ServersBase> <ServersBase>
<ServerList> <ServerList>
<Link to={`/`}> <ConditionalLink active={homeActive} to={lastOpened.home ? `/channel/${lastOpened.home}` : '/'}>
<ServerEntry invert <ServerEntry invert active={homeActive}>
active={typeof server === 'undefined' && !path.startsWith('/invite')}>
<Icon size={36} unread={homeUnread}> <Icon size={36} unread={homeUnread}>
<img src={logoSVG} /> <img src={logoSVG} />
</Icon> </Icon>
</ServerEntry> </ServerEntry>
</Link> </ConditionalLink>
<LineDivider /> <LineDivider />
{ {
servers.map(entry => servers.map(entry => {
<Link to={`/server/${entry!._id}`}> const active = entry!._id === server?._id;
const id = lastOpened[entry!._id];
return (
<ConditionalLink active={active} to={`/server/${entry!._id}` + (id ? `/channel/${id}` : '')}>
<ServerEntry <ServerEntry
active={entry!._id === server?._id} active={active}
onContextMenu={attachContextMenu('Menu', { server: entry!._id })}> onContextMenu={attachContextMenu('Menu', { server: entry!._id })}>
<Icon size={36} unread={entry.unread}> <Icon size={36} unread={entry.unread}>
<ServerIcon size={32} target={entry} /> <ServerIcon size={32} target={entry} />
</Icon> </Icon>
</ServerEntry> </ServerEntry>
</Link> </ConditionalLink>
) )
})
} }
<IconButton onClick={() => openScreen({ id: 'special_input', type: 'create_server' })}> <IconButton onClick={() => openScreen({ id: 'special_input', type: 'create_server' })}>
<PlusCircle size={36} /> <PlusCircle size={36} />
@ -187,7 +195,8 @@ export default connectState(
ServerListSidebar, ServerListSidebar,
state => { state => {
return { return {
unreads: state.unreads unreads: state.unreads,
lastOpened: state.lastOpened
}; };
} }
); );

View file

@ -15,6 +15,8 @@ import PaintCounter from "../../../lib/PaintCounter";
import styled from "styled-components"; import styled from "styled-components";
import { attachContextMenu } from 'preact-context-menu'; import { attachContextMenu } from 'preact-context-menu';
import ServerHeader from "../../common/ServerHeader"; import ServerHeader from "../../common/ServerHeader";
import { useEffect } from "preact/hooks";
import ConditionalLink from "../../../lib/ConditionalLink";
interface Props { interface Props {
unreads: Unreads; unreads: Unreads;
@ -51,24 +53,37 @@ function ServerSidebar(props: Props & WithDispatcher) {
.map(x => mapChannelWithUnread(x, props.unreads)); .map(x => mapChannelWithUnread(x, props.unreads));
const channel = channels.find(x => x?._id === channel_id); const channel = channels.find(x => x?._id === channel_id);
if (channel_id && !channel) return <Redirect to={`/server/${server_id}`} />;
if (channel) useUnreads({ ...props, channel }, ctx); if (channel) useUnreads({ ...props, channel }, ctx);
useEffect(() => {
if (!channel_id) return;
props.dispatcher({
type: 'LAST_OPENED_SET',
parent: server_id!,
child: channel_id!
});
}, [ channel_id ]);
return ( return (
<ServerBase> <ServerBase>
<ServerHeader server={server} ctx={ctx} /> <ServerHeader server={server} ctx={ctx} />
<ConnectionStatus /> <ConnectionStatus />
<ServerList onContextMenu={attachContextMenu('Menu', { server_list: server._id })}> <ServerList onContextMenu={attachContextMenu('Menu', { server_list: server._id })}>
{channels.map(entry => { {channels.map(entry => {
const active = channel?._id === entry._id;
return ( return (
<Link to={`/server/${server._id}/channel/${entry._id}`}> <ConditionalLink active={active} to={`/server/${server._id}/channel/${entry._id}`}>
<ChannelButton <ChannelButton
key={entry._id} key={entry._id}
channel={entry} channel={entry}
active={channel?._id === entry._id} active={active}
alert={entry.unread} alert={entry.unread}
compact compact
/> />
</Link> </ConditionalLink>
); );
})} })}
</ServerList> </ServerList>

View file

@ -24,11 +24,9 @@ interface Props {
} }
function Settings({ settings, children }: Props) { function Settings({ settings, children }: Props) {
console.info(settings.notification);
const play = useMemo(() => { const play = useMemo(() => {
const enabled: SoundOptions = defaultsDeep(settings.notification ?? {}, DEFAULT_SOUNDS); const enabled: SoundOptions = defaultsDeep(settings.notification ?? {}, DEFAULT_SOUNDS);
return (sound: Sounds) => { return (sound: Sounds) => {
console.info('check if we can play sound', enabled[sound]);
if (enabled[sound]) { if (enabled[sound]) {
playSound(sound); playSound(sound);
} }

View file

@ -0,0 +1,15 @@
import { Link, LinkProps } from "react-router-dom";
type Props = LinkProps & JSX.HTMLAttributes<HTMLAnchorElement> & {
active: boolean
};
export default function ConditionalLink(props: Props) {
const { active, ...linkProps } = props;
if (active) {
return <a>{ props.children }</a>;
} else {
return <Link {...linkProps} />;
}
}

View file

@ -35,7 +35,7 @@ export default function HeaderActions({ channel, toggleSidebar }: ChannelHeaderP
</> </>
) } ) }
<VoiceActions channel={channel} /> <VoiceActions channel={channel} />
{ channel.channel_type === "Group" && !isTouchscreenDevice && ( { (channel.channel_type === "Group" || channel.channel_type === "TextChannel") && !isTouchscreenDevice && (
<IconButton onClick={toggleSidebar}> <IconButton onClick={toggleSidebar}>
<SidebarIcon size={22} /> <SidebarIcon size={22} />
</IconButton> </IconButton>

View file

@ -12,6 +12,7 @@ import { IntermediateContext } from "../../../context/intermediate/Intermediate"
import { ClientStatus, StatusContext } from "../../../context/revoltjs/RevoltClient"; import { ClientStatus, StatusContext } from "../../../context/revoltjs/RevoltClient";
import { useContext, useEffect, useLayoutEffect, useRef, useState } from "preact/hooks"; import { useContext, useEffect, useLayoutEffect, useRef, useState } from "preact/hooks";
import { defer } from "../../../lib/defer"; import { defer } from "../../../lib/defer";
import { internalEmit } from "../../../lib/eventEmitter";
const Area = styled.div` const Area = styled.div`
height: 100%; height: 100%;
@ -246,6 +247,7 @@ export function MessageArea({ id }: Props) {
function keyUp(e: KeyboardEvent) { function keyUp(e: KeyboardEvent) {
if (e.key === "Escape" && !focusTaken) { if (e.key === "Escape" && !focusTaken) {
SingletonMessageRenderer.jumpToBottom(id, true); SingletonMessageRenderer.jumpToBottom(id, true);
internalEmit("TextArea", "focus", "message");
} }
} }

View file

@ -1,9 +1,10 @@
import styled from "styled-components"; import styled from "styled-components";
import { useContext, useState } from "preact/hooks"; import { useContext, useEffect, useState } from "preact/hooks";
import TextAreaAutoSize from "../../../lib/TextAreaAutoSize"; import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
import { MessageObject } from "../../../context/revoltjs/util"; import { MessageObject } from "../../../context/revoltjs/util";
import { AppContext } from "../../../context/revoltjs/RevoltClient"; import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import { IntermediateContext } from "../../../context/intermediate/Intermediate";
const EditorBase = styled.div` const EditorBase = styled.div`
display: flex; display: flex;
@ -38,6 +39,7 @@ interface Props {
export default function MessageEditor({ message, finish }: Props) { export default function MessageEditor({ message, finish }: Props) {
const [ content, setContent ] = useState(message.content as string ?? ''); const [ content, setContent ] = useState(message.content as string ?? '');
const { focusTaken } = useContext(IntermediateContext);
const client = useContext(AppContext); const client = useContext(AppContext);
async function save() { async function save() {
@ -55,6 +57,18 @@ export default function MessageEditor({ message, finish }: Props) {
} }
} }
// ? Stop editing when pressing ESC.
useEffect(() => {
function keyUp(e: KeyboardEvent) {
if (e.key === "Escape" && !focusTaken) {
finish();
}
}
document.body.addEventListener("keyup", keyUp);
return () => document.body.removeEventListener("keyup", keyUp);
}, [focusTaken]);
return ( return (
<EditorBase> <EditorBase>
<TextAreaAutoSize <TextAreaAutoSize

View file

@ -12,6 +12,7 @@ import { SyncOptions } from "./reducers/sync";
import { Settings } from "./reducers/settings"; import { Settings } from "./reducers/settings";
import { QueuedMessage } from "./reducers/queue"; import { QueuedMessage } from "./reducers/queue";
import { ExperimentOptions } from "./reducers/experiments"; import { ExperimentOptions } from "./reducers/experiments";
import { LastOpened } from "./reducers/last_opened";
export type State = { export type State = {
config: Core.RevoltNodeConfiguration, config: Core.RevoltNodeConfiguration,
@ -24,6 +25,7 @@ export type State = {
drafts: Drafts; drafts: Drafts;
sync: SyncOptions; sync: SyncOptions;
experiments: ExperimentOptions; experiments: ExperimentOptions;
lastOpened: LastOpened;
}; };
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -51,6 +53,7 @@ store.subscribe(() => {
drafts, drafts,
sync, sync,
experiments, experiments,
lastOpened
} = store.getState() as State; } = store.getState() as State;
localForage.setItem("state", { localForage.setItem("state", {
@ -63,5 +66,6 @@ store.subscribe(() => {
drafts, drafts,
sync, sync,
experiments, experiments,
lastOpened
}); });
}); });

View file

@ -11,6 +11,7 @@ import { typing, TypingAction } from "./typing";
import { drafts, DraftAction } from "./drafts"; import { drafts, DraftAction } from "./drafts";
import { sync, SyncAction } from "./sync"; import { sync, SyncAction } from "./sync";
import { experiments, ExperimentsAction } from "./experiments"; import { experiments, ExperimentsAction } from "./experiments";
import { lastOpened, LastOpenedAction } from "./last_opened";
export default combineReducers({ export default combineReducers({
config, config,
@ -23,6 +24,7 @@ export default combineReducers({
drafts, drafts,
sync, sync,
experiments, experiments,
lastOpened
}); });
export type Action = export type Action =
@ -36,6 +38,7 @@ export type Action =
| DraftAction | DraftAction
| SyncAction | SyncAction
| ExperimentsAction | ExperimentsAction
| LastOpenedAction
| { 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,29 @@
export interface LastOpened {
[key: string]: string
}
export type LastOpenedAction =
| { type: undefined }
| {
type: "LAST_OPENED_SET";
parent: string;
child: string;
}
| {
type: "RESET";
};
export function lastOpened(state = {} as LastOpened, action: LastOpenedAction): LastOpened {
switch (action.type) {
case "LAST_OPENED_SET": {
return {
...state,
[action.parent]: action.child
}
}
case "RESET":
return {};
default:
return state;
}
}