Port navigation.

This commit is contained in:
Paul
2021-06-19 15:29:04 +01:00
parent 5aa8f30e14
commit 5b77ed439f
25 changed files with 1341 additions and 42 deletions

View File

@@ -0,0 +1,160 @@
import { Localizer, Text } from "preact-i18n";
import { useContext, useLayoutEffect } from "preact/hooks";
import { Home, Users, Tool, Settings, Save } from "@styled-icons/feather";
import { Link, Redirect, useHistory, useLocation, useParams } from "react-router-dom";
import { WithDispatcher } from "../../../redux/reducers";
import { Unreads } from "../../../redux/reducers/unreads";
import { connectState } from "../../../redux/connector";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { useChannels, useForceUpdate, useUsers } from "../../../context/revoltjs/hooks";
import { User } from "revolt.js";
import { Users as UsersNS } from 'revolt.js/dist/api/objects';
import { mapChannelWithUnread, useUnreads } from "./common";
import { Channels } from "revolt.js/dist/api/objects";
import UserIcon from '../../common/UserIcon';
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import ConnectionStatus from '../items/ConnectionStatus';
import UserStatus from '../../common/UserStatus';
import ButtonItem, { ChannelButton } from '../items/ButtonItem';
import styled from "styled-components";
import Header from '../../ui/Header';
import UserHeader from "../../common/UserHeader";
import Category from '../../ui/Category';
import PaintCounter from "../../../lib/PaintCounter";
type Props = WithDispatcher & {
unreads: Unreads;
}
const HomeBase = styled.div`
height: 100%;
width: 240px;
display: flex;
flex-shrink: 0;
flex-direction: column;
background: var(--secondary-background);
`;
const HomeList = styled.div`
padding: 6px;
flex-grow: 1;
overflow-y: scroll;
> svg {
width: 100%;
}
`;
function HomeSidebar(props: Props) {
const { pathname } = useLocation();
const { client } = useContext(AppContext);
const { channel } = useParams<{ channel: string }>();
// const { openScreen, writeClipboard } = useContext(IntermediateContext);
const ctx = useForceUpdate();
const users = useUsers(undefined, ctx);
const channels = useChannels(undefined, ctx);
const obj = channels.find(x => x?._id === channel);
if (channel && !obj) return <Redirect to="/" />;
if (obj) useUnreads({ ...props, channel: obj });
const channelsArr = (channels
.filter(
x => x && (x.channel_type === "Group" || (x.channel_type === 'DirectMessage' && x.active))
) as (Channels.GroupChannel | Channels.DirectMessageChannel)[])
.map(x => mapChannelWithUnread(x, props.unreads));
channelsArr.sort((b, a) => a.timestamp.localeCompare(b.timestamp));
return (
<HomeBase>
<UserHeader user={client.user!} />
<ConnectionStatus />
<HomeList>
{!isTouchscreenDevice && (
<>
<Link to="/">
<ButtonItem active={pathname === "/"}>
<Home size={20} />
<span><Text id="app.navigation.tabs.home" /></span>
</ButtonItem>
</Link>
<Link to="/friends">
<ButtonItem
active={pathname === "/friends"}
alert={
typeof users.find(
user =>
user?.relationship ===
UsersNS.Relationship.Incoming
) !== "undefined" ? 'unread' : undefined
}
>
<Users size={20} />
<span><Text id="app.navigation.tabs.friends" /></span>
</ButtonItem>
</Link>
</>
)}
<Link to="/open/saved">
<ButtonItem active={obj?.channel_type === "SavedMessages"}>
<Save size={20} />
<span><Text id="app.navigation.tabs.saved" /></span>
</ButtonItem>
</Link>
{import.meta.env.DEV && (
<Link to="/dev">
<ButtonItem active={pathname === "/dev"}>
<Tool size={20} />
<span><Text id="app.navigation.tabs.dev" /></span>
</ButtonItem>
</Link>
)}
<Localizer>
<Category
text={
(
<Text id="app.main.categories.conversations" />
) as any
}
action={() => /*openScreen({ id: "special_input", type: "create_group" })*/{}}
/>
</Localizer>
{channelsArr.length === 0 && <img src="/assets/images/placeholder.svg" />}
{channelsArr.map(x => {
let user;
if (x.channel_type === 'DirectMessage') {
let recipient = client.channels.getRecipient(x._id);
user = users.find(x => x!._id === recipient);
}
return (
<Link to={`/channel/${x._id}`}>
<ChannelButton
user={user}
channel={x}
alert={x.unread}
alertCount={x.alertCount}
active={x._id === channel}
/>
</Link>
);
})}
<PaintCounter />
</HomeList>
</HomeBase>
);
};
export default connectState(
HomeSidebar,
state => {
return {
unreads: state.unreads
};
},
true,
true
);

View File

@@ -0,0 +1,215 @@
import { useContext } from "preact/hooks";
import { PlusCircle } from "@styled-icons/feather";
import { Channel, Servers } from "revolt.js/dist/api/objects";
import { Link, useLocation, useParams } from "react-router-dom";
import { useChannels, useForceUpdate, useServers } from "../../../context/revoltjs/hooks";
import { mapChannelWithUnread } from "./common";
import { Unreads } from "../../../redux/reducers/unreads";
import { connectState } from "../../../redux/connector";
import styled, { css } from "styled-components";
import { Children } from "../../../types/Preact";
import LineDivider from "../../ui/LineDivider";
import ServerIcon from "../../common/ServerIcon";
import PaintCounter from "../../../lib/PaintCounter";
function Icon({ children, unread, size }: { children: Children, unread?: 'mention' | 'unread', size: number }) {
return (
<svg
width={size}
height={size}
aria-hidden="true"
viewBox="0 0 32 32"
>
<foreignObject x="0" y="0" width="32" height="32">
{ children }
</foreignObject>
{unread === 'unread' && (
<circle
cx="27"
cy="27"
r="5"
fill={"white"}
/>
)}
{unread === 'mention' && (
<circle
cx="27"
cy="27"
r="5"
fill={"red"}
/>
)}
</svg>
)
}
const ServersBase = styled.div`
width: 52px;
height: 100%;
display: flex;
flex-direction: column;
`;
const ServerList = styled.div`
flex-grow: 1;
display: flex;
overflow-y: scroll;
padding-bottom: 48px;
flex-direction: column;
border-inline-end: 2px solid var(--sidebar-active);
scrollbar-width: none;
> :first-child > svg {
margin: 6px 0 6px 4px;
}
&::-webkit-scrollbar {
width: 0px;
}
`;
const ServerEntry = styled.div<{ active: boolean, invert?: boolean }>`
height: 44px;
padding: 4px;
margin: 2px 0 2px 4px;
border-top-left-radius: 4px;
border-bottom-left-radius: 4px;
img {
width: 32px;
height: 32px;
}
${ props => props.active && css`
background: var(--sidebar-active);
` }
${ props => props.active && props.invert && css`
img {
filter: saturate(0) brightness(10);
}
` }
`;
interface Props {
unreads: Unreads;
}
export function ServerListSidebar({ unreads }: Props) {
const ctx = useForceUpdate();
const activeServers = useServers(undefined, ctx) as Servers.Server[];
const channels = (useChannels(undefined, ctx) as Channel[])
.map(x => mapChannelWithUnread(x, unreads));
const unreadChannels = channels.filter(x => x.unread)
.map(x => x._id);
const servers = activeServers.map(server => {
let alertCount = 0;
for (let id of server.channels) {
let channel = channels.find(x => x._id === id);
if (channel?.alertCount) {
alertCount += channel.alertCount;
}
}
return {
...server,
unread: (typeof server.channels.find(x => unreadChannels.includes(x)) !== 'undefined' ?
( alertCount > 0 ? 'mention' : 'unread' ) : undefined) as 'mention' | 'unread' | undefined,
alertCount
}
});
const path = useLocation().pathname;
const { server: server_id } = useParams<{ server?: string }>();
const server = servers.find(x => x!._id == server_id);
// const { openScreen } = useContext(IntermediateContext);
let homeUnread: 'mention' | 'unread' | undefined;
let alertCount = 0;
for (let x of channels) {
if (((x.channel_type === 'DirectMessage' && x.active) || x.channel_type === 'Group') && x.unread) {
homeUnread = 'unread';
alertCount += x.alertCount ?? 0;
}
}
if (alertCount > 0) homeUnread = 'mention';
return (
<ServersBase>
<ServerList>
<Link to={`/`}>
<ServerEntry invert
active={typeof server === 'undefined' && !path.startsWith('/invite')}>
<Icon size={36} unread={homeUnread}>
<img src="/assets/app_icon.png" />
</Icon>
</ServerEntry>
</Link>
<LineDivider />
{
servers.map(entry =>
<Link to={`/server/${entry!._id}`}>
<ServerEntry
active={entry!._id === server?._id}
//onContextMenu={attachContextMenu('Menu', { server: entry!._id })}>
>
<Icon size={36} unread={entry.unread}>
<ServerIcon size={32} target={entry} />
</Icon>
</ServerEntry>
</Link>
)
}
<PaintCounter small />
</ServerList>
</ServersBase>
/*<div className={styles.servers}>
<div className={styles.list}>
<Link to={`/`}>
<div className={styles.entry}
data-active={typeof server === 'undefined' && !path.startsWith('/invite')}>
<Icon size={36} unread={homeUnread} alertCount={alertCount}>
<div className={styles.logo} />
</Icon>
</div>
</Link>
<LineDivider className={styles.divider} />
{
servers.map(entry =>
<Link to={`/server/${entry!._id}`}>
<div className={styles.entry}
data-active={entry!._id === server?._id}
onContextMenu={attachContextMenu('Menu', { server: entry!._id })}>
<Icon size={36} unread={entry.unread}>
<ServerIcon id={entry!._id} size={32} />
</Icon>
</div>
</Link>
)
}
</div>
<div className={styles.overlay}>
<div className={styles.actions}>
<IconButton onClick={() => openScreen({ id: 'special_input', type: 'create_server' })}>
<PlusCircle size={36} />
</IconButton>
</div>
</div>
</div> */
)
}
export default connectState(
ServerListSidebar,
state => {
return {
unreads: state.unreads
};
}
);

View File

@@ -0,0 +1,78 @@
import { Link } from "react-router-dom";
import { Settings } from "@styled-icons/feather";
import { Redirect, useParams } from "react-router";
import { ChannelButton } from "../items/ButtonItem";
import { Channels } from "revolt.js/dist/api/objects";
import { ServerPermission } from "revolt.js/dist/api/permissions";
import { Unreads } from "../../../redux/reducers/unreads";
import { WithDispatcher } from "../../../redux/reducers";
import { useChannels, useForceUpdate, useServer, useServerPermission } from "../../../context/revoltjs/hooks";
import { mapChannelWithUnread, useUnreads } from "./common";
import Header from '../../ui/Header';
import ConnectionStatus from '../items/ConnectionStatus';
import { connectState } from "../../../redux/connector";
import PaintCounter from "../../../lib/PaintCounter";
interface Props {
unreads: Unreads;
}
function ServerSidebar(props: Props & WithDispatcher) {
const { server: server_id, channel: channel_id } = useParams<{ server?: string, channel?: string }>();
const ctx = useForceUpdate();
const server = useServer(server_id, ctx);
if (!server) return <Redirect to="/" />;
const permissions = useServerPermission(server._id, ctx);
const channels = (useChannels(server.channels, ctx)
.filter(entry => typeof entry !== 'undefined') as Readonly<Channels.TextChannel>[])
.map(x => mapChannelWithUnread(x, props.unreads));
const channel = channels.find(x => x?._id === channel_id);
if (channel) useUnreads({ ...props, channel }, ctx);
return (
<div>
<Header placement="secondary" background style={{ background: `url('${ctx.client.servers.getBannerURL(server._id, { width: 480 }, true)}')` }}>
<div>
{ server.name }
</div>
{ (permissions & ServerPermission.ManageServer) > 0 && <div className="actions">
{/*<IconButton to={`/server/${server._id}/settings`}>*/}
<Settings size={24} />
{/*</IconButton>*/}
</div> }
</Header>
<ConnectionStatus />
<div
//onContextMenu={attachContextMenu('Menu', { server_list: server._id })}>
>
{channels.map(entry => {
return (
<Link to={`/server/${server._id}/channel/${entry._id}`}>
<ChannelButton
key={entry._id}
channel={entry}
active={channel?._id === entry._id}
alert={entry.unread}
compact
/>
</Link>
);
})}
</div>
<PaintCounter small />
</div>
)
};
export default connectState(
ServerSidebar,
state => {
return {
unreads: state.unreads
};
},
true
);

View File

@@ -0,0 +1,74 @@
import { Channel } from "revolt.js";
import { useLayoutEffect } from "preact/hooks";
import { WithDispatcher } from "../../../redux/reducers";
import { Unreads } from "../../../redux/reducers/unreads";
import { HookContext, useForceUpdate } from "../../../context/revoltjs/hooks";
type UnreadProps = WithDispatcher & {
channel: Channel;
unreads: Unreads;
}
export function useUnreads({ channel, unreads, dispatcher }: UnreadProps, context?: HookContext) {
const ctx = useForceUpdate(context);
useLayoutEffect(() => {
function checkUnread(target?: Channel) {
if (!target) return;
if (target._id !== channel._id) return;
if (target?.channel_type === "SavedMessages") return;
const unread = unreads[channel._id]?.last_id;
if (target.last_message) {
const message = typeof target.last_message === 'string' ? target.last_message : target.last_message._id;
if (!unread || (unread && message.localeCompare(unread) > 0)) {
dispatcher({
type: "UNREADS_MARK_READ",
channel: channel._id,
message,
request: true
});
}
}
}
checkUnread(channel);
ctx.client.channels.addListener("mutation", checkUnread);
return () => ctx.client.channels.removeListener("mutation", checkUnread);
}, [channel, unreads]);
}
export function mapChannelWithUnread(channel: Channel, unreads: Unreads) {
let last_message_id;
if (channel.channel_type === 'DirectMessage' || channel.channel_type === 'Group') {
last_message_id = channel.last_message?._id;
} else if (channel.channel_type === 'TextChannel') {
last_message_id = channel.last_message;
} else {
return { ...channel, unread: undefined, alertCount: undefined, timestamp: channel._id };
}
let unread: 'mention' | 'unread' | undefined;
let alertCount: undefined | number;
if (last_message_id && unreads) {
const u = unreads[channel._id];
if (u) {
if (u.mentions && u.mentions.length > 0) {
alertCount = u.mentions.length;
unread = 'mention';
} else if (u.last_id && last_message_id.localeCompare(u.last_id) > 0) {
unread = 'unread';
}
} else {
unread = 'unread';
}
}
return {
...channel,
timestamp: last_message_id ?? channel._id,
unread,
alertCount
};
}