feat(mobx): migrate auth and config

This commit is contained in:
Paul 2021-12-11 21:04:12 +00:00
parent bc799931a8
commit f8b8d96d3d
22 changed files with 342 additions and 279 deletions

View file

@ -1,10 +1,13 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" background="#191919">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<!--App Title-->
<title>Revolt</title> <title>Revolt</title>
<meta name="apple-mobile-web-app-title" content="Revolt" /> <meta name="apple-mobile-web-app-title" content="Revolt" />
<!--App Scaling-->
<meta <meta
name="viewport" name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=no" content="width=device-width, initial-scale=1.0, user-scalable=no"
@ -74,9 +77,4 @@
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
<style>
html {
background-color: #191919;
}
</style>
</html> </html>

View file

@ -49,7 +49,10 @@
"FunctionExpression": false "FunctionExpression": false
}, },
"ignore": { "ignore": {
"MethodDefinition": ["toJSON", "hydrate"] "MethodDefinition": [
"toJSON",
"hydrate"
]
} }
} }
] ]
@ -140,7 +143,7 @@
"react-virtuoso": "^1.10.4", "react-virtuoso": "^1.10.4",
"redux": "^4.1.0", "redux": "^4.1.0",
"revolt-api": "0.5.3-alpha.10", "revolt-api": "0.5.3-alpha.10",
"revolt.js": "^5.1.0-alpha.10", "revolt.js": "5.1.0-alpha.15",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"sass": "^1.35.1", "sass": "^1.35.1",
"shade-blend-color": "^1.0.0", "shade-blend-color": "^1.0.0",

View file

@ -276,13 +276,13 @@ export const ServerListSidebar = observer(({ unreads }: Props) => {
onClick={() => onClick={() =>
homeActive && history.push("/settings") homeActive && history.push("/settings")
}> }>
<UserHover user={client.user}> <UserHover user={client.user ?? undefined}>
<Icon <Icon
size={42} size={42}
unread={homeUnread} unread={homeUnread}
count={alertCount}> count={alertCount}>
<UserIcon <UserIcon
target={client.user} target={client.user ?? undefined}
size={32} size={32}
status status
hover hover

View file

@ -205,7 +205,6 @@ export const Languages: { [key in Language]: LanguageEntry } = {
interface Props { interface Props {
children: JSX.Element | JSX.Element[]; children: JSX.Element | JSX.Element[];
locale: Language;
} }
export interface Dictionary { export interface Dictionary {

View file

@ -1,9 +1,9 @@
import { Redirect } from "react-router-dom"; import { Redirect } from "react-router-dom";
import { useContext } from "preact/hooks"; import { useApplicationState } from "../../mobx/State";
import { Children } from "../../types/Preact"; import { Children } from "../../types/Preact";
import { OperationsContext } from "./RevoltClient"; import { useClient } from "./RevoltClient";
interface Props { interface Props {
auth?: boolean; auth?: boolean;
@ -11,11 +11,13 @@ interface Props {
} }
export const CheckAuth = (props: Props) => { export const CheckAuth = (props: Props) => {
const operations = useContext(OperationsContext); const auth = useApplicationState().auth;
const client = useClient();
const ready = auth.isLoggedIn() && typeof client?.user !== "undefined";
if (props.auth && !operations.ready()) { if (props.auth && !ready) {
return <Redirect to="/login" />; return <Redirect to="/login" />;
} else if (!props.auth && operations.ready()) { } else if (!props.auth && ready) {
return <Redirect to="/" />; return <Redirect to="/" />;
} }

View file

@ -1,26 +1,22 @@
/* eslint-disable react-hooks/rules-of-hooks */ /* eslint-disable react-hooks/rules-of-hooks */
import { Session } from "revolt-api/types/Auth"; import { observer } from "mobx-react-lite";
import { Client } from "revolt.js"; import { Client } from "revolt.js";
import { Route } from "revolt.js/dist/api/routes";
import { createContext } from "preact"; import { createContext } from "preact";
import { useContext, useEffect, useMemo, useState } from "preact/hooks"; import { useContext, useEffect, useMemo, useState } from "preact/hooks";
import { dispatch } from "../../redux"; import { useApplicationState } from "../../mobx/State";
import { connectState } from "../../redux/connector";
import { AuthState } from "../../redux/reducers/auth";
import Preloader from "../../components/ui/Preloader"; import Preloader from "../../components/ui/Preloader";
import { Children } from "../../types/Preact"; import { Children } from "../../types/Preact";
import { useIntermediate } from "../intermediate/Intermediate"; import { useIntermediate } from "../intermediate/Intermediate";
import { registerEvents, setReconnectDisallowed } from "./events"; import { registerEvents } from "./events";
import { takeError } from "./util"; import { takeError } from "./util";
export enum ClientStatus { export enum ClientStatus {
INIT,
LOADING,
READY, READY,
LOADING,
OFFLINE, OFFLINE,
DISCONNECTED, DISCONNECTED,
CONNECTING, CONNECTING,
@ -29,179 +25,75 @@ export enum ClientStatus {
} }
export interface ClientOperations { export interface ClientOperations {
login: (
data: Route<"POST", "/auth/session/login">["data"],
) => Promise<void>;
logout: (shouldRequest?: boolean) => Promise<void>; logout: (shouldRequest?: boolean) => Promise<void>;
loggedIn: () => boolean;
ready: () => boolean;
} }
// By the time they are used, they should all be initialized.
// Currently the app does not render until a client is built and the other two are always initialized on first render.
// - insert's words
export const AppContext = createContext<Client>(null!); export const AppContext = createContext<Client>(null!);
export const StatusContext = createContext<ClientStatus>(null!); export const StatusContext = createContext<ClientStatus>(null!);
export const OperationsContext = createContext<ClientOperations>(null!); export const OperationsContext = createContext<ClientOperations>(null!);
export const LogOutContext = createContext(() => {});
type Props = { type Props = {
auth: AuthState;
children: Children; children: Children;
}; };
function Context({ auth, children }: Props) { export default observer(({ children }: Props) => {
const state = useApplicationState();
const { openScreen } = useIntermediate(); const { openScreen } = useIntermediate();
const [status, setStatus] = useState(ClientStatus.INIT); const [client, setClient] = useState<Client>(null!);
const [client, setClient] = useState<Client>( const [status, setStatus] = useState(ClientStatus.LOADING);
undefined as unknown as Client, const [loaded, setLoaded] = useState(false);
);
function logout() {
setLoaded(false);
client.logout(false);
}
useEffect(() => { useEffect(() => {
(async () => { if (navigator.onLine) {
const client = new Client({ new Client().req("GET", "/").then(state.config.set);
autoReconnect: false, }
apiURL: import.meta.env.VITE_API_URL,
debug: import.meta.env.DEV,
});
setClient(client);
setStatus(ClientStatus.LOADING);
})();
}, []); }, []);
if (status === ClientStatus.INIT) return null;
const operations: ClientOperations = useMemo(() => {
return {
login: async (data) => {
setReconnectDisallowed(true);
try {
const onboarding = await client.login(data);
setReconnectDisallowed(false);
const login = () =>
dispatch({
type: "LOGIN",
session: client.session as Session,
});
if (onboarding) {
openScreen({
id: "onboarding",
callback: async (username: string) =>
onboarding(username, true).then(login),
});
} else {
login();
}
} catch (err) {
setReconnectDisallowed(false);
throw err;
}
},
logout: async (shouldRequest) => {
dispatch({ type: "LOGOUT" });
client.reset();
dispatch({ 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: () =>
operations.loggedIn() && typeof client.user !== "undefined",
};
}, [client, auth.active, openScreen]);
useEffect(
() => registerEvents({ operations }, setStatus, client),
[client, operations],
);
useEffect(() => { useEffect(() => {
(async () => { if (state.auth.isLoggedIn()) {
if (auth.active) { const client = state.config.createClient();
dispatch({ type: "QUEUE_FAIL_ALL" }); setClient(client);
const active = auth.accounts[auth.active]; client
client.user = client.users.get(active.session.user_id); .useExistingSession(state.auth.getSession()!)
if (!navigator.onLine) { .then(() => setLoaded(true))
return setStatus(ClientStatus.OFFLINE); .catch((err) => {
}
if (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 });
}
} catch (err) {
setStatus(ClientStatus.DISCONNECTED);
const error = takeError(err); const error = takeError(err);
if (error === "Forbidden" || error === "Unauthorized") { if (error === "Forbidden" || error === "Unauthorized") {
operations.logout(true); client.logout(true);
openScreen({ id: "signed_out" }); openScreen({ id: "signed_out" });
} else { } else {
setStatus(ClientStatus.DISCONNECTED);
openScreen({ id: "error", error }); openScreen({ id: "error", error });
} }
} });
} else { } else {
try { setStatus(ClientStatus.READY);
await client.fetchConfiguration(); setLoaded(true);
} catch (err) { }
console.error("Failed to connect to API server."); }, [state.auth.getSession()]);
}
setStatus(ClientStatus.READY); useEffect(() => registerEvents(state.auth, setStatus, client), [client]);
}
})();
// eslint-disable-next-line
}, []);
if (status === ClientStatus.LOADING) { if (!loaded || status === ClientStatus.LOADING) {
return <Preloader type="spinner" />; return <Preloader type="spinner" />;
} }
return ( return (
<AppContext.Provider value={client}> <AppContext.Provider value={client}>
<StatusContext.Provider value={status}> <StatusContext.Provider value={status}>
<OperationsContext.Provider value={operations}> <LogOutContext.Provider value={logout}>
{children} {children}
</OperationsContext.Provider> </LogOutContext.Provider>
</StatusContext.Provider> </StatusContext.Provider>
</AppContext.Provider> </AppContext.Provider>
); );
}
export default connectState<{ children: Children }>(Context, (state) => {
return {
auth: state.auth,
sync: state.sync,
};
}); });
export const useClient = () => useContext(AppContext); export const useClient = () => useContext(AppContext);

View file

@ -21,7 +21,7 @@ import {
import { Language } from "../Locale"; import { Language } from "../Locale";
import { AppContext, ClientStatus, StatusContext } from "./RevoltClient"; import { AppContext, ClientStatus, StatusContext } from "./RevoltClient";
type Props = { /*type Props = {
settings: Settings; settings: Settings;
locale: Language; locale: Language;
sync: SyncOptions; sync: SyncOptions;
@ -150,4 +150,8 @@ export default connectState(SyncManager, (state) => {
sync: state.sync, sync: state.sync,
notifications: state.notifications, notifications: state.notifications,
}; };
}); });*/
function SyncManager() {
return <></>;
}

View file

@ -4,9 +4,10 @@ import { ClientboundNotification } from "revolt.js/dist/websocket/notifications"
import { StateUpdater } from "preact/hooks"; import { StateUpdater } from "preact/hooks";
import Auth from "../../mobx/stores/Auth";
import { dispatch } from "../../redux"; import { dispatch } from "../../redux";
import { ClientOperations, ClientStatus } from "./RevoltClient"; import { ClientStatus } from "./RevoltClient";
export let preventReconnect = false; export let preventReconnect = false;
let preventUntil = 0; let preventUntil = 0;
@ -16,10 +17,12 @@ export function setReconnectDisallowed(allowed: boolean) {
} }
export function registerEvents( export function registerEvents(
{ operations }: { operations: ClientOperations }, auth: Auth,
setStatus: StateUpdater<ClientStatus>, setStatus: StateUpdater<ClientStatus>,
client: Client, client: Client,
) { ) {
if (!client) return;
function attemptReconnect() { function attemptReconnect() {
if (preventReconnect) return; if (preventReconnect) return;
function reconnect() { function reconnect() {
@ -36,14 +39,11 @@ export function registerEvents(
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
let listeners: Record<string, (...args: any[]) => void> = { let listeners: Record<string, (...args: any[]) => void> = {
connecting: () => connecting: () => setStatus(ClientStatus.CONNECTING),
operations.ready() && setStatus(ClientStatus.CONNECTING),
dropped: () => { dropped: () => {
if (operations.ready()) { setStatus(ClientStatus.DISCONNECTED);
setStatus(ClientStatus.DISCONNECTED); attemptReconnect();
attemptReconnect();
}
}, },
packet: (packet: ClientboundNotification) => { packet: (packet: ClientboundNotification) => {
@ -70,6 +70,11 @@ export function registerEvents(
}, },
ready: () => setStatus(ClientStatus.ONLINE), ready: () => setStatus(ClientStatus.ONLINE),
logout: () => {
auth.logout();
setStatus(ClientStatus.READY);
},
}; };
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
@ -89,19 +94,15 @@ export function registerEvents(
} }
const online = () => { const online = () => {
if (operations.ready()) { setStatus(ClientStatus.RECONNECTING);
setStatus(ClientStatus.RECONNECTING); setReconnectDisallowed(false);
setReconnectDisallowed(false); attemptReconnect();
attemptReconnect();
}
}; };
const offline = () => { const offline = () => {
if (operations.ready()) { setReconnectDisallowed(true);
setReconnectDisallowed(true); client.websocket.disconnect();
client.websocket.disconnect(); setStatus(ClientStatus.OFFLINE);
setStatus(ClientStatus.OFFLINE);
}
}; };
window.addEventListener("online", online); window.addEventListener("online", online);

View file

@ -105,14 +105,14 @@ type Action =
| { action: "create_channel"; target: Server } | { action: "create_channel"; target: Server }
| { action: "create_category"; target: Server } | { action: "create_category"; target: Server }
| { | {
action: "create_invite"; action: "create_invite";
target: Channel; target: Channel;
} }
| { action: "leave_group"; target: Channel } | { action: "leave_group"; target: Channel }
| { | {
action: "delete_channel"; action: "delete_channel";
target: Channel; target: Channel;
} }
| { action: "close_dm"; target: Channel } | { action: "close_dm"; target: Channel }
| { action: "leave_server"; target: Server } | { action: "leave_server"; target: Server }
| { action: "delete_server"; target: Server } | { action: "delete_server"; target: Server }
@ -123,10 +123,10 @@ type Action =
| { action: "open_server_settings"; id: string } | { action: "open_server_settings"; id: string }
| { action: "open_server_channel_settings"; server: string; id: string } | { action: "open_server_channel_settings"; server: string; id: string }
| { | {
action: "set_notification_state"; action: "set_notification_state";
key: string; key: string;
state?: NotificationState; state?: NotificationState;
}; };
type Props = { type Props = {
notifications: Notifications; notifications: Notifications;
@ -488,8 +488,9 @@ function ContextMenus(props: Props) {
elements.push( elements.push(
<MenuItem data={action} disabled={disabled}> <MenuItem data={action} disabled={disabled}>
<Text <Text
id={`app.context_menu.${locale ?? action.action id={`app.context_menu.${
}`} locale ?? action.action
}`}
/> />
{tip && <div className="tip">{tip}</div>} {tip && <div className="tip">{tip}</div>}
</MenuItem>, </MenuItem>,
@ -545,8 +546,8 @@ function ContextMenus(props: Props) {
const user = uid ? client.users.get(uid) : undefined; const user = uid ? client.users.get(uid) : undefined;
const serverChannel = const serverChannel =
targetChannel && targetChannel &&
(targetChannel.channel_type === "TextChannel" || (targetChannel.channel_type === "TextChannel" ||
targetChannel.channel_type === "VoiceChannel") targetChannel.channel_type === "VoiceChannel")
? targetChannel ? targetChannel
: undefined; : undefined;
@ -558,8 +559,8 @@ function ContextMenus(props: Props) {
(server (server
? server.permission ? server.permission
: serverChannel : serverChannel
? serverChannel.server?.permission ? serverChannel.server?.permission
: 0) || 0; : 0) || 0;
const userPermissions = (user ? user.permission : 0) || 0; const userPermissions = (user ? user.permission : 0) || 0;
if (unread) { if (unread) {
@ -705,7 +706,8 @@ function ContextMenus(props: Props) {
if (message && !queued) { if (message && !queued) {
const sendPermission = const sendPermission =
message.channel && message.channel &&
message.channel.permission & ChannelPermission.SendMessage message.channel.permission &
ChannelPermission.SendMessage;
if (sendPermission) { if (sendPermission) {
generateAction({ generateAction({
@ -741,7 +743,7 @@ function ContextMenus(props: Props) {
if ( if (
message.author_id === userId || message.author_id === userId ||
channelPermissions & channelPermissions &
ChannelPermission.ManageMessages ChannelPermission.ManageMessages
) { ) {
generateAction({ generateAction({
action: "delete_message", action: "delete_message",
@ -765,8 +767,8 @@ function ContextMenus(props: Props) {
type === "Image" type === "Image"
? "open_image" ? "open_image"
: type === "Video" : type === "Video"
? "open_video" ? "open_video"
: "open_file", : "open_file",
); );
generateAction( generateAction(
@ -777,8 +779,8 @@ function ContextMenus(props: Props) {
type === "Image" type === "Image"
? "save_image" ? "save_image"
: type === "Video" : type === "Video"
? "save_video" ? "save_video"
: "save_file", : "save_file",
); );
generateAction( generateAction(
@ -930,9 +932,9 @@ function ContextMenus(props: Props) {
if ( if (
serverPermissions & serverPermissions &
ServerPermission.ChangeNickname || ServerPermission.ChangeNickname ||
serverPermissions & serverPermissions &
ServerPermission.ChangeAvatar ServerPermission.ChangeAvatar
) )
generateAction( generateAction(
{ action: "edit_identity", target: server }, { action: "edit_identity", target: server },
@ -976,10 +978,10 @@ function ContextMenus(props: Props) {
sid sid
? "copy_sid" ? "copy_sid"
: cid : cid
? "copy_cid" ? "copy_cid"
: message : message
? "copy_mid" ? "copy_mid"
: "copy_uid", : "copy_uid",
); );
} }

View file

@ -10,6 +10,7 @@ import Draft from "./stores/Draft";
import Experiments from "./stores/Experiments"; import Experiments from "./stores/Experiments";
import Layout from "./stores/Layout"; import Layout from "./stores/Layout";
import LocaleOptions from "./stores/LocaleOptions"; import LocaleOptions from "./stores/LocaleOptions";
import ServerConfig from "./stores/ServerConfig";
/** /**
* Handles global application state. * Handles global application state.
@ -20,6 +21,7 @@ export default class State {
locale: LocaleOptions; locale: LocaleOptions;
experiments: Experiments; experiments: Experiments;
layout: Layout; layout: Layout;
config: ServerConfig;
private persistent: [string, Persistent<unknown>][] = []; private persistent: [string, Persistent<unknown>][] = [];
@ -32,12 +34,16 @@ export default class State {
this.locale = new LocaleOptions(); this.locale = new LocaleOptions();
this.experiments = new Experiments(); this.experiments = new Experiments();
this.layout = new Layout(); this.layout = new Layout();
this.config = new ServerConfig();
makeAutoObservable(this); makeAutoObservable(this);
this.registerListeners = this.registerListeners.bind(this); this.registerListeners = this.registerListeners.bind(this);
this.register(); this.register();
} }
/**
* Categorise and register stores referenced on this object.
*/
private register() { private register() {
for (const key of Object.keys(this)) { for (const key of Object.keys(this)) {
const obj = ( const obj = (
@ -65,12 +71,22 @@ export default class State {
} }
} }
/**
* Register reaction listeners for persistent data stores.
* @returns Function to dispose of listeners
*/
registerListeners() { registerListeners() {
const listeners = this.persistent.map(([id, store]) => { const listeners = this.persistent.map(([id, store]) => {
return reaction( return reaction(
() => store.toJSON(), () => store.toJSON(),
(value) => { async (value) => {
localforage.setItem(id, value); try {
await localforage.setItem(id, value);
} catch (err) {
console.error("Failed to serialise!");
console.error(err);
console.error(value);
}
}, },
); );
}); });
@ -78,6 +94,9 @@ export default class State {
return () => listeners.forEach((x) => x()); return () => listeners.forEach((x) => x());
} }
/**
* Load data stores from local storage.
*/
async hydrate() { async hydrate() {
for (const [id, store] of this.persistent) { for (const [id, store] of this.persistent) {
const data = await localforage.getItem(id); const data = await localforage.getItem(id);

View file

@ -1,12 +1,18 @@
import { makeAutoObservable, ObservableMap } from "mobx"; import { action, computed, makeAutoObservable, ObservableMap } from "mobx";
import { Session } from "revolt-api/types/Auth"; import { Session } from "revolt-api/types/Auth";
import { Nullable } from "revolt.js/dist/util/null"; import { Nullable } from "revolt.js/dist/util/null";
import { mapToRecord } from "../../lib/conversion";
import Persistent from "../interfaces/Persistent"; import Persistent from "../interfaces/Persistent";
import Store from "../interfaces/Store"; import Store from "../interfaces/Store";
interface Account {
session: Session;
}
interface Data { interface Data {
sessions: Record<string, Session>; sessions: Record<string, Account> | [string, Account][];
current?: string; current?: string;
} }
@ -15,7 +21,7 @@ interface Data {
* accounts and their sessions. * accounts and their sessions.
*/ */
export default class Auth implements Store, Persistent<Data> { export default class Auth implements Store, Persistent<Data> {
private sessions: ObservableMap<string, Session>; private sessions: ObservableMap<string, Account>;
private current: Nullable<string>; private current: Nullable<string>;
/** /**
@ -31,17 +37,27 @@ export default class Auth implements Store, Persistent<Data> {
return "auth"; return "auth";
} }
toJSON() { @action toJSON() {
return { return {
sessions: [...this.sessions], sessions: JSON.parse(JSON.stringify(this.sessions)),
current: this.current ?? undefined, current: this.current ?? undefined,
}; };
} }
hydrate(data: Data) { @action hydrate(data: Data) {
Object.keys(data.sessions).forEach((id) => if (Array.isArray(data.sessions)) {
this.sessions.set(id, data.sessions[id]), data.sessions.forEach(([key, value]) =>
); this.sessions.set(key, value),
);
} else if (
typeof data.sessions === "object" &&
data.sessions !== null
) {
let v = data.sessions;
Object.keys(data.sessions).forEach((id) =>
this.sessions.set(id, v[id]),
);
}
if (data.current && this.sessions.has(data.current)) { if (data.current && this.sessions.has(data.current)) {
this.current = data.current; this.current = data.current;
@ -52,8 +68,8 @@ export default class Auth implements Store, Persistent<Data> {
* Add a new session to the auth manager. * Add a new session to the auth manager.
* @param session Session * @param session Session
*/ */
setSession(session: Session) { @action setSession(session: Session) {
this.sessions.set(session.user_id, session); this.sessions.set(session.user_id, { session });
this.current = session.user_id; this.current = session.user_id;
} }
@ -61,11 +77,28 @@ export default class Auth implements Store, Persistent<Data> {
* Remove existing session by user ID. * Remove existing session by user ID.
* @param user_id User ID tied to session * @param user_id User ID tied to session
*/ */
removeSession(user_id: string) { @action removeSession(user_id: string) {
this.sessions.delete(user_id);
if (user_id == this.current) { if (user_id == this.current) {
this.current = null; this.current = null;
} }
this.sessions.delete(user_id);
}
@action logout() {
this.current && this.removeSession(this.current);
}
@computed getSession() {
if (!this.current) return;
return this.sessions.get(this.current)!.session;
}
/**
* Check whether we are currently logged in.
* @returns Whether we are logged in
*/
@computed isLoggedIn() {
return this.current !== null;
} }
} }

View file

@ -0,0 +1,75 @@
import { action, computed, makeAutoObservable } from "mobx";
import { RevoltConfiguration } from "revolt-api/types/Core";
import { Client } from "revolt.js";
import { Nullable } from "revolt.js/dist/util/null";
import Persistent from "../interfaces/Persistent";
import Store from "../interfaces/Store";
interface Data {
config?: RevoltConfiguration;
}
/**
* Stores server configuration data.
*/
export default class ServerConfig
implements Store, Persistent<RevoltConfiguration>
{
private config: Nullable<RevoltConfiguration>;
/**
* Construct new ServerConfig store.
*/
constructor() {
this.config = null;
makeAutoObservable(this);
this.set = this.set.bind(this);
}
get id() {
return "server_conf";
}
toJSON() {
return JSON.parse(JSON.stringify(this.config));
}
@action hydrate(data: RevoltConfiguration) {
this.config = data;
}
/**
* Create a new Revolt client.
* @returns Revolt client
*/
createClient() {
const client = new Client({
autoReconnect: false,
apiURL: import.meta.env.VITE_API_URL,
debug: import.meta.env.DEV,
});
if (this.config !== null) {
client.configuration = this.config;
}
return client;
}
/**
* Get server configuration.
* @returns Server configuration
*/
@computed get() {
return this.config;
}
/**
* Set server configuration.
* @param config Server configuration
*/
@action set(config: RevoltConfiguration) {
this.config = config;
}
}

View file

@ -1,3 +1,4 @@
import { observer } from "mobx-react-lite";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { Route, Switch } from "react-router-dom"; import { Route, Switch } from "react-router-dom";
import { LIBRARY_VERSION } from "revolt.js"; import { LIBRARY_VERSION } from "revolt.js";
@ -6,22 +7,24 @@ import styles from "./Login.module.scss";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useContext } from "preact/hooks"; import { useContext } from "preact/hooks";
import { useApplicationState } from "../../mobx/State";
import { ThemeContext } from "../../context/Theme"; import { ThemeContext } from "../../context/Theme";
import { AppContext } from "../../context/revoltjs/RevoltClient"; import { AppContext } from "../../context/revoltjs/RevoltClient";
import LocaleSelector from "../../components/common/LocaleSelector"; import LocaleSelector from "../../components/common/LocaleSelector";
import background from "./background.jpg";
import { Titlebar } from "../../components/native/Titlebar"; import { Titlebar } from "../../components/native/Titlebar";
import { APP_VERSION } from "../../version"; import { APP_VERSION } from "../../version";
import background from "./background.jpg";
import { FormCreate } from "./forms/FormCreate"; import { FormCreate } from "./forms/FormCreate";
import { FormLogin } from "./forms/FormLogin"; import { FormLogin } from "./forms/FormLogin";
import { FormReset, FormSendReset } from "./forms/FormReset"; import { FormReset, FormSendReset } from "./forms/FormReset";
import { FormResend, FormVerify } from "./forms/FormVerify"; import { FormResend, FormVerify } from "./forms/FormVerify";
export default function Login() { export default observer(() => {
const theme = useContext(ThemeContext); const theme = useContext(ThemeContext);
const client = useContext(AppContext); const configuration = useApplicationState().config.get();
return ( return (
<> <>
@ -35,8 +38,7 @@ export default function Login() {
<div className={styles.content}> <div className={styles.content}>
<div className={styles.attribution}> <div className={styles.attribution}>
<span> <span>
API:{" "} API: <code>{configuration?.revolt ?? "???"}</code>{" "}
<code>{client.configuration?.revolt ?? "???"}</code>{" "}
&middot; revolt.js: <code>{LIBRARY_VERSION}</code>{" "} &middot; revolt.js: <code>{LIBRARY_VERSION}</code>{" "}
&middot; App: <code>{APP_VERSION}</code> &middot; App: <code>{APP_VERSION}</code>
</span> </span>
@ -80,4 +82,4 @@ export default function Login() {
</div> </div>
</> </>
); );
} });

View file

@ -1,10 +1,11 @@
import HCaptcha from "@hcaptcha/react-hcaptcha"; import HCaptcha from "@hcaptcha/react-hcaptcha";
import { observer } from "mobx-react-lite";
import styles from "../Login.module.scss"; import styles from "../Login.module.scss";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useContext, useEffect } from "preact/hooks"; import { useEffect } from "preact/hooks";
import { AppContext } from "../../../context/revoltjs/RevoltClient"; import { useApplicationState } from "../../../mobx/State";
import Preloader from "../../../components/ui/Preloader"; import Preloader from "../../../components/ui/Preloader";
@ -13,22 +14,22 @@ export interface CaptchaProps {
onCancel: () => void; onCancel: () => void;
} }
export function CaptchaBlock(props: CaptchaProps) { export const CaptchaBlock = observer((props: CaptchaProps) => {
const client = useContext(AppContext); const configuration = useApplicationState().config.get();
useEffect(() => { useEffect(() => {
if (!client.configuration?.features.captcha.enabled) { if (!configuration?.features.captcha.enabled) {
props.onSuccess(); props.onSuccess();
} }
}, [client.configuration?.features.captcha.enabled, props]); }, [configuration?.features.captcha.enabled, props]);
if (!client.configuration?.features.captcha.enabled) if (!configuration?.features.captcha.enabled)
return <Preloader type="spinner" />; return <Preloader type="spinner" />;
return ( return (
<div> <div>
<HCaptcha <HCaptcha
sitekey={client.configuration.features.captcha.key} sitekey={configuration.features.captcha.key}
onVerify={(token) => props.onSuccess(token)} onVerify={(token) => props.onSuccess(token)}
/> />
<div className={styles.footer}> <div className={styles.footer}>
@ -38,4 +39,4 @@ export function CaptchaBlock(props: CaptchaProps) {
</div> </div>
</div> </div>
); );
} });

View file

@ -6,6 +6,8 @@ import styles from "../Login.module.scss";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useContext, useState } from "preact/hooks"; import { useContext, useState } from "preact/hooks";
import { useApplicationState } from "../../../mobx/State";
import { AppContext } from "../../../context/revoltjs/RevoltClient"; import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { takeError } from "../../../context/revoltjs/util"; import { takeError } from "../../../context/revoltjs/util";
@ -44,7 +46,7 @@ interface FormInputs {
} }
export function Form({ page, callback }: Props) { export function Form({ page, callback }: Props) {
const client = useContext(AppContext); const configuration = useApplicationState().config.get();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState<string | undefined>(undefined); const [success, setSuccess] = useState<string | undefined>(undefined);
@ -80,10 +82,7 @@ export function Form({ page, callback }: Props) {
} }
try { try {
if ( if (configuration?.features.captcha.enabled && page !== "reset") {
client.configuration?.features.captcha.enabled &&
page !== "reset"
) {
setCaptcha({ setCaptcha({
onSuccess: async (captcha) => { onSuccess: async (captcha) => {
setCaptcha(undefined); setCaptcha(undefined);
@ -111,7 +110,7 @@ export function Form({ page, callback }: Props) {
if (typeof success !== "undefined") { if (typeof success !== "undefined") {
return ( return (
<div className={styles.success}> <div className={styles.success}>
{client.configuration?.features.email ? ( {configuration?.features.email ? (
<> <>
<Envelope size={72} /> <Envelope size={72} />
<h2> <h2>
@ -172,15 +171,14 @@ export function Form({ page, callback }: Props) {
error={errors.password?.message} error={errors.password?.message}
/> />
)} )}
{client.configuration?.features.invite_only && {configuration?.features.invite_only && page === "create" && (
page === "create" && ( <FormField
<FormField type="invite"
type="invite" register={register}
register={register} showOverline
showOverline error={errors.invite?.message}
error={errors.invite?.message} />
/> )}
)}
{error && ( {error && (
<Overline type="error" error={error}> <Overline type="error" error={error}>
<Text id={`login.error.${page}`} /> <Text id={`login.error.${page}`} />

View file

@ -1,10 +1,9 @@
import { useContext } from "preact/hooks"; import { useApplicationState } from "../../../mobx/State";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { Form } from "./Form"; import { Form } from "./Form";
export function FormCreate() { export function FormCreate() {
const client = useContext(AppContext); const config = useApplicationState().config;
const client = config.createClient();
return <Form page="create" callback={(data) => client.register(data)} />; return <Form page="create" callback={(data) => client.register(data)} />;
} }

View file

@ -1,15 +1,16 @@
import { detect } from "detect-browser"; import { detect } from "detect-browser";
import { useHistory } from "react-router-dom"; import { Session } from "revolt-api/types/Auth";
import { Client } from "revolt.js";
import { useContext } from "preact/hooks"; import { useApplicationState } from "../../../mobx/State";
import { OperationsContext } from "../../../context/revoltjs/RevoltClient"; import { useIntermediate } from "../../../context/intermediate/Intermediate";
import { Form } from "./Form"; import { Form } from "./Form";
export function FormLogin() { export function FormLogin() {
const { login } = useContext(OperationsContext); const auth = useApplicationState().auth;
const history = useHistory(); const { openScreen } = useIntermediate();
return ( return (
<Form <Form
@ -34,8 +35,40 @@ export function FormLogin() {
friendly_name = "Unknown Device"; friendly_name = "Unknown Device";
} }
await login({ ...data, friendly_name }); // ! FIXME: temporary login flow code
history.push("/"); // This should be replaced in the future.
const client = new Client();
await client.fetchConfiguration();
const session = (await client.req(
"POST",
"/auth/session/login",
{ ...data, friendly_name },
)) as unknown as Session;
client.session = session;
(client as any).Axios.defaults.headers = {
"x-session-token": session?.token,
};
function login() {
auth.setSession(session);
}
const { onboarding } = await client.req(
"GET",
"/onboard/hello",
);
if (onboarding) {
openScreen({
id: "onboarding",
callback: async (username: string) =>
client
.completeOnboarding({ username }, false)
.then(login),
});
} else {
login();
}
}} }}
/> />
); );

View file

@ -2,12 +2,15 @@ import { useHistory, useParams } from "react-router-dom";
import { useContext } from "preact/hooks"; import { useContext } from "preact/hooks";
import { useApplicationState } from "../../../mobx/State";
import { AppContext } from "../../../context/revoltjs/RevoltClient"; import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { Form } from "./Form"; import { Form } from "./Form";
export function FormSendReset() { export function FormSendReset() {
const client = useContext(AppContext); const config = useApplicationState().config;
const client = config.createClient();
return ( return (
<Form <Form

View file

@ -2,6 +2,8 @@ import { useHistory, useParams } from "react-router-dom";
import { useContext, useEffect, useState } from "preact/hooks"; import { useContext, useEffect, useState } from "preact/hooks";
import { useApplicationState } from "../../../mobx/State";
import { AppContext } from "../../../context/revoltjs/RevoltClient"; import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { takeError } from "../../../context/revoltjs/util"; import { takeError } from "../../../context/revoltjs/util";
@ -11,7 +13,8 @@ import Preloader from "../../../components/ui/Preloader";
import { Form } from "./Form"; import { Form } from "./Form";
export function FormResend() { export function FormResend() {
const client = useContext(AppContext); const config = useApplicationState().config;
const client = config.createClient();
return ( return (
<Form <Form

View file

@ -32,7 +32,6 @@ function mapMailProvider(email?: string): [string, string] | undefined {
case "outlook.com.br": case "outlook.com.br":
case "outlook.cl": case "outlook.cl":
case "outlook.cz": case "outlook.cz":
case "outlook.dk":
case "outlook.com.gr": case "outlook.com.gr":
case "outlook.co.il": case "outlook.co.il":
case "outlook.in": case "outlook.in":

View file

@ -29,10 +29,7 @@ import { useContext } from "preact/hooks";
import { useApplicationState } from "../../mobx/State"; import { useApplicationState } from "../../mobx/State";
import RequiresOnline from "../../context/revoltjs/RequiresOnline"; import RequiresOnline from "../../context/revoltjs/RequiresOnline";
import { import { AppContext, LogOutContext } from "../../context/revoltjs/RevoltClient";
AppContext,
OperationsContext,
} from "../../context/revoltjs/RevoltClient";
import LineDivider from "../../components/ui/LineDivider"; import LineDivider from "../../components/ui/LineDivider";
@ -57,7 +54,7 @@ import { ThemeShop } from "./panes/ThemeShop";
export default observer(() => { export default observer(() => {
const history = useHistory(); const history = useHistory();
const client = useContext(AppContext); const client = useContext(AppContext);
const operations = useContext(OperationsContext); const logout = useContext(LogOutContext);
const experiments = useApplicationState().experiments; const experiments = useApplicationState().experiments;
function switchPage(to?: string) { function switchPage(to?: string) {
@ -220,7 +217,7 @@ export default observer(() => {
</a> </a>
<LineDivider /> <LineDivider />
<ButtonItem <ButtonItem
onClick={() => operations.logout()} onClick={logout}
className={styles.logOut} className={styles.logOut}
compact> compact>
<LogOut size={20} /> <LogOut size={20} />

View file

@ -3765,10 +3765,10 @@ revolt-api@^0.5.3-alpha.9:
resolved "https://registry.yarnpkg.com/revolt-api/-/revolt-api-0.5.3-alpha.9.tgz#46e75b7d8f9c6702df39039b829dddbb7897f237" resolved "https://registry.yarnpkg.com/revolt-api/-/revolt-api-0.5.3-alpha.9.tgz#46e75b7d8f9c6702df39039b829dddbb7897f237"
integrity sha512-L8K9uPV3ME8bLdtWm8L9iPQvFM0GghA+5LzmWFjd6Gbn56u22ZYub2lABi4iHrWgeA2X41dGSsuSBgHSlts9Og== integrity sha512-L8K9uPV3ME8bLdtWm8L9iPQvFM0GghA+5LzmWFjd6Gbn56u22ZYub2lABi4iHrWgeA2X41dGSsuSBgHSlts9Og==
revolt.js@^5.1.0-alpha.10: revolt.js@5.1.0-alpha.15:
version "5.1.0-alpha.10" version "5.1.0-alpha.15"
resolved "https://registry.yarnpkg.com/revolt.js/-/revolt.js-5.1.0-alpha.10.tgz#e393ac8524e629d3359135651b23b044c0cc9b7b" resolved "https://registry.yarnpkg.com/revolt.js/-/revolt.js-5.1.0-alpha.15.tgz#a2be1f29de93f1ec18f0e502ecb65ade55c0070d"
integrity sha512-wEmBMJkZE/oWy6mzVZg1qw5QC9CE+Gb7sTFlJl+C4pbXfTJWAtY311Tjbd2tX8w3ohYDmN338bVfCW4cOQ8GXQ== integrity sha512-1gGcGDv1+J5NlmnX099XafKugCebACg9ke0NA754I4hLTNMMwkZyphyvYWWWkI394qn2mA3NG7WgEmrIoZUtgw==
dependencies: dependencies:
axios "^0.21.4" axios "^0.21.4"
eventemitter3 "^4.0.7" eventemitter3 "^4.0.7"