handmade-revolt/src/controllers/client/Session.tsx

278 lines
7.4 KiB
TypeScript

import { action, computed, makeAutoObservable } from "mobx";
import { API, Client } from "revolt.js";
import { state } from "../../mobx/State";
import { __thisIsAHack } from "../../context/intermediate/Intermediate";
import { modalController } from "../modals/ModalController";
/**
* Current lifecycle state
*/
type State = "Ready" | "Connecting" | "Online" | "Disconnected" | "Offline";
/**
* Possible transitions between states
*/
type Transition =
| {
action: "LOGIN";
apiUrl?: string;
session: SessionPrivate;
configuration?: API.RevoltConfig;
knowledge: "new" | "existing";
}
| {
action:
| "SUCCESS"
| "DISCONNECT"
| "RETRY"
| "LOGOUT"
| "ONLINE"
| "OFFLINE";
};
/**
* Client lifecycle finite state machine
*/
export default class Session {
state: State = window.navigator.onLine ? "Ready" : "Offline";
user_id: string | null = null;
client: Client | null = null;
/**
* Create a new Session
*/
constructor() {
makeAutoObservable(this);
this.onDropped = this.onDropped.bind(this);
this.onReady = this.onReady.bind(this);
this.onOnline = this.onOnline.bind(this);
this.onOffline = this.onOffline.bind(this);
window.addEventListener("online", this.onOnline);
window.addEventListener("offline", this.onOffline);
}
/**
* Initiate logout and destroy client
*/
@action destroy() {
if (this.client) {
this.client.logout(false);
this.state = "Ready";
this.client = null;
}
}
/**
* Called when user's browser signals it is online
*/
private onOnline() {
this.emit({
action: "ONLINE",
});
}
/**
* Called when user's browser signals it is offline
*/
private onOffline() {
this.emit({
action: "OFFLINE",
});
}
/**
* Called when the client signals it has disconnected
*/
private onDropped() {
this.emit({
action: "DISCONNECT",
});
}
/**
* Called when the client signals it has received the Ready packet
*/
private onReady() {
this.emit({
action: "SUCCESS",
});
}
/**
* Create a new Revolt.js Client for this Session
* @param apiUrl Optionally specify an API URL
*/
private createClient(apiUrl?: string) {
this.client = new Client({
unreads: true,
autoReconnect: false,
onPongTimeout: "EXIT",
apiURL: apiUrl ?? import.meta.env.VITE_API_URL,
});
this.client.addListener("dropped", this.onDropped);
this.client.addListener("ready", this.onReady);
}
/**
* Destroy the client including any listeners.
*/
private destroyClient() {
this.client!.removeAllListeners();
this.client!.logout();
this.user_id = null;
this.client = null;
}
/**
* Ensure we are in one of the given states
* @param state Possible states
*/
private assert(...state: State[]) {
let found = false;
for (const target of state) {
if (this.state === target) {
found = true;
break;
}
}
if (!found) {
throw `State must be ${state} in order to transition! (currently ${this.state})`;
}
}
/**
* Continue logging in provided onboarding is successful
* @param data Transition Data
*/
private async continueLogin(data: Transition & { action: "LOGIN" }) {
try {
await this.client!.useExistingSession(data.session);
this.user_id = this.client!.user!._id;
state.auth.setSession(data.session);
} catch (err) {
this.state = "Ready";
throw err;
}
}
/**
* Transition to a new state by a certain action
* @param data Transition Data
*/
@action async emit(data: Transition) {
console.info(`[FSM ${this.user_id ?? "Anonymous"}]`, data);
switch (data.action) {
// Login with session
case "LOGIN": {
this.assert("Ready");
this.state = "Connecting";
this.createClient(data.apiUrl);
if (data.configuration) {
this.client!.configuration = data.configuration;
}
if (data.knowledge === "new") {
await this.client!.fetchConfiguration();
this.client!.session = data.session;
(this.client! as any).$updateHeaders();
const { onboarding } = await this.client!.api.get(
"/onboard/hello",
);
if (onboarding) {
modalController.push({
type: "onboarding",
callback: async (username: string) =>
this.client!.completeOnboarding(
{ username },
false,
).then(() => this.continueLogin(data)),
});
return;
}
}
this.continueLogin(data);
break;
}
// Ready successfully received
case "SUCCESS": {
this.assert("Connecting");
this.state = "Online";
break;
}
// Client got disconnected
case "DISCONNECT": {
if (navigator.onLine) {
this.assert("Online");
this.state = "Disconnected";
setTimeout(() => {
// Check we are still disconnected before retrying.
if (this.state === "Disconnected") {
this.emit({
action: "RETRY",
});
}
}, 1000);
}
break;
}
// We should try reconnecting
case "RETRY": {
this.assert("Disconnected");
this.client!.websocket.connect();
this.state = "Connecting";
break;
}
// User instructed logout
case "LOGOUT": {
this.assert("Connecting", "Online", "Disconnected");
this.state = "Ready";
this.destroyClient();
break;
}
// Browser went offline
case "OFFLINE": {
this.state = "Offline";
break;
}
// Browser went online
case "ONLINE": {
this.assert("Offline");
if (this.client) {
this.state = "Disconnected";
this.emit({
action: "RETRY",
});
} else {
this.state = "Ready";
}
break;
}
}
}
/**
* Whether we are ready to render.
* @returns Boolean
*/
@computed get ready() {
return !!this.client?.user;
}
}