Compare commits

..

No commits in common. "main" and "config-page" have entirely different histories.

24 changed files with 315 additions and 1028 deletions

View file

@ -1,24 +0,0 @@
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

328
Cargo.lock generated
View file

@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
version = 3
[[package]]
name = "addr2line"
@ -185,29 +185,6 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cssparser"
version = "0.34.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c66d1cd8ed61bf80b38432613a7a2f09401ab8d0501110655f8b341484a3e3"
dependencies = [
"cssparser-macros",
"dtoa-short",
"itoa",
"phf",
"smallvec",
]
[[package]]
name = "cssparser-macros"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
dependencies = [
"quote",
"syn",
]
[[package]]
name = "deranged"
version = "0.3.11"
@ -217,17 +194,6 @@ dependencies = [
"powerfmt",
]
[[package]]
name = "derive_more"
version = "0.99.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f33878137e4dafd7fa914ad4e259e18a4e8e532b9617a2d0150262bf53abfce"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "devise"
version = "0.4.2"
@ -272,27 +238,6 @@ dependencies = [
"syn",
]
[[package]]
name = "dtoa"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcbb2bf8e87535c23f7a8a321e364ce21462d0ff10cb6407820e8e96dfff6653"
[[package]]
name = "dtoa-short"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
dependencies = [
"dtoa",
]
[[package]]
name = "ego-tree"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2972feb8dffe7bc8c5463b1dacda1b0dfbed3710e50f977d965429692d74cd8"
[[package]]
name = "either"
version = "1.13.0"
@ -330,32 +275,16 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "favicon-scraper"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e2d1e19588bba8f650edac55fc88e1f2db227d18920d9e71322b46530f04a2"
dependencies = [
"futures",
"imagesize",
"reqwest",
"scraper",
"serde",
"url",
]
[[package]]
name = "fedirect"
version = "0.1.0"
dependencies = [
"bytes",
"favicon-scraper",
"reqwest",
"rocket",
"semver",
"serde",
"serde_json",
"tokio",
"url",
]
@ -403,16 +332,6 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "futf"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
dependencies = [
"mac",
"new_debug_unreachable",
]
[[package]]
name = "futures"
version = "0.3.31"
@ -490,15 +409,6 @@ dependencies = [
"slab",
]
[[package]]
name = "fxhash"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
dependencies = [
"byteorder",
]
[[package]]
name = "generator"
version = "0.7.5"
@ -512,15 +422,6 @@ dependencies = [
"windows",
]
[[package]]
name = "getopts"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5"
dependencies = [
"unicode-width",
]
[[package]]
name = "getrandom"
version = "0.2.15"
@ -600,20 +501,6 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc"
[[package]]
name = "html5ever"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e15626aaf9c351bc696217cbe29cb9b5e86c43f8a46b5e2f5c6c5cf7cb904ce"
dependencies = [
"log",
"mac",
"markup5ever",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "http"
version = "0.2.12"
@ -917,12 +804,6 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "imagesize"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285"
[[package]]
name = "indexmap"
version = "2.7.0"
@ -1028,26 +909,6 @@ dependencies = [
"tracing-subscriber",
]
[[package]]
name = "mac"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "markup5ever"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82c88c6129bd24319e62a0359cb6b958fa7e8be6e19bb1663bc396b90883aca5"
dependencies = [
"log",
"phf",
"phf_codegen",
"string_cache",
"string_cache_codegen",
"tendril",
]
[[package]]
name = "matchers"
version = "0.1.0"
@ -1125,12 +986,6 @@ dependencies = [
"tempfile",
]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nu-ansi-term"
version = "0.46.0"
@ -1274,77 +1129,6 @@ version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_macros",
"phf_shared 0.11.3",
]
[[package]]
name = "phf_codegen"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
dependencies = [
"phf_generator 0.11.3",
"phf_shared 0.11.3",
]
[[package]]
name = "phf_generator"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6"
dependencies = [
"phf_shared 0.10.0",
"rand",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared 0.11.3",
"rand",
]
[[package]]
name = "phf_macros"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
"phf_generator 0.11.3",
"phf_shared 0.11.3",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "phf_shared"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096"
dependencies = [
"siphasher 0.3.11",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher 1.0.1",
]
[[package]]
name = "pin-project-lite"
version = "0.2.16"
@ -1378,12 +1162,6 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]]
name = "proc-macro2"
version = "1.0.93"
@ -1752,21 +1530,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "scraper"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc3d051b884f40e309de6c149734eab57aa8cc1347992710dc80bcc1c2194c15"
dependencies = [
"cssparser",
"ego-tree",
"getopts",
"html5ever",
"precomputed-hash",
"selectors",
"tendril",
]
[[package]]
name = "security-framework"
version = "2.11.1"
@ -1790,25 +1553,6 @@ dependencies = [
"libc",
]
[[package]]
name = "selectors"
version = "0.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd568a4c9bb598e291a08244a5c1f5a8a6650bee243b5b0f8dbb3d9cc1d87fe8"
dependencies = [
"bitflags",
"cssparser",
"derive_more",
"fxhash",
"log",
"new_debug_unreachable",
"phf",
"phf_codegen",
"precomputed-hash",
"servo_arc",
"smallvec",
]
[[package]]
name = "semver"
version = "1.0.24"
@ -1868,15 +1612,6 @@ dependencies = [
"serde",
]
[[package]]
name = "servo_arc"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae65c4249478a2647db249fb43e23cec56a2c8974a427e7bd8cb5a1d0964921a"
dependencies = [
"stable_deref_trait",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
@ -1901,18 +1636,6 @@ dependencies = [
"libc",
]
[[package]]
name = "siphasher"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
[[package]]
name = "siphasher"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
[[package]]
name = "slab"
version = "0.4.9"
@ -1968,32 +1691,6 @@ dependencies = [
"loom",
]
[[package]]
name = "string_cache"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f91138e76242f575eb1d3b38b4f1362f10d3a43f47d182a5b359af488a02293b"
dependencies = [
"new_debug_unreachable",
"once_cell",
"parking_lot",
"phf_shared 0.10.0",
"precomputed-hash",
"serde",
]
[[package]]
name = "string_cache_codegen"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bb30289b722be4ff74a408c3cc27edeaad656e06cb1fe8fa9231fa59c728988"
dependencies = [
"phf_generator 0.10.0",
"phf_shared 0.10.0",
"proc-macro2",
"quote",
]
[[package]]
name = "subtle"
version = "2.6.1"
@ -2066,17 +1763,6 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "tendril"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
dependencies = [
"futf",
"mac",
"utf-8",
]
[[package]]
name = "thread_local"
version = "1.1.8"
@ -2353,12 +2039,6 @@ version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83"
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-xid"
version = "0.2.6"
@ -2382,12 +2062,6 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf16_iter"
version = "1.0.5"

View file

@ -5,11 +5,9 @@ edition = "2021"
[dependencies]
bytes = "1.9.0"
favicon-scraper = "0.3.1"
reqwest = { version = "0.12.12", features = ["stream"] }
rocket = { version = "0.5.1", features = ["json"] }
semver = "1.0.24"
serde = { version = "1.0.217", features = ["derive"] }
serde_json = "1.0.135"
tokio = { version = "1.43.0", features = ["process"] }
url = "2.5.4"

View file

@ -2,13 +2,11 @@
Fedi links that open on your preferred instance!
## Building and Running
## Building
To compile TypeScript, the build script assumes Deno is installed.
1. [Install Rust via rustup](https://rustup.rs/)
2. [Install Deno](https://deno.com/)
3. Use Cargo to build the project:
When you have Deno and Rust installed, simply use Cargo to build the project
```sh
# For example, to build for release, you can do

View file

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

View file

@ -1,2 +0,0 @@
[toolchain]
channel = "nightly"

View file

@ -1,14 +1,9 @@
use std::net::ToSocketAddrs;
use favicon_scraper::{Icon, IconKind};
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
use url::Url;
use crate::known_software::KNOWN_SOFTWARE_NODEINFO_NAMES;
const MINIMUM_ICON_SIZE: usize = 16;
#[derive(Serialize)]
pub struct InstanceInfo {
name: String,
@ -17,6 +12,28 @@ pub struct InstanceInfo {
icon_url: Option<String>,
}
#[derive(Deserialize)]
struct InstanceManifest {
name: Option<String>,
short_name: Option<String>,
icons: Option<Vec<InstanceIcon>>,
}
#[derive(Deserialize)]
struct InstanceIcon {
src: String,
sizes: String,
}
impl InstanceIcon {
fn get_size(&self) -> Option<usize> {
let (x, y) = self.sizes.split_once("x")?;
let x: usize = x.parse().ok()?;
let y: usize = y.parse().ok()?;
Some(x.max(y))
}
}
#[derive(Deserialize)]
struct NodeInfoDiscovery {
links: Vec<NodeInfoLink>,
@ -45,59 +62,40 @@ struct NodeInfoMetadata {
name: Option<String>,
}
/// Scrapes icons and returns the smallest one where the smallest axis is bigger than or equal to [MINIMUM_ICON_SIZE]
async fn find_icon(host: &str) -> Option<String> {
let icons: Vec<Icon> = favicon_scraper::scrape(host)
.await
.ok()?
.into_iter()
.filter(|i| i.size.width.min(i.size.height) >= MINIMUM_ICON_SIZE)
.collect();
let priority = |kind: &IconKind| match kind {
IconKind::LinkedInHTML => 0,
IconKind::LinkedInManifest => 1,
IconKind::HardcodedURL => 2,
_ => 3,
};
let preferred_kind = icons
.iter()
.map(|i| i.kind)
.min_by(|x, y| priority(x).cmp(&priority(y)))?; // None if icons is empty
icons
.into_iter()
.filter(|i| i.kind == preferred_kind)
.min_by_key(|i| i.size)
.map(|i| i.url.into())
async fn get_info_from_manifest(url: Url) -> Option<[Option<String>; 3]> {
// FIXME: Iceshrimp.NET doesn't have a manifest...
let response = reqwest::get(url.clone()).await.ok()?.text().await.ok()?;
let manifest: InstanceManifest = serde_json::from_str(&response).ok()?;
Some([
manifest.name,
manifest.short_name,
manifest
.icons
.as_ref()
.and_then(|icons| icons.iter().min_by_key(|icon| icon.get_size()))
.map(|icon| icon.src.to_owned()),
])
}
#[get("/instance_info/<secure>/<host>")]
pub async fn instance_info(secure: bool, host: &str) -> Option<Json<InstanceInfo>> {
let mut url = Url::parse(if secure {
"https://temp.host/"
} else {
"http://temp.host/"
})
.unwrap();
url.set_host(Some(host)).ok()?; // Using this to catch malformed hosts
let host = url.host_str()?.to_owned(); // Shadow the original host in case things were filtered out
// Check if the host is globally routable.
// This should help filter out a bunch of invalid or potentially malicious requests
let host_with_port = format!("{host}:{}", url.port_or_known_default()?);
if !host_with_port
.to_socket_addrs()
.ok()?
.next()?
.ip()
.is_global()
{
let mut url = Url::parse(&format!(
"http{}://{host}/manifest.json",
if secure { "s" } else { "" }
))
.ok()?;
// I'm not sure if you can sneak in a path, but better safe than sorry
// I don't really care about username/password/port, those are fine
if url.path() != "/manifest.json" {
return None;
}
let icon_url = find_icon(url.as_str()).await;
let [name, short_name, icon_url] = get_info_from_manifest(url.clone())
.await
.unwrap_or_default();
let icon_url = icon_url
.and_then(|i| url.join(&i).ok())
.map(|u| u.to_string());
// FIXME: Iceshrimp.NET doesn't have a nodeinfo discovery file either.............
url.set_path("/.well-known/nodeinfo");
let response = reqwest::get(url.clone()).await.ok()?.text().await.ok()?;
let nodeinfo_discovery: NodeInfoDiscovery = serde_json::from_str(&response).ok()?;
@ -114,12 +112,11 @@ pub async fn instance_info(secure: bool, host: &str) -> Option<Json<InstanceInfo
.and_then(|v| fork_map.get(v.build.as_str()))
.unwrap_or(software_name)
.to_owned();
Some(Json(InstanceInfo {
name: nodeinfo
.metadata
.and_then(|m| m.name)
.unwrap_or(host.to_owned()),
name: name
.or(short_name)
.or(nodeinfo.metadata.and_then(|m| m.name))
.unwrap_or(url.host_str().unwrap().to_owned()),
software,
icon_url,
}))

View file

@ -4,9 +4,5 @@ pub mod instance_info;
pub mod proxy;
pub fn get_routes() -> Vec<Route> {
routes![
instance_info::instance_info,
// Proxy is temporarily disabled as it's not needed
// proxy::proxy
]
routes![instance_info::instance_info, proxy::proxy]
}

View file

@ -98,17 +98,3 @@ 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,4 +1,3 @@
#![feature(ip)]
#[macro_use]
extern crate rocket;

View file

@ -1,6 +1,5 @@
// 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 {
@ -14,67 +13,43 @@ export function parseHost(host: string): { host: string, secure: boolean } | nul
};
}
export type AddInstanceDialogData = {
export function initializeAddInstanceDialog(
dialog: HTMLDialogElement,
callback: (
host: string,
secure: boolean,
autoQueryMetadata: boolean,
};
) => void
): {
showAddInstanceDialog: () => void,
hideAddInstanceDialog: () => void,
} {
const showAddInstanceDialog = () => dialog.showModal();
const hideAddInstanceDialog = () => dialog.close();
export class AddInstanceDialog extends FormDialog {
protected instanceHost: HTMLInputElement;
protected autoQueryMetadata: HTMLInputElement;
protected closeButton: HTMLButtonElement;
const form = findFormOrFail(dialog, ".addInstanceForm");
const instanceHost = findInputOrFail(form, "#instanceHost");
const autoQueryMetadata = findInputOrFail(form, "#autoQueryMetadata");
const closeButton = findButtonOrFail(form, ".close");
constructor(dialog: HTMLDialogElement, initializeDOM: boolean = true) {
super(dialog, findFormOrFail(dialog, ".addInstanceForm"));
this.instanceHost = findInputOrFail(this.form, "#instanceHost");
this.autoQueryMetadata = findInputOrFail(this.form, "#autoQueryMetadata");
this.closeButton = findButtonOrFail(this.form, ".close");
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 {
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();
instanceHost.addEventListener("input", e => {
if (parseHost(instanceHost.value) === null)
instanceHost.setCustomValidity("Invalid instance hostname or URL");
else
instanceHost.setCustomValidity("");
});
}
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();
});
closeButton.addEventListener("click", e => hideAddInstanceDialog());
return {
showAddInstanceDialog,
hideAddInstanceDialog
};
}

View file

@ -1,73 +1,69 @@
import { AddInstanceDialog } from "./add_an_instance.mjs";
import { dialogDetailsToInstance, InstanceDetailsDialog, InstanceDetailsDialogData } from "./confirm_instance_details.mjs";
import { Dialog } from "./dialog.mjs";
import { initializeAddInstanceDialog } from "./add_an_instance.mjs";
import { initializeInstanceDetailsDialog } from "./confirm_instance_details.mjs";
import storageManager, { Instance } from "./storage_manager.mjs";
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 {
autoQueryMetadata,
host,
secure,
} = await this.addDialog.present();
const detailsDialogData: InstanceDetailsDialogData = {
name: host,
host,
hostSecure: secure,
software: "",
iconURL: null,
preferredFor: []
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);
};
const {
showInstanceDetailsDialog,
hideInstanceDetailsDialog,
populateInstanceDetailsDialog
} = initializeInstanceDetailsDialog(detailsDialog, instanceDetailsDialogCallback);
const addInstanceDialogCallback = async (
host: string,
secure: boolean,
autoQueryMetadata: boolean,
) => {
try {
if (!autoQueryMetadata) throw null; // Skip to catch block
this.spinnerDialog.open();
if (!autoQueryMetadata) throw new Error("Don't");
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) // I guess TS is too stupid to understand this?
|| !(typeof iconURL === "string" || iconURL === null)
)
throw new Error("Invalid API response");
detailsDialogData.name = name;
detailsDialogData.software = software;
detailsDialogData.iconURL = iconURL as string | null;
} catch { }
this.spinnerDialog.close();
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);
populateInstanceDetailsDialog(name, host, secure, software, iconURL as string | null);
} catch {
populateInstanceDetailsDialog(host, host, secure, "", null);
} finally {
showInstanceDetailsDialog();
}
}
const {
showAddInstanceDialog,
hideAddInstanceDialog
} = initializeAddInstanceDialog(addDialog, addInstanceDialogCallback);
return {
showAddInstanceDialog,
hideAddInstanceDialog
};
}

View file

@ -6,11 +6,13 @@
<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" src="/static/config.mjs"></script>
<script type="module">
Object.assign(globalThis, await import("/static/config.mjs"));
getMainDialog().show(); // Don't show until the page is ready
</script>
<div class="flex-vcenter">
<dialog id="mainDialog" class="half-width half-height">
<header class="separator-bottom margin-large-bottom">
@ -25,7 +27,7 @@
<center class="half-width">
<ol id="instanceList" class="align-start wfit-content"></ol>
<br>
<button id="startAddInstanceFlow">Add an instance</button>
<button onclick="showAddInstanceDialog()">Add an instance</button>
</center>
</div>
<div class="half-width align-self-start">
@ -50,8 +52,9 @@
<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.">
<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.">
Automatically query metadata
</abbr>
</label>
@ -101,15 +104,14 @@ 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 id="noDefaults" value="" disabled>(None, use the "Redirect always" button to set!)</option>
<option value="" disabled>(None, use the "Redirect always" button to set!)</option>
</select>
<button id="removeDefaults" type="button" disabled>Remove</button>
<button id="removeDefaults" 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,34 +1,22 @@
import { AddInstanceFlow } from "./add_instance_flow.mjs";
import { dialogDetailsFromInstance, dialogDetailsToInstance, InstanceDetailsDialog } from "./confirm_instance_details.mjs";
import { parseHost } from "./add_an_instance.mjs";
import { initializeAddInstanceFlow } from "./add_instance_flow.mjs";
import { initializeInstanceDetailsDialog } from "./confirm_instance_details.mjs";
import { findButtonOrFail, findDialogOrFail, findOlOrFail } from "./dom.mjs";
import storageManager, { Instance } from "./storage_manager.mjs";
import storageManager 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 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 addDialog = findDialogOrFail(document.body, "#addInstance");
const instanceList = findOlOrFail(document.body, "#instanceList");
const saveButton = findButtonOrFail(document.body, "#save");
const reorderButton = findButtonOrFail(document.body, "#reorder");
const resetButton = findButtonOrFail(document.body, "#reset");
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 => {
storageManager.save();
});
});
saveButton.addEventListener("click", e => saveChanges());
reorderButton.addEventListener("click", () => {
reordering = !reordering;
@ -37,47 +25,22 @@ reorderButton.addEventListener("click", () => {
reorderButton.innerText = reordering ? "Finish reordering" : "Reorder";
});
resetButton.addEventListener("click", e => {
storageManager.reset();
updateInstanceList();
unsavedChanges();
});
export const getMainDialog = () => findDialogOrFail(document.body, "#mainDialog");
const {
showInstanceDetailsDialog,
hideInstanceDetailsDialog,
populateInstanceDetailsDialog,
} = initializeInstanceDetailsDialog(detailsDialog, () => { });
export const {
showAddInstanceDialog,
hideAddInstanceDialog
} = initializeAddInstanceFlow(detailsDialog, addDialog);
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";
@ -123,11 +86,26 @@ function updateInstanceList() {
const editLink = document.createElement("a");
editLink.innerText = `Edit`;
editLink.href = "#";
editLink.addEventListener("click", e => editInstance(instance));
editLink.addEventListener("click", e => {
const host = parseHost(instance.origin)!;
populateInstanceDetailsDialog(
instance.name,
host.host,
host.secure,
instance.software,
instance.iconURL ?? null
);
showInstanceDetailsDialog();
});
const deleteLink = document.createElement("a");
deleteLink.innerText = `Delete`;
deleteLink.href = "#";
deleteLink.addEventListener("click", e => deleteInstance(instance));
deleteLink.addEventListener("click", e => {
storageManager.storage.instances.splice(
storageManager.storage.instances.indexOf(instance)
);
updateInstanceList();
});
label.append(editLink, " ", deleteLink);
}
li.appendChild(label);
@ -152,5 +130,4 @@ function applyReordering() {
indices.push(parseInt(option));
}
storageManager.storage.instances = indices.map(i => storageManager.storage.instances[i]);
unsavedChanges();
}

View file

@ -1,184 +1,88 @@
// This file handles the "Confirm instance details" dialog
import { parseHost } from "./add_an_instance.mjs";
import { FormDialog, ONCE } from "./dialog.mjs";
import { findButtonOrFail, findFormOrFail, findImageOrFail, findInputOrFail, findOptionOrFail, findSelectOrFail } from "./dom.mjs";
import { findButtonOrFail, findFormOrFail, findImageOrFail, findInputOrFail, findSelectOrFail } from "./dom.mjs";
import { resize } from "./image.mjs";
import knownSoftware from "./known_software.mjs";
import { Instance } from "./storage_manager.mjs";
const blankImage = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==";
export function mergeHost(host: string, secure: boolean): string {
return `http${secure ? "s" : ""}://${host}`;
}
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 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();
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");
for (const [name, software] of Object.entries(knownSoftware.software)) {
const option = new Option(software.name, name);
this.instanceSoftware.appendChild(option);
instanceSoftware.appendChild(option);
}
this.instanceIcon.src = blankImage;
instanceIcon.src = blankImage;
this.closeButton.addEventListener("click", e => this.close());
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 === null ? blankImage : `/api/proxy/${encodeURIComponent(instanceIconValue)}`;
};
if (this.defaultsList) {
this.defaultsList.list.addEventListener("change", e => this.#handleListSelectionChange());
this.defaultsList.removeButton.addEventListener("click", e => this.#removeSelectedListOptions());
form.addEventListener("submit", e => {
let image: string | null = null;
if (instanceIcon.src !== blankImage) {
try {
image = resize(instanceIcon);
} catch { }
}
}
#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();
callback(
instanceName.value,
instanceHost.value,
instanceHostSecure.checked,
instanceSoftware.value,
image
);
form.reset();
});
}
closeButton.addEventListener("click", e => {
instanceIcon.src = blankImage;
hideInstanceDetailsDialog();
});
return {
showInstanceDetailsDialog,
hideInstanceDetailsDialog,
populateInstanceDetailsDialog
};
}

View file

@ -6,11 +6,13 @@
<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" src="/static/crossroad.mjs"></script>
<script type="module">
Object.assign(globalThis, await import("/static/crossroad.mjs"));
getMainDialog().show(); // Don't show until the page is ready
</script>
<div class="flex-vcenter">
<dialog id="mainDialog" class="half-width half-height">
<header class="separator-bottom margin-large-bottom">
@ -29,7 +31,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="startAddInstanceFlow">Add an instance</button>
<button onclick="showAddInstanceDialog()">Add an instance</button>
</center>
</div>
<div class="half-width align-self-start">
@ -54,8 +56,9 @@
<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.">
<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.">
Automatically query metadata
</abbr>
</label>
@ -107,7 +110,6 @@ 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,37 +1,15 @@
import { AddInstanceFlow } from "./add_instance_flow.mjs";
import { initializeAddInstanceFlow } 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 RADIO_BUTTON_NAME = "instanceSelect";
const radioButtonName = "instanceSelect";
let addInstanceFlow: AddInstanceFlow | undefined;
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 addDialog = findDialogOrFail(document.body, "#addInstance");
const instanceSelectForm = findFormOrFail(document.body, "#instanceSelectForm");
const redirectButton = findButtonOrFail(document.body, "#redirect");
const redirectAlwaysButton = findButtonOrFail(document.body, "#redirectAlways");
const pathText = findPreOrFail(document.body, "#path");
// 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
@ -45,6 +23,30 @@ redirectAlwaysButton.addEventListener("click", e => {
redirect(option);
});
export const getMainDialog = () => findDialogOrFail(document.body, "#mainDialog");
export const {
showAddInstanceDialog,
hideAddInstanceDialog
} = ((): {
showAddInstanceDialog: () => void,
hideAddInstanceDialog: () => void
} => {
// Don't bother initializing if we're performing autoredirect
if (autoRedirect()) return {
showAddInstanceDialog: () => { },
hideAddInstanceDialog: () => { }
}
createInstanceSelectOptions();
storageManager.addSaveCallback(createInstanceSelectOptions);
updateNoInstanceHint();
storageManager.addSaveCallback(updateNoInstanceHint);
findPreOrFail(document.body, "#path").innerText = getTargetPath();
return initializeAddInstanceFlow(detailsDialog, addDialog);
})();
function updateNoInstanceHint() {
findParagraphOrFail(document.body, "#no-instance").style.display =
storageManager.storage.instances.length > 0
@ -61,7 +63,7 @@ function createInstanceSelectOptions() {
radio.id = instance.origin;
radio.value = instance.origin;
radio.type = "radio";
radio.name = RADIO_BUTTON_NAME;
radio.name = radioButtonName;
const label = document.createElement("label");
label.htmlFor = instance.origin;
label.innerText = instance.name + " ";
@ -108,7 +110,7 @@ function getTargetPath(): string {
function getSelectedOption(): string | null {
try {
return findInputOrFail(instanceSelectForm, `input[name="${RADIO_BUTTON_NAME}"]:checked`).value;
return findInputOrFail(instanceSelectForm, `input[name="${radioButtonName}"]:checked`).value;
} catch {
return null;
}
@ -127,6 +129,7 @@ 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();
}
@ -137,3 +140,5 @@ function redirect(to: string) {
url.pathname = getTargetPath();
location.href = url.toString();
}
export { storageManager };

View file

@ -1,37 +0,0 @@
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;
}
}

View file

@ -1,46 +0,0 @@
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,13 +1,6 @@
// 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,5 +1,3 @@
@import url("/static/spinner.css");
:root {
--red: #cb0b0b;
--blue: #2081c3;
@ -13,7 +11,6 @@ html,
body {
background: linear-gradient(300deg, var(--red), var(--blue));
background-size: 100vw 100vh;
background-attachment: fixed;
margin: 0;
min-height: 100vh;
height: 100vh;
@ -185,17 +182,3 @@ 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);
}
}

View file

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

View file

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