Compare commits

..

44 commits

Author SHA1 Message Date
becaf79690 Add managing preferredFor from editing
All checks were successful
Build & Test / build-run (push) Successful in 45s
2025-02-12 06:49:22 +01:00
68f54b341b This is no longer necessary with LSV1
All checks were successful
Build & Test / build-run (push) Successful in 42s
2025-02-11 23:08:20 +01:00
5e3817c1a7 Add migration
All checks were successful
Build & Test / build-run (push) Successful in 42s
2025-02-11 22:11:56 +01:00
f0617522e3 Implement "Destroy all data" button
All checks were successful
Build & Test / build-run (push) Successful in 49s
Which resets the storage manager, not the entire localstorage. Not that localstorage should be touched outside of the storage manager, but it means I can keep my backups for debugging in there.
2025-02-09 19:13:54 +01:00
7a39fbd418 Re-add setup deno (oops)
All checks were successful
Build & Test / build-run (push) Successful in 41s
2025-02-04 00:11:32 +01:00
8850e08f5d Try using the rust image again
Some checks failed
Build & Test / build-run (push) Failing after 23s
Manually install node this time
2025-02-04 00:08:38 +01:00
kio
9a4e2b3ca5 Remove unneccesary lines
All checks were successful
Build & Test / build-run (push) Successful in 58s
2025-02-03 22:45:31 +00:00
kio
9f5e6115e9 openssl-libs-static
All checks were successful
Build & Test / build-run (push) Successful in 58s
2025-02-03 22:41:02 +00:00
kio
22bbbf0955 add musl-dev
Some checks failed
Build & Test / build-run (push) Failing after 53s
2025-02-03 22:37:46 +00:00
kio
563462c1c5 Update .forgejo/workflows/ci.yaml
Some checks failed
Build & Test / build-run (push) Failing after 29s
2025-02-03 22:36:15 +00:00
kio
cfae51c43f Update .forgejo/workflows/ci.yaml
Some checks failed
Build & Test / build-run (push) Failing after 24s
2025-02-03 22:35:17 +00:00
kio
beaa76f996 Literal paths
Some checks failed
Build & Test / build-run (push) Failing after 36s
2025-02-03 22:28:00 +00:00
kio
3244cecc6a ingest cargo env vars
Some checks failed
Build & Test / build-run (push) Failing after 19s
2025-02-03 22:26:44 +00:00
kio
b4c70c0d16 automate the install
Some checks failed
Build & Test / build-run (push) Failing after 32s
2025-02-03 22:23:06 +00:00
kio
079b91a6c1 Update .forgejo/workflows/ci.yaml
Some checks failed
Build & Test / build-run (push) Failing after 5s
2025-02-03 22:21:58 +00:00
kio
aa3e1ab526 Change explicit install of Rust/Cargo to rustup
Some checks failed
Build & Test / build-run (push) Failing after 1s
2025-02-03 22:21:31 +00:00
kio
3fad4f8d6e add openssl-dev
Some checks failed
Build & Test / build-run (push) Failing after 32s
2025-02-03 22:17:16 +00:00
kio
93f8ba4226 add openssl
Some checks failed
Build & Test / build-run (push) Failing after 16s
2025-02-03 22:16:03 +00:00
kio
764529f36b add pkgconfig
Some checks failed
Build & Test / build-run (push) Failing after 16s
2025-02-03 22:15:07 +00:00
kio
b2cbd4cc5d add cargo
Some checks failed
Build & Test / build-run (push) Failing after 17s
2025-02-03 22:14:18 +00:00
kio
7aa1b483e1 add nodejs
Some checks failed
Build & Test / build-run (push) Failing after 8s
2025-02-03 22:13:43 +00:00
kio
72faa8901c fix command
Some checks failed
Build & Test / build-run (push) Failing after 8s
2025-02-03 22:13:04 +00:00
kio
da6d60f94c Use alpine instead?
Some checks failed
Build & Test / build-run (push) Failing after 8s
2025-02-03 22:12:21 +00:00
3671085acc Use circleci's image instead
Some checks failed
Build & Test / build-run (push) Failing after 27s
2025-02-03 22:36:46 +01:00
bc8d0a3f92 ok add node ig thanks actions/checkout
Some checks failed
Build & Test / build-run (push) Failing after 21s
2025-02-03 22:28:37 +01:00
93e1ac85cd Use the Rust docker image
Some checks failed
Build & Test / build-run (push) Failing after 3s
2025-02-03 22:26:07 +01:00
c3b5666bd5 Typo
Some checks failed
Build & Test / build-run (push) Failing after 17s
2025-02-03 22:18:57 +01:00
0ba211c054 Add setup rust toolchain to ci
Some checks failed
Build & Test / build-run (push) Failing after 3s
2025-02-03 22:16:29 +01:00
b3c049a8fa Rename because oops
Some checks failed
Build & Test / build-run (push) Failing after 7s
2025-02-03 22:12:40 +01:00
kio
5dde457d45 use literal instead of relative
Some checks failed
Test build & run / build-run (push) Failing after 8s
2025-02-03 21:11:06 +00:00
kio
532cd614ce Update CI
Some checks failed
Test build & run / build-run (push) Failing after 15s
2025-02-03 21:09:59 +00:00
be021c4b16 Add CI
Some checks are pending
Test build & run / build-run (push) Waiting to run
Uhhhh hoping it runs ig?
2025-02-03 21:56:13 +01:00
26a48f23a5 Sharkey has mastoapi 2025-02-03 20:14:51 +01:00
30b28b8aba This was bothering me 2025-02-03 20:07:19 +01:00
28bbdac90a Most people do not care about this 2025-02-03 20:03:32 +01:00
940fc92856 Refresh list on edit 2025-02-03 19:37:15 +01:00
93c9e5154f Only delete one 2025-02-03 19:24:34 +01:00
516473edeb Some more fixes 2025-02-03 19:03:18 +01:00
7e1416a721 Editing & saving changes & fixes 2025-02-03 19:00:15 +01:00
f451b1fbc3 Rewrite InstanceDetailsDialog
Also misc cleanup. Almost done with this
2025-02-03 17:23:45 +01:00
bfd61c2e50 Rewrite AddInstanceFlow
Now with spinner!
2025-02-03 03:52:11 +01:00
2be0658ed9 Rewrite AddInstanceDialog
Object oriented it is
2025-02-03 03:15:14 +01:00
5534bc3942 Make the CSP changes on config 2025-02-03 01:11:29 +01:00
3f9624fe91 Merge pull request 'no-more-proxy' (#3) from no-more-proxy into main
Reviewed-on: #3
2025-02-03 00:03:50 +00:00
17 changed files with 624 additions and 242 deletions

View file

@ -0,0 +1,24 @@
name: Build & Test
on: [push]
jobs:
build-run:
runs-on: docker
container:
image: rust
steps:
- name: Update package repos
run: apt update
- name: Install Node using apt
run: apt install nodejs -y
- name: Checkout repo
uses: actions/checkout@v4
- name: Setup Deno
uses: https://github.com/denoland/setup-deno@v2
with:
deno-version: v2.x
- name: Build using Cargo
run: cargo build --verbose
- name: Run unit tests
run: cargo test --verbose

View file

@ -136,7 +136,8 @@
],
"groups": [
"misskey-compliant",
"misskey-v13"
"misskey-v13",
"mastodon-compliant-api"
],
"forkOf": "misskey"
},

View file

@ -98,3 +98,17 @@ impl<'r> FromParam<'r> for KnownInstanceSoftware<'r> {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// If this test fails, known-software.json is invalid
#[test]
fn known_software_is_valid() {
assert!(!KNOWN_SOFTWARE.groups.is_empty());
assert!(!KNOWN_SOFTWARE.software.is_empty());
assert!(!KNOWN_SOFTWARE_NAMES.is_empty());
assert!(!KNOWN_SOFTWARE_NODEINFO_NAMES.is_empty());
}
}

View file

@ -1,5 +1,6 @@
// This file handles the "Add an instance" dialog
import { FormDialog, ONCE } from "./dialog.mjs";
import { findButtonOrFail, findFormOrFail, findInputOrFail } from "./dom.mjs";
export function parseHost(host: string): { host: string, secure: boolean } | null {
@ -13,43 +14,67 @@ export function parseHost(host: string): { host: string, secure: boolean } | nul
};
}
export function initializeAddInstanceDialog(
dialog: HTMLDialogElement,
callback: (
export type AddInstanceDialogData = {
host: string,
secure: boolean,
autoQueryMetadata: boolean,
) => void
): {
showAddInstanceDialog: () => void,
hideAddInstanceDialog: () => void,
} {
const showAddInstanceDialog = () => dialog.showModal();
const hideAddInstanceDialog = () => dialog.close();
};
const form = findFormOrFail(dialog, ".addInstanceForm");
const instanceHost = findInputOrFail(form, "#instanceHost");
const autoQueryMetadata = findInputOrFail(form, "#autoQueryMetadata");
const closeButton = findButtonOrFail(form, ".close");
export class AddInstanceDialog extends FormDialog {
protected instanceHost: HTMLInputElement;
protected autoQueryMetadata: HTMLInputElement;
protected closeButton: HTMLButtonElement;
instanceHost.addEventListener("input", e => {
if (parseHost(instanceHost.value) === null)
instanceHost.setCustomValidity("Invalid instance hostname or URL");
else
instanceHost.setCustomValidity("");
});
constructor(dialog: HTMLDialogElement, initializeDOM: boolean = true) {
super(dialog, findFormOrFail(dialog, ".addInstanceForm"));
form.addEventListener("submit", e => {
// A sane browser doesn't allow for submitting the form if the above validation fails
const { host, secure } = parseHost(instanceHost.value)!;
callback(host, secure, autoQueryMetadata.checked);
form.reset();
});
this.instanceHost = findInputOrFail(this.form, "#instanceHost");
this.autoQueryMetadata = findInputOrFail(this.form, "#autoQueryMetadata");
this.closeButton = findButtonOrFail(this.form, ".close");
closeButton.addEventListener("click", e => hideAddInstanceDialog());
if (initializeDOM) this.initializeDOM();
}
protected override initializeDOM() {
super.initializeDOM();
this.instanceHost.addEventListener("input", e => this.#getDataIfValid());
this.closeButton.addEventListener("click", e => this.close());
}
#getDataIfValid(): AddInstanceDialogData | null {
const parsedHost = parseHost(this.instanceHost.value);
if (parsedHost === null) {
this.instanceHost.setCustomValidity("Invalid instance hostname or URL");
return null;
}
this.instanceHost.setCustomValidity("");
return {
showAddInstanceDialog,
hideAddInstanceDialog
host: parsedHost.host,
secure: parsedHost.secure,
autoQueryMetadata: this.autoQueryMetadata.checked
};
}
#handleSubmit(resolve: (data: AddInstanceDialogData) => void) {
this.form.addEventListener("submit", e => {
const data = this.#getDataIfValid();
if (data === null) {
// Prevent the user from submitting the form if it's invalid and let them try again
e.preventDefault();
this.#handleSubmit(resolve);
return;
}
resolve(data);
this.close();
}, ONCE);
}
async present(): Promise<AddInstanceDialogData> {
return new Promise((resolve, reject) => {
this.cancelOnceClosed(reject);
this.#handleSubmit(resolve);
this.open();
});
}
}

View file

@ -1,69 +1,73 @@
import { initializeAddInstanceDialog } from "./add_an_instance.mjs";
import { initializeInstanceDetailsDialog } from "./confirm_instance_details.mjs";
import { AddInstanceDialog } from "./add_an_instance.mjs";
import { dialogDetailsToInstance, InstanceDetailsDialog, InstanceDetailsDialogData } from "./confirm_instance_details.mjs";
import { Dialog } from "./dialog.mjs";
import storageManager, { Instance } from "./storage_manager.mjs";
export function initializeAddInstanceFlow(
detailsDialog: HTMLDialogElement,
addDialog: HTMLDialogElement
): {
showAddInstanceDialog: () => void,
hideAddInstanceDialog: () => void
} {
const instanceDetailsDialogCallback = (
name: string,
host: string,
hostSecure: boolean,
software: string,
icon: string | null
) => {
const instance: Instance = {
name,
origin: `http${hostSecure ? "s" : ""}://${host}`,
software,
iconURL: icon ?? undefined
};
storageManager.storage.instances.push(instance);
storageManager.save();
console.log("Successfully added new instance:", instance);
};
export class AddInstanceFlow {
addDialog: AddInstanceDialog;
spinnerDialog: Dialog;
detailsDialog: InstanceDetailsDialog;
constructor(
addDialog: AddInstanceDialog | HTMLDialogElement,
spinnerDialog: HTMLDialogElement,
detailsDialog: InstanceDetailsDialog | HTMLDialogElement,
) {
if (addDialog instanceof AddInstanceDialog)
this.addDialog = addDialog;
else
this.addDialog = new AddInstanceDialog(addDialog, true);
this.spinnerDialog = new Dialog(spinnerDialog);
if (detailsDialog instanceof InstanceDetailsDialog)
this.detailsDialog = detailsDialog;
else
this.detailsDialog = new InstanceDetailsDialog(detailsDialog, true);
}
async start(autoSave: boolean) {
const {
showInstanceDetailsDialog,
hideInstanceDetailsDialog,
populateInstanceDetailsDialog
} = initializeInstanceDetailsDialog(detailsDialog, instanceDetailsDialogCallback);
autoQueryMetadata,
host,
secure,
} = await this.addDialog.present();
const detailsDialogData: InstanceDetailsDialogData = {
name: host,
host,
hostSecure: secure,
software: "",
iconURL: null,
preferredFor: []
};
const addInstanceDialogCallback = async (
host: string,
secure: boolean,
autoQueryMetadata: boolean,
) => {
try {
if (!autoQueryMetadata) throw new Error("Don't");
if (!autoQueryMetadata) throw null; // Skip to catch block
this.spinnerDialog.open();
const { name, software, iconURL } =
await fetch(`/api/instance_info/${secure}/${encodeURIComponent(host)}`)
.then(r => r.json());
if (
typeof name !== "string"
|| typeof software !== "string"
|| !(typeof iconURL === "string" || iconURL === null)
|| !(typeof iconURL === "string" || iconURL === null) // I guess TS is too stupid to understand this?
)
throw new Error("Invalid API response");
populateInstanceDetailsDialog(name, host, secure, software, iconURL as string | null);
} catch {
populateInstanceDetailsDialog(host, host, secure, "", null);
} finally {
showInstanceDetailsDialog();
}
}
const {
showAddInstanceDialog,
hideAddInstanceDialog
} = initializeAddInstanceDialog(addDialog, addInstanceDialogCallback);
detailsDialogData.name = name;
detailsDialogData.software = software;
detailsDialogData.iconURL = iconURL as string | null;
} catch { }
this.spinnerDialog.close();
return {
showAddInstanceDialog,
hideAddInstanceDialog
};
const finalData = await this.detailsDialog.present(detailsDialogData);
const instance = dialogDetailsToInstance(finalData, {});
storageManager.storage.instances.push(instance);
if (autoSave) storageManager.save();
console.log("Successfully added new instance:", instance);
}
}

View file

@ -6,13 +6,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FeDirect</title>
<link rel="stylesheet" href="/static/main.css">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src *;">
</head>
<body>
<script type="module">
Object.assign(globalThis, await import("/static/config.mjs"));
getMainDialog().show(); // Don't show until the page is ready
</script>
<script type="module" src="/static/config.mjs"></script>
<div class="flex-vcenter">
<dialog id="mainDialog" class="half-width half-height">
<header class="separator-bottom margin-large-bottom">
@ -27,7 +25,7 @@
<center class="half-width">
<ol id="instanceList" class="align-start wfit-content"></ol>
<br>
<button onclick="showAddInstanceDialog()">Add an instance</button>
<button id="startAddInstanceFlow">Add an instance</button>
</center>
</div>
<div class="half-width align-self-start">
@ -52,9 +50,8 @@
<br>
<input id="autoQueryMetadata" type="checkbox" name="autoQueryMetadata" checked />
<label for="autoQueryMetadata">
<abbr title="This uses FeDirect's API to automatically try to detect instance metadata, such as the name, software, and an icon.
We do this on the backend to avoid CORS problems.
We do not track or save any requests or data.">
<abbr
title="This uses FeDirect's API to automatically try to detect instance metadata, such as the name, software, and an icon.">
Automatically query metadata
</abbr>
</label>
@ -104,14 +101,15 @@ Unchecking this is not recommended, and this option only exists for exceptional
<br>
<label for="defaultsList">Default option for:</label><br>
<select id="defaultsList" class="full-width" multiple>
<option value="" disabled>(None, use the "Redirect always" button to set!)</option>
<option id="noDefaults" value="" disabled>(None, use the "Redirect always" button to set!)</option>
</select>
<button id="removeDefaults" disabled>Remove</button>
<button id="removeDefaults" type="button" disabled>Remove</button>
<br><br>
<button type="submit">OK</button>
<button type="reset" class="close">Cancel</button>
</form>
</dialog>
<dialog id="spinner"><span class="spinner"></span></dialog>
</body>
</html>

View file

@ -1,22 +1,34 @@
import { parseHost } from "./add_an_instance.mjs";
import { initializeAddInstanceFlow } from "./add_instance_flow.mjs";
import { initializeInstanceDetailsDialog } from "./confirm_instance_details.mjs";
import { AddInstanceFlow } from "./add_instance_flow.mjs";
import { dialogDetailsFromInstance, dialogDetailsToInstance, InstanceDetailsDialog } from "./confirm_instance_details.mjs";
import { findButtonOrFail, findDialogOrFail, findOlOrFail } from "./dom.mjs";
import storageManager from "./storage_manager.mjs";
import storageManager, { Instance } from "./storage_manager.mjs";
let reordering = false;
let unsaved = false;
// Dragging code is a heavily modified version of https://stackoverflow.com/a/28962290
let elementBeingDragged: HTMLLIElement | undefined;
const detailsDialog = findDialogOrFail(document.body, "#instanceDetails");
const mainDialog = findDialogOrFail(document.body, "#mainDialog");
const startAddInstanceFlowButton = findButtonOrFail(document.body, "#startAddInstanceFlow");
const addDialog = findDialogOrFail(document.body, "#addInstance");
const spinnerDialog = findDialogOrFail(document.body, "#spinner");
const detailsDialog = findDialogOrFail(document.body, "#instanceDetails");
const instanceList = findOlOrFail(document.body, "#instanceList");
const saveButton = findButtonOrFail(document.body, "#save");
const reorderButton = findButtonOrFail(document.body, "#reorder");
const resetButton = findButtonOrFail(document.body, "#reset");
saveButton.addEventListener("click", e => {
storageManager.save();
let instanceDetailsDialog = new InstanceDetailsDialog(detailsDialog, true);
let addInstanceFlow = new AddInstanceFlow(addDialog, spinnerDialog, instanceDetailsDialog);
startAddInstanceFlowButton.addEventListener("click", e => {
addInstanceFlow.start(false).then(_ => {
updateInstanceList();
unsavedChanges();
});
});
saveButton.addEventListener("click", e => saveChanges());
reorderButton.addEventListener("click", () => {
reordering = !reordering;
@ -25,22 +37,47 @@ reorderButton.addEventListener("click", () => {
reorderButton.innerText = reordering ? "Finish reordering" : "Reorder";
});
export const getMainDialog = () => findDialogOrFail(document.body, "#mainDialog");
const {
showInstanceDetailsDialog,
hideInstanceDetailsDialog,
populateInstanceDetailsDialog,
} = initializeInstanceDetailsDialog(detailsDialog, () => { });
export const {
showAddInstanceDialog,
hideAddInstanceDialog
} = initializeAddInstanceFlow(detailsDialog, addDialog);
resetButton.addEventListener("click", e => {
storageManager.reset();
updateInstanceList();
unsavedChanges();
});
updateInstanceList();
storageManager.addSaveCallback(updateInstanceList);
mainDialog.show();
function saveChanges() {
storageManager.save();
unsaved = false;
saveButton.classList.remove("pulse-red");
}
function unsavedChanges() {
if (!unsaved) {
unsaved = true;
saveButton.classList.add("pulse-red");
}
}
async function editInstance(instance: Instance) {
const data = dialogDetailsFromInstance(instance);
const newData = await instanceDetailsDialog.present(data);
dialogDetailsToInstance(newData, instance);
updateInstanceList();
unsavedChanges();
}
function deleteInstance(instance: Instance) {
storageManager.storage.instances.splice(
storageManager.storage.instances.indexOf(instance),
1
);
updateInstanceList();
unsavedChanges();
}
function updateInstanceList() {
instanceList.replaceChildren(); // Erase all child nodes
instanceList.style.listStyleType = reordering ? "\"≡ \"" : "disc";
@ -86,26 +123,11 @@ function updateInstanceList() {
const editLink = document.createElement("a");
editLink.innerText = `Edit`;
editLink.href = "#";
editLink.addEventListener("click", e => {
const host = parseHost(instance.origin)!;
populateInstanceDetailsDialog(
instance.name,
host.host,
host.secure,
instance.software,
instance.iconURL ?? null
);
showInstanceDetailsDialog();
});
editLink.addEventListener("click", e => editInstance(instance));
const deleteLink = document.createElement("a");
deleteLink.innerText = `Delete`;
deleteLink.href = "#";
deleteLink.addEventListener("click", e => {
storageManager.storage.instances.splice(
storageManager.storage.instances.indexOf(instance)
);
updateInstanceList();
});
deleteLink.addEventListener("click", e => deleteInstance(instance));
label.append(editLink, " ", deleteLink);
}
li.appendChild(label);
@ -130,4 +152,5 @@ function applyReordering() {
indices.push(parseInt(option));
}
storageManager.storage.instances = indices.map(i => storageManager.storage.instances[i]);
unsavedChanges();
}

View file

@ -1,81 +1,184 @@
// This file handles the "Confirm instance details" dialog
import { findButtonOrFail, findFormOrFail, findImageOrFail, findInputOrFail, findSelectOrFail } from "./dom.mjs";
import { parseHost } from "./add_an_instance.mjs";
import { FormDialog, ONCE } from "./dialog.mjs";
import { findButtonOrFail, findFormOrFail, findImageOrFail, findInputOrFail, findOptionOrFail, findSelectOrFail } from "./dom.mjs";
import knownSoftware from "./known_software.mjs";
import { Instance } from "./storage_manager.mjs";
const blankImage = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==";
export function initializeInstanceDetailsDialog(
dialog: HTMLDialogElement,
callback: (
instanceName: string,
instanceHost: string,
instanceHostSecure: boolean,
instanceSoftware: string,
instanceIcon: string | null
) => void
): {
showInstanceDetailsDialog: () => void,
hideInstanceDetailsDialog: () => void,
populateInstanceDetailsDialog: (
instanceNameValue: string,
instanceHostValue: string,
instanceHostSecureValue: boolean,
instanceSoftwareValue: string,
instanceIconValue: string | null
) => void
} {
const showInstanceDetailsDialog = () => dialog.showModal();
const hideInstanceDetailsDialog = () => dialog.close();
export function mergeHost(host: string, secure: boolean): string {
return `http${secure ? "s" : ""}://${host}`;
}
const form = findFormOrFail(dialog, ".instanceDetailsForm");
const instanceName = findInputOrFail(form, "#instanceName");
const instanceHost = findInputOrFail(form, "#instanceHost");
const instanceHostSecure = findInputOrFail(form, "#instanceHostSecure");
const instanceSoftware = findSelectOrFail(form, "#instanceSoftware");
const instanceIcon = findImageOrFail(form, "#instanceIcon");
const closeButton = findButtonOrFail(form, ".close");
export type InstanceDetailsDialogData = {
name: string,
host: string,
hostSecure: boolean,
software: string,
iconURL: string | null,
preferredFor: string[],
};
export function dialogDetailsFromInstance(instance: Instance): InstanceDetailsDialogData {
const host = parseHost(instance.origin)!;
return {
name: instance.name,
host: host.host,
hostSecure: host.secure,
software: instance.software,
iconURL: instance.iconURL ?? null,
preferredFor: instance.preferredFor,
};
}
export function dialogDetailsToInstance(data: InstanceDetailsDialogData, instance: Partial<Instance>): Instance {
instance.name = data.name;
instance.origin = mergeHost(data.host, data.hostSecure);
instance.software = data.software;
instance.iconURL = data.iconURL ?? undefined;
instance.preferredFor = data.preferredFor;
return instance as Instance;
}
export class InstanceDetailsDialog extends FormDialog {
protected instanceName: HTMLInputElement;
protected instanceHost: HTMLInputElement;
protected instanceHostSecure: HTMLInputElement;
protected instanceSoftware: HTMLSelectElement;
protected instanceIcon: HTMLImageElement;
protected closeButton: HTMLButtonElement;
protected defaultsList?: {
list: HTMLSelectElement,
removeButton: HTMLButtonElement,
noDefaultsOption: HTMLOptionElement,
};
constructor(dialog: HTMLDialogElement, initializeDOM: boolean = true) {
super(dialog, findFormOrFail(dialog, ".instanceDetailsForm"));
this.instanceName = findInputOrFail(this.form, "#instanceName");
this.instanceHost = findInputOrFail(this.form, "#instanceHost");
this.instanceHostSecure = findInputOrFail(this.form, "#instanceHostSecure");
this.instanceSoftware = findSelectOrFail(this.form, "#instanceSoftware");
this.instanceIcon = findImageOrFail(this.form, "#instanceIcon");
this.closeButton = findButtonOrFail(this.form, ".close");
try {
this.defaultsList = {
list: findSelectOrFail(this.form, "#defaultsList"),
removeButton: findButtonOrFail(this.form, "#removeDefaults"),
noDefaultsOption: findOptionOrFail(this.form, "#noDefaults"),
};
} catch {
this.defaultsList = undefined;
}
if (initializeDOM) this.initializeDOM();
}
protected override initializeDOM() {
super.initializeDOM();
for (const [name, software] of Object.entries(knownSoftware.software)) {
const option = new Option(software.name, name);
instanceSoftware.appendChild(option);
this.instanceSoftware.appendChild(option);
}
instanceIcon.src = blankImage;
this.instanceIcon.src = blankImage;
const populateInstanceDetailsDialog = (
instanceNameValue: string,
instanceHostValue: string,
instanceHostSecureValue: boolean,
instanceSoftwareValue: string,
instanceIconValue: string | null
) => {
instanceName.value = instanceNameValue;
instanceHost.value = instanceHostValue;
instanceHostSecure.checked = instanceHostSecureValue;
instanceSoftware.value = instanceSoftwareValue;
instanceIcon.src = instanceIconValue ?? blankImage;
};
this.closeButton.addEventListener("click", e => this.close());
form.addEventListener("submit", e => {
callback(
instanceName.value,
instanceHost.value,
instanceHostSecure.checked,
instanceSoftware.value,
instanceIcon.src
);
form.reset();
});
closeButton.addEventListener("click", e => {
instanceIcon.src = blankImage;
hideInstanceDetailsDialog();
});
return {
showInstanceDetailsDialog,
hideInstanceDetailsDialog,
populateInstanceDetailsDialog
};
if (this.defaultsList) {
this.defaultsList.list.addEventListener("change", e => this.#handleListSelectionChange());
this.defaultsList.removeButton.addEventListener("click", e => this.#removeSelectedListOptions());
}
}
#getRemainingListOptions(): string[] {
if (!this.defaultsList) return [];
const items: string[] = [];
for (const option of this.defaultsList.list.options) {
if (option == this.defaultsList.noDefaultsOption) continue;
items.push(option.value);
}
return items;
}
#removeSelectedListOptions() {
if (!this.defaultsList) return;
// Copy using spread because this breaks when the list changes mid-iteration
for (const option of [...this.defaultsList.list.selectedOptions]) {
option.remove();
}
this.#handleListChange();
}
#populateDefaultsList(items: string[]) {
if (!this.defaultsList) return;
while (this.defaultsList.list.children.length > 1)
this.defaultsList.list.removeChild(this.defaultsList.list.lastChild!);
for (const item of items) {
const option = document.createElement("option");
option.value = item;
option.innerText = knownSoftware.software[item]?.name ?? knownSoftware.groups[item]?.name ?? item;
this.defaultsList.list.appendChild(option);
}
this.#handleListChange();
}
#handleListChange() {
this.#handleListEmpty();
this.#handleListSelectionChange();
}
#handleListSelectionChange() {
if (!this.defaultsList) return;
this.defaultsList.removeButton.disabled = this.defaultsList.list.selectedOptions.length === 0;
}
#handleListEmpty() {
if (!this.defaultsList) return;
if (this.defaultsList.list.children.length == 1) {
this.defaultsList.noDefaultsOption.removeAttribute("hidden");
} else {
this.defaultsList.noDefaultsOption.setAttribute("hidden", "");
}
}
#handleSubmit(data: InstanceDetailsDialogData, resolve: (data: InstanceDetailsDialogData) => void) {
this.form.addEventListener("submit", e => {
data.name = this.instanceName.value;
data.host = this.instanceHost.value;
data.hostSecure = this.instanceHostSecure.checked;
data.software = this.instanceSoftware.value;
data.preferredFor = this.#getRemainingListOptions();
resolve(data);
this.close();
}, ONCE);
}
async present(data: InstanceDetailsDialogData): Promise<InstanceDetailsDialogData> {
this.instanceName.value = data.name;
this.instanceHost.value = data.host;
this.instanceHostSecure.checked = data.hostSecure;
this.instanceSoftware.value = data.software;
this.instanceIcon.src = data.iconURL ?? blankImage;
this.#populateDefaultsList(data.preferredFor);
return new Promise((resolve, reject) => {
this.cancelOnceClosed(reject);
this.#handleSubmit(data, resolve);
this.open();
});
}
}

View file

@ -29,7 +29,7 @@
<p id="no-instance">You currently don't have any instances. You should add one!</p>
<form id="instanceSelectForm" class="align-start wfit-content"></form>
<br>
<button id="showAddInstanceDialog">Add an instance</button>
<button id="startAddInstanceFlow">Add an instance</button>
</center>
</div>
<div class="half-width align-self-start">
@ -54,9 +54,8 @@
<br>
<input id="autoQueryMetadata" type="checkbox" name="autoQueryMetadata" checked />
<label for="autoQueryMetadata">
<abbr title="This uses FeDirect's API to automatically try to detect instance metadata, such as the name, software, and an icon.
We do this on the backend to avoid CORS problems.
We do not track or save any requests or data.">
<abbr
title="This uses FeDirect's API to automatically try to detect instance metadata, such as the name, software, and an icon.">
Automatically query metadata
</abbr>
</label>
@ -108,6 +107,7 @@ Unchecking this is not recommended, and this option only exists for exceptional
<button type="reset" class="close">Cancel</button>
</form>
</dialog>
<dialog id="spinner"><span class="spinner"></span></dialog>
</body>
</html>

View file

@ -1,20 +1,37 @@
import { initializeAddInstanceFlow } from "./add_instance_flow.mjs";
import { AddInstanceFlow } from "./add_instance_flow.mjs";
import { findButtonOrFail, findDialogOrFail, findFormOrFail, findInputOrFail, findParagraphOrFail, findPreOrFail } from "./dom.mjs";
import knownSoftware from "./known_software.mjs";
import storageManager from "./storage_manager.mjs";
const radioButtonName = "instanceSelect";
const RADIO_BUTTON_NAME = "instanceSelect";
let addInstanceFlow: AddInstanceFlow | undefined;
const mainDialog = findDialogOrFail(document.body, "#mainDialog");
const showAddInstanceDialogButton = findButtonOrFail(document.body, "#showAddInstanceDialog");
const detailsDialog = findDialogOrFail(document.body, "#instanceDetails");
const startAddInstanceFlowButton = findButtonOrFail(document.body, "#startAddInstanceFlow");
const addDialog = findDialogOrFail(document.body, "#addInstance");
const spinnerDialog = findDialogOrFail(document.body, "#spinner");
const detailsDialog = findDialogOrFail(document.body, "#instanceDetails");
const instanceSelectForm = findFormOrFail(document.body, "#instanceSelectForm");
const redirectButton = findButtonOrFail(document.body, "#redirect");
const redirectAlwaysButton = findButtonOrFail(document.body, "#redirectAlways");
const pathText = findPreOrFail(document.body, "#path");
showAddInstanceDialogButton.addEventListener("click", e => showAddInstanceDialog());
// Don't bother initializing if we're performing autoredirect
if (!autoRedirect()) {
createInstanceSelectOptions();
storageManager.addSaveCallback(createInstanceSelectOptions);
updateNoInstanceHint();
storageManager.addSaveCallback(updateNoInstanceHint);
pathText.innerText = getTargetPath();
addInstanceFlow = new AddInstanceFlow(addDialog, spinnerDialog, detailsDialog);
mainDialog.show();
};
startAddInstanceFlowButton.addEventListener("click", e => addInstanceFlow?.start(true));
redirectButton.addEventListener("click", e => {
// Can be assumed to not fail because the button is disabled if there are no options and the first one is selected by default
@ -28,26 +45,6 @@ redirectAlwaysButton.addEventListener("click", e => {
redirect(option);
});
let showAddInstanceDialog = () => { };
let hideAddInstanceDialog = () => { };
// Don't bother initializing if we're performing autoredirect
if (!autoRedirect()) {
createInstanceSelectOptions();
storageManager.addSaveCallback(createInstanceSelectOptions);
updateNoInstanceHint();
storageManager.addSaveCallback(updateNoInstanceHint);
pathText.innerText = getTargetPath();
({
showAddInstanceDialog,
hideAddInstanceDialog
} = initializeAddInstanceFlow(detailsDialog, addDialog));
mainDialog.show();
};
function updateNoInstanceHint() {
findParagraphOrFail(document.body, "#no-instance").style.display =
storageManager.storage.instances.length > 0
@ -64,7 +61,7 @@ function createInstanceSelectOptions() {
radio.id = instance.origin;
radio.value = instance.origin;
radio.type = "radio";
radio.name = radioButtonName;
radio.name = RADIO_BUTTON_NAME;
const label = document.createElement("label");
label.htmlFor = instance.origin;
label.innerText = instance.name + " ";
@ -111,7 +108,7 @@ function getTargetPath(): string {
function getSelectedOption(): string | null {
try {
return findInputOrFail(instanceSelectForm, `input[name="${radioButtonName}"]:checked`).value;
return findInputOrFail(instanceSelectForm, `input[name="${RADIO_BUTTON_NAME}"]:checked`).value;
} catch {
return null;
}
@ -130,7 +127,6 @@ function autoRedirect(): boolean {
function setAutoRedirect(option: string) {
const instance = storageManager.storage.instances.find(e => e.origin === option);
if (!instance) throw new Error("Invalid argument");
instance.preferredFor ??= [];
instance.preferredFor.push(getTargetSoftwareOrGroup());
storageManager.save();
}

37
static/data_migration.mts Normal file
View file

@ -0,0 +1,37 @@
import { LocalStorage } from "./storage_manager.mjs"
type InstanceV0 = {
name: string,
origin: string,
software: string,
iconURL?: string,
preferredFor?: string[],
}
type LocalStorageV0 = {
version: undefined,
instances: InstanceV0[],
}
type LocalStorageV1 = LocalStorage;
function migrate0to1(s: LocalStorageV0): LocalStorageV1 {
return {
version: 1,
instances: s.instances.map(i => ({
preferredFor: i.preferredFor ?? [],
...i
}))
};
}
type AnyLocalStorage = LocalStorageV0 | LocalStorageV1;
export default function migrate(storage: AnyLocalStorage): LocalStorage {
switch (storage.version) {
case undefined:
storage = migrate0to1(storage);
case 1:
default:
return storage;
}
}

46
static/dialog.mts Normal file
View file

@ -0,0 +1,46 @@
export const ONCE = { once: true };
export const CANCELLED = Symbol("Cancelled");
export class Dialog {
protected dialog: HTMLDialogElement;
constructor(dialog: HTMLDialogElement) {
this.dialog = dialog;
}
/**
* A function that should only be called once that has permanent effects on the DOM
*/
protected initializeDOM() { }
open() {
this.dialog.showModal();
}
close() {
this.dialog.close();
}
protected cancelOnceClosed(reject: (reason?: any) => void) {
this.dialog.addEventListener("close", e => reject(CANCELLED), ONCE);
}
};
export class FormDialog extends Dialog {
protected form: HTMLFormElement;
constructor(dialog: HTMLDialogElement, form: HTMLFormElement) {
super(dialog);
this.form = form;
}
protected override initializeDOM() {
super.initializeDOM();
this.dialog.addEventListener("close", e => this.reset());
}
reset() {
this.form.reset();
}
}

View file

@ -1,6 +1,13 @@
// I would've LOVED to use generics for this but unfortunately that's not possible.
// Type safety, but at what cost... >~< thanks TypeScript
export function findOptionOrFail(on: Element, selector: string): HTMLOptionElement {
const element = on.querySelector(selector);
if (!(element instanceof HTMLOptionElement))
throw new Error(`${selector} isn't an option`);
return element;
}
export function findOlOrFail(on: Element, selector: string): HTMLOListElement {
const element = on.querySelector(selector);
if (!(element instanceof HTMLOListElement))

View file

@ -1,3 +1,5 @@
@import url("/static/spinner.css");
:root {
--red: #cb0b0b;
--blue: #2081c3;
@ -183,3 +185,17 @@ abbr[title] {
.buttonPanel>* {
margin-top: min(var(--xl), 6vh);
}
.pulse-red {
animation: 1s ease-in-out 0s infinite alternate both running pulse-red-anim;
}
@keyframes pulse-red-anim {
0% {
box-shadow: 0px 0px 0px var(--red);
}
100% {
box-shadow: 0px 0px 20px var(--red);
}
}

78
static/spinner.css Normal file
View file

@ -0,0 +1,78 @@
/* Sourced and modified from https://cssloaders.github.io/ */
.spinner {
display: block;
animation: rotate 1s infinite;
height: 50px;
width: 50px;
}
.spinner:before,
.spinner:after {
border-radius: 50%;
content: "";
display: block;
height: 20px;
width: 20px;
}
.spinner:before {
animation: ball1 1s infinite;
background-color: var(--red);
box-shadow: 30px 0 0 var(--blue);
margin-bottom: 10px;
}
.spinner:after {
animation: ball2 1s infinite;
background-color: var(--blue);
box-shadow: 30px 0 0 var(--red);
}
@keyframes rotate {
0% {
transform: rotate(0deg) scale(0.8)
}
50% {
transform: rotate(360deg) scale(1.2)
}
100% {
transform: rotate(720deg) scale(0.8)
}
}
@keyframes ball1 {
0% {
box-shadow: 30px 0 0 var(--blue);
}
50% {
box-shadow: 0 0 0 var(--blue);
margin-bottom: 0;
transform: translate(15px, 15px);
}
100% {
box-shadow: 30px 0 0 var(--blue);
margin-bottom: 10px;
}
}
@keyframes ball2 {
0% {
box-shadow: 30px 0 0 var(--red);
}
50% {
box-shadow: 0 0 0 var(--red);
margin-top: -20px;
transform: translate(15px, 15px);
}
100% {
box-shadow: 30px 0 0 var(--red);
margin-top: 0;
}
}

View file

@ -1,3 +1,5 @@
import migrate from "./data_migration.mjs";
export type Instance = {
/**
* The instance's (nick)name
@ -21,18 +23,19 @@ export type Instance = {
software: string,
/**
* The instance's icon URL
*
* Make sure to sanitize this! Could lead to XSS
* @example undefined
* @example "https://void.lgbt/favicon.png"
*/
iconURL?: string,
/**
* The list of software names and groups the user prefers to autoredirect to this instance
* @example ["sharkey", "misskey-compliant"]
*/
preferredFor?: string[],
preferredFor: string[],
}
type LocalStorage = {
export type LocalStorage = {
version: number,
instances: Instance[],
}
@ -46,12 +49,14 @@ export default new class StorageManager {
default(): LocalStorage {
return {
version: 1,
instances: []
}
}
load() {
this.storage = JSON.parse(window.localStorage.getItem("storage") ?? "null") ?? this.default();
const data = JSON.parse(window.localStorage.getItem("storage") ?? "null") ?? this.default();
this.storage = migrate(data);
}
save() {
@ -62,4 +67,8 @@ export default new class StorageManager {
addSaveCallback(callback: () => void) {
this.saveCallbacks.push(callback);
}
reset() {
this.storage = this.default();
}
}();

View file

@ -4,6 +4,7 @@
"allowJs": true,
"checkJs": true,
"strictNullChecks": true,
"noImplicitOverride": true,
},
"include": [
"static/**.mts",