Hide client behind context.

Use idb for saving data.
Allow logins.
This commit is contained in:
Paul
2021-06-18 22:47:25 +01:00
parent 68a35751b3
commit ec97dbebd0
13 changed files with 474 additions and 187 deletions

View File

@@ -4,6 +4,22 @@ import Context from "./context";
import { Login } from "./pages/login/Login";
import { useForceUpdate, useSelf, useUser } from "./context/revoltjs/hooks";
function Test() {
const ctx = useForceUpdate();
let self = useSelf(ctx);
let bree = useUser('01EZZJ98RM1YVB1FW9FG221CAN', ctx);
return (
<div>
<h1>logged in as { self?.username }</h1>
<h4>bree: { JSON.stringify(bree) }</h4>
</div>
)
}
export function App() {
return (
<Context>
@@ -15,7 +31,7 @@ export function App() {
</Route>
<Route path="/">
<CheckAuth auth>
<h1>revolt app</h1>
<Test />
</CheckAuth>
</Route>
</Switch>

View File

@@ -1,14 +1,19 @@
import { openDB } from 'idb';
import { Client } from "revolt.js";
import { takeError } from "./error";
import { createContext } from "preact";
import { useState } from "preact/hooks";
import { Children } from "../../types/Preact";
import { Route } from "revolt.js/dist/api/routes";
import { useEffect, useState } from "preact/hooks";
import { connectState } from "../../redux/connector";
import Preloader from "../../components/ui/Preloader";
import { WithDispatcher } from "../../redux/reducers";
import { AuthState } from "../../redux/reducers/auth";
import { SyncOptions } from "../../redux/reducers/sync";
import { registerEvents, setReconnectDisallowed } from "./events";
export enum ClientStatus {
INIT,
LOADING,
READY,
OFFLINE,
@@ -26,19 +31,13 @@ export interface ClientOperations {
}
export interface AppState {
client: Client;
status: ClientStatus;
operations: ClientOperations;
}
export const AppContext = createContext<AppState>(undefined as any);
export const RevoltClient = new Client({
autoReconnect: false,
apiURL: import.meta.env.VITE_API_URL,
debug: process.env.NODE_ENV === "development",
// db: new Db("state", 3, ["channels", "servers", "users", "members"])
});
type Props = WithDispatcher & {
auth: AuthState;
sync: SyncOptions;
@@ -46,18 +45,176 @@ type Props = WithDispatcher & {
};
function Context({ auth, sync, children, dispatcher }: Props) {
const [status, setStatus] = useState(ClientStatus.LOADING);
const [status, setStatus] = useState(ClientStatus.INIT);
const [client, setClient] = useState<Client>(undefined as unknown as Client);
useEffect(() => {
(async () => {
let db;
try {
db = await openDB('state', 3, {
upgrade(db) {
for (let store of [ "channels", "servers", "users", "members" ]) {
db.createObjectStore(store, {
keyPath: '_id'
});
}
},
});
} catch (err) {
console.error('Failed to open IndexedDB store, continuing without.');
}
setClient(new Client({
autoReconnect: false,
apiURL: import.meta.env.VITE_API_URL,
debug: import.meta.env.DEV,
db
}));
setStatus(ClientStatus.LOADING);
})();
}, [ ]);
if (status === ClientStatus.INIT) return null;
const value: AppState = {
client,
status,
operations: {
login: async data => {},
logout: async shouldRequest => {},
loggedIn: () => false,
ready: () => false
login: async data => {
setReconnectDisallowed(true);
try {
const onboarding = await client.login(data);
setReconnectDisallowed(false);
const login = () =>
dispatcher({
type: "LOGIN",
session: client.session as any
});
if (onboarding) {
/*openScreen({
id: "onboarding",
callback: async (username: string) => {
await (onboarding as any)(username, true);
login();
}
});*/
} else {
login();
}
} catch (err) {
setReconnectDisallowed(false);
throw err;
}
},
logout: async shouldRequest => {
dispatcher({ type: "LOGOUT" });
delete client.user;
dispatcher({ type: "RESET" });
// openScreen({ id: "none" });
setStatus(ClientStatus.READY);
client.websocket.disconnect();
if (shouldRequest) {
try {
await client.logout();
} catch (err) {
console.error(err);
}
}
},
loggedIn: () => typeof auth.active !== "undefined",
ready: () => (
value.operations.loggedIn() &&
typeof client.user !== "undefined"
)
}
};
useEffect(
() => registerEvents({ ...value, dispatcher }, setStatus, client),
[ client ]
);
useEffect(() => {
(async () => {
await client.restore();
if (auth.active) {
dispatcher({ type: "QUEUE_FAIL_ALL" });
const active = auth.accounts[auth.active];
client.user = client.users.get(active.session.user_id);
if (!navigator.onLine) {
return setStatus(ClientStatus.OFFLINE);
}
if (value.operations.ready())
setStatus(ClientStatus.CONNECTING);
if (navigator.onLine) {
await client
.fetchConfiguration()
.catch(() =>
console.error("Failed to connect to API server.")
);
}
try {
await client.fetchConfiguration();
const callback = await client.useExistingSession(
active.session
);
//if (callback) {
/*openScreen({ id: "onboarding", callback });*/
//} else {
/*
// ! FIXME: all this code needs to be re-written
(async () => {
// ! FIXME: should be included in Ready payload
props.dispatcher({
type: 'SYNC_UPDATE',
// ! FIXME: write a procedure to resolve merge conflicts
update: mapSync(
await client.syncFetchSettings(DEFAULT_ENABLED_SYNC.filter(x => !props.sync?.disabled?.includes(x)))
)
});
})()
props.dispatcher({ type: 'UNREADS_SET', unreads: await client.syncFetchUnreads() });*/
//}
} catch (err) {
setStatus(ClientStatus.DISCONNECTED);
const error = takeError(err);
if (error === "Forbidden") {
value.operations.logout(true);
// openScreen({ id: "signed_out" });
} else {
// openScreen({ id: "error", error });
}
}
} else {
await client
.fetchConfiguration()
.catch(() =>
console.error("Failed to connect to API server.")
);
setStatus(ClientStatus.READY);
}
})();
}, []);
if (status === ClientStatus.LOADING) {
return <Preloader />;
}
return (
<AppContext.Provider value={value}>
{ children }

View File

@@ -0,0 +1,121 @@
import { ClientboundNotification } from "revolt.js/dist/websocket/notifications";
import { WithDispatcher } from "../../redux/reducers";
import { Client, Message } from "revolt.js/dist";
import {
AppState,
ClientStatus
} from "./RevoltClient";
import { StateUpdater } from "preact/hooks";
export var preventReconnect = false;
let preventUntil = 0;
export function setReconnectDisallowed(allowed: boolean) {
preventReconnect = allowed;
}
export function registerEvents({
operations,
dispatcher
}: AppState & WithDispatcher, setStatus: StateUpdater<ClientStatus>, client: Client) {
const listeners = {
connecting: () =>
operations.ready() && setStatus(ClientStatus.CONNECTING),
dropped: () => {
operations.ready() && setStatus(ClientStatus.DISCONNECTED);
if (preventReconnect) return;
function reconnect() {
preventUntil = +new Date() + 2000;
client.websocket.connect().catch(err => console.error(err));
}
if (+new Date() > preventUntil) {
setTimeout(reconnect, 2000);
} else {
reconnect();
}
},
packet: (packet: ClientboundNotification) => {
switch (packet.type) {
case "ChannelStartTyping": {
if (packet.user === client.user?._id) return;
dispatcher({
type: "TYPING_START",
channel: packet.id,
user: packet.user
});
break;
}
case "ChannelStopTyping": {
if (packet.user === client.user?._id) return;
dispatcher({
type: "TYPING_STOP",
channel: packet.id,
user: packet.user
});
break;
}
case "ChannelAck": {
dispatcher({
type: "UNREADS_MARK_READ",
channel: packet.id,
message: packet.message_id,
request: false
});
break;
}
}
},
message: (message: Message) => {
if (message.mentions?.includes(client.user!._id)) {
dispatcher({
type: "UNREADS_MENTION",
channel: message.channel,
message: message._id
});
}
},
ready: () => {
setStatus(ClientStatus.ONLINE);
}
};
let listenerFunc: { [key: string]: Function };
if (import.meta.env.DEV) {
listenerFunc = {};
for (const listener of Object.keys(listeners)) {
listenerFunc[listener] = (...args: any[]) => {
console.debug(`Calling ${listener} with`, args);
(listeners as any)[listener](...args);
};
}
} else {
listenerFunc = listeners;
}
for (const listener of Object.keys(listenerFunc)) {
client.addListener(listener, (listenerFunc as any)[listener]);
}
/*const online = () =>
operations.ready() && setStatus(ClientStatus.RECONNECTING);
const offline = () =>
operations.ready() && setStatus(ClientStatus.OFFLINE);
window.addEventListener("online", online);
window.addEventListener("offline", offline);
return () => {
for (const listener of Object.keys(listenerFunc)) {
RevoltClient.removeListener(listener, (listenerFunc as any)[listener]);
}
window.removeEventListener("online", online);
window.removeEventListener("offline", offline);
};*/
}

View File

@@ -0,0 +1,108 @@
import { useCallback, useContext, useEffect, useState } from "preact/hooks";
import { Channels, Servers, Users } from "revolt.js/dist/api/objects";
import { Client, PermissionCalculator } from 'revolt.js';
import { AppContext } from "./RevoltClient";
interface HookContext {
client: Client,
forceUpdate: () => void
}
export function useForceUpdate(context?: HookContext): HookContext {
const { client } = useContext(AppContext);
if (context) return context;
const [, updateState] = useState({});
return { client, forceUpdate: useCallback(() => updateState({}), []) };
}
function useObject(type: string, id?: string | string[], context?: HookContext) {
const ctx = useForceUpdate(context);
function mutation(target: string) {
if (typeof id === 'string' ? target === id :
Array.isArray(id) ? id.includes(target) : true) {
ctx.forceUpdate();
}
}
const map = (ctx.client as any)[type];
useEffect(() => {
map.addListener("update", mutation);
return () => map.removeListener("update", mutation);
}, [id]);
return typeof id === 'string' ? map.get(id)
: Array.isArray(id) ? id.map(x => map.get(x))
: map.toArray();
}
export function useUser(id?: string, context?: HookContext) {
if (typeof id === "undefined") return;
return useObject('users', id, context) as Readonly<Users.User> | undefined;
}
export function useSelf(context?: HookContext) {
const ctx = useForceUpdate(context);
return useUser(ctx.client.user!._id, ctx);
}
export function useUsers(ids?: string[], context?: HookContext) {
return useObject('users', ids, context) as (Readonly<Users.User> | undefined)[];
}
export function useChannel(id?: string, context?: HookContext) {
if (typeof id === "undefined") return;
return useObject('channels', id, context) as Readonly<Channels.Channel> | undefined;
}
export function useChannels(ids?: string[], context?: HookContext) {
return useObject('channels', ids, context) as (Readonly<Channels.Channel> | undefined)[];
}
export function useServer(id?: string, context?: HookContext) {
if (typeof id === "undefined") return;
return useObject('servers', id, context) as Readonly<Servers.Server> | undefined;
}
export function useServers(ids?: string[], context?: HookContext) {
return useObject('servers', ids, context) as (Readonly<Servers.Server> | undefined)[];
}
export function useUserPermission(id: string, context?: HookContext) {
const ctx = useForceUpdate(context);
const mutation = (target: string) => (target === id) && ctx.forceUpdate();
useEffect(() => {
ctx.client.users.addListener("update", mutation);
return () => ctx.client.users.removeListener("update", mutation);
}, [id]);
let calculator = new PermissionCalculator(ctx.client);
return calculator.forUser(id);
}
export function useChannelPermission(id: string, context?: HookContext) {
const ctx = useForceUpdate(context);
const mutation = (target: string) => (target === id) && ctx.forceUpdate();
useEffect(() => {
ctx.client.channels.addListener("update", mutation);
return () => ctx.client.channels.removeListener("update", mutation);
}, [id]);
let calculator = new PermissionCalculator(ctx.client);
return calculator.forChannel(id);
}
export function useServerPermission(id: string, context?: HookContext) {
const ctx = useForceUpdate(context);
const mutation = (target: string) => (target === id) && ctx.forceUpdate();
useEffect(() => {
ctx.client.servers.addListener("update", mutation);
return () => ctx.client.servers.removeListener("update", mutation);
}, [id]);
let calculator = new PermissionCalculator(ctx.client);
return calculator.forServer(id);
}

View File

@@ -6,7 +6,7 @@ import { APP_VERSION } from "../../version";
import { LIBRARY_VERSION } from "revolt.js";
import { Route, Switch } from "react-router-dom";
import { ThemeContext } from "../../context/Theme";
import { RevoltClient } from "../../context/revoltjs/RevoltClient";
import { AppContext } from "../../context/revoltjs/RevoltClient";
import background from "./background.jpg";
@@ -17,6 +17,7 @@ import { FormReset, FormSendReset } from "./forms/FormReset";
export const Login = () => {
const theme = useContext(ThemeContext);
const { client } = useContext(AppContext);
return (
<div className={styles.login}>
@@ -27,7 +28,7 @@ export const Login = () => {
<div className={styles.attribution}>
<span>
API:{" "}
<code>{RevoltClient.configuration?.revolt ?? "???"}</code>{" "}
<code>{client.configuration?.revolt ?? "???"}</code>{" "}
&middot; revolt.js: <code>{LIBRARY_VERSION}</code>{" "}
&middot; App: <code>{APP_VERSION}</code>
</span>

View File

@@ -1,9 +1,9 @@
import { Text } from "preact-i18n";
import { useEffect } from "preact/hooks";
import styles from "../Login.module.scss";
import HCaptcha from "@hcaptcha/react-hcaptcha";
import { useContext, useEffect } from "preact/hooks";
import Preloader from "../../../components/ui/Preloader";
import { RevoltClient } from "../../../context/revoltjs/RevoltClient";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
export interface CaptchaProps {
onSuccess: (token?: string) => void;
@@ -11,19 +11,21 @@ export interface CaptchaProps {
}
export function CaptchaBlock(props: CaptchaProps) {
const { client } = useContext(AppContext);
useEffect(() => {
if (!RevoltClient.configuration?.features.captcha.enabled) {
if (!client.configuration?.features.captcha.enabled) {
props.onSuccess();
}
}, []);
if (!RevoltClient.configuration?.features.captcha.enabled)
if (!client.configuration?.features.captcha.enabled)
return <Preloader />;
return (
<div>
<HCaptcha
sitekey={RevoltClient.configuration.features.captcha.key}
sitekey={client.configuration.features.captcha.key}
onVerify={token => props.onSuccess(token)}
/>
<div className={styles.footer}>

View File

@@ -1,14 +1,14 @@
import { Legal } from "./Legal";
import { Text } from "preact-i18n";
import { Link } from "react-router-dom";
import { useState } from "preact/hooks";
import styles from "../Login.module.scss";
import { useForm } from "react-hook-form";
import { MailProvider } from "./MailProvider";
import { useContext, useState } from "preact/hooks";
import { CheckCircle, Mail } from "@styled-icons/feather";
import { CaptchaBlock, CaptchaProps } from "./CaptchaBlock";
import { takeError } from "../../../context/revoltjs/error";
import { RevoltClient } from "../../../context/revoltjs/RevoltClient";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import FormField from "../FormField";
import Button from "../../../components/ui/Button";
@@ -34,6 +34,8 @@ function getInviteCode() {
}
export function Form({ page, callback }: Props) {
const { client } = useContext(AppContext);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState<string | undefined>(undefined);
const [error, setGlobalError] = useState<string | undefined>(undefined);
@@ -73,7 +75,7 @@ export function Form({ page, callback }: Props) {
try {
if (
RevoltClient.configuration?.features.captcha.enabled &&
client.configuration?.features.captcha.enabled &&
page !== "reset"
) {
setCaptcha({
@@ -103,7 +105,7 @@ export function Form({ page, callback }: Props) {
if (typeof success !== "undefined") {
return (
<div className={styles.success}>
{RevoltClient.configuration?.features.email ? (
{client.configuration?.features.email ? (
<>
<Mail size={72} />
<h2>
@@ -157,7 +159,7 @@ export function Form({ page, callback }: Props) {
error={errors.password?.message}
/>
)}
{RevoltClient.configuration?.features.invite_only &&
{client.configuration?.features.invite_only &&
page === "create" && (
<FormField
type="invite"

View File

@@ -1,12 +1,15 @@
import { RevoltClient } from "../../../context/revoltjs/RevoltClient";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { useContext } from "preact/hooks";
import { Form } from "./Form";
export function FormCreate() {
const { client } = useContext(AppContext);
return (
<Form
page="create"
callback={async data => {
await RevoltClient.register(process.env.API_SERVER as string, data);
await client.register(import.meta.env.VITE_API_URL, data);
}}
/>
);

View File

@@ -1,12 +1,15 @@
import { RevoltClient } from "../../../context/revoltjs/RevoltClient";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { useContext } from "preact/hooks";
import { Form } from "./Form";
export function FormResend() {
const { client } = useContext(AppContext);
return (
<Form
page="resend"
callback={async data => {
await RevoltClient.req("POST", "/auth/resend", data);
await client.req("POST", "/auth/resend", data);
}}
/>
);

View File

@@ -1,13 +1,16 @@
import { Form } from "./Form";
import { useContext } from "preact/hooks";
import { useHistory, useParams } from "react-router-dom";
import { RevoltClient } from "../../../context/revoltjs/RevoltClient";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
export function FormSendReset() {
const { client } = useContext(AppContext);
return (
<Form
page="send_reset"
callback={async data => {
await RevoltClient.req("POST", "/auth/send_reset", data);
await client.req("POST", "/auth/send_reset", data);
}}
/>
);
@@ -15,13 +18,14 @@ export function FormSendReset() {
export function FormReset() {
const { token } = useParams<{ token: string }>();
const { client } = useContext(AppContext);
const history = useHistory();
return (
<Form
page="reset"
callback={async data => {
await RevoltClient.req("POST", "/auth/reset" as any, {
await client.req("POST", "/auth/reset" as any, {
token,
...(data as any)
});

View File

@@ -28,7 +28,7 @@ export type State = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const store = createStore((state: any, action: any) => {
if (process.env.NODE_ENV === "development") {
if (import.meta.env.DEV) {
console.debug("State Update:", action);
}