Compare commits

..

56 commits

Author SHA1 Message Date
e7748a71da Instance name drop
All checks were successful
Build & Test / build-run (push) Successful in 41s
2025-02-12 22:27:32 +01:00
b408f66f9d Use hidden properly everywhere
All checks were successful
Build & Test / build-run (push) Successful in 45s
2025-02-12 21:59:35 +01:00
c1f61cfe9b Hide image instead of setting it to blank 2025-02-12 21:55:17 +01:00
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
cc15a4b29f CSP and other stuff 2025-02-03 01:01:53 +01:00
c7ea3326cb Rest in piss site_icons
You will not be missed
2025-02-02 23:54:36 +01:00
01d0b6a8bd Don't use the proxy in the JS 2025-01-31 15:58:10 +01:00
28a4ac2ca3 Oops 2025-01-30 17:44:44 +01:00
4793aa3a7a Add script to install site_icons 2025-01-30 17:33:15 +01:00
1449e2f5df Use external scraper for finding icons 2025-01-30 17:13:50 +01:00
c856ab9900 Redo safety checks for instance_info
Using the `ip` feature, and some clever use of `Url::set_host` and `Url::host_str`
2025-01-29 22:13:14 +01:00
6684943989 Switch toolchain to nightly 2025-01-29 22:08:30 +01:00
c1021077bc No more proxy 2025-01-29 22:08:07 +01:00
26 changed files with 1065 additions and 327 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

328
Cargo.lock generated
View file

@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo. # This file is automatically @generated by Cargo.
# It is not intended for manual editing. # It is not intended for manual editing.
version = 3 version = 4
[[package]] [[package]]
name = "addr2line" name = "addr2line"
@ -185,6 +185,29 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 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]] [[package]]
name = "deranged" name = "deranged"
version = "0.3.11" version = "0.3.11"
@ -194,6 +217,17 @@ dependencies = [
"powerfmt", "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]] [[package]]
name = "devise" name = "devise"
version = "0.4.2" version = "0.4.2"
@ -238,6 +272,27 @@ dependencies = [
"syn", "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]] [[package]]
name = "either" name = "either"
version = "1.13.0" version = "1.13.0"
@ -275,16 +330,32 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 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]] [[package]]
name = "fedirect" name = "fedirect"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"bytes", "bytes",
"favicon-scraper",
"reqwest", "reqwest",
"rocket", "rocket",
"semver", "semver",
"serde", "serde",
"serde_json", "serde_json",
"tokio",
"url", "url",
] ]
@ -332,6 +403,16 @@ dependencies = [
"percent-encoding", "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]] [[package]]
name = "futures" name = "futures"
version = "0.3.31" version = "0.3.31"
@ -409,6 +490,15 @@ dependencies = [
"slab", "slab",
] ]
[[package]]
name = "fxhash"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
dependencies = [
"byteorder",
]
[[package]] [[package]]
name = "generator" name = "generator"
version = "0.7.5" version = "0.7.5"
@ -422,6 +512,15 @@ dependencies = [
"windows", "windows",
] ]
[[package]]
name = "getopts"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5"
dependencies = [
"unicode-width",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.15" version = "0.2.15"
@ -501,6 +600,20 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" 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]] [[package]]
name = "http" name = "http"
version = "0.2.12" version = "0.2.12"
@ -804,6 +917,12 @@ dependencies = [
"icu_properties", "icu_properties",
] ]
[[package]]
name = "imagesize"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285"
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.7.0" version = "2.7.0"
@ -909,6 +1028,26 @@ dependencies = [
"tracing-subscriber", "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]] [[package]]
name = "matchers" name = "matchers"
version = "0.1.0" version = "0.1.0"
@ -986,6 +1125,12 @@ dependencies = [
"tempfile", "tempfile",
] ]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.46.0" version = "0.46.0"
@ -1129,6 +1274,77 @@ version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" 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]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.16" version = "0.2.16"
@ -1162,6 +1378,12 @@ dependencies = [
"zerocopy", "zerocopy",
] ]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.93" version = "1.0.93"
@ -1530,6 +1752,21 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 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]] [[package]]
name = "security-framework" name = "security-framework"
version = "2.11.1" version = "2.11.1"
@ -1553,6 +1790,25 @@ dependencies = [
"libc", "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]] [[package]]
name = "semver" name = "semver"
version = "1.0.24" version = "1.0.24"
@ -1612,6 +1868,15 @@ dependencies = [
"serde", "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]] [[package]]
name = "sharded-slab" name = "sharded-slab"
version = "0.1.7" version = "0.1.7"
@ -1636,6 +1901,18 @@ dependencies = [
"libc", "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]] [[package]]
name = "slab" name = "slab"
version = "0.4.9" version = "0.4.9"
@ -1691,6 +1968,32 @@ dependencies = [
"loom", "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]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"
@ -1763,6 +2066,17 @@ dependencies = [
"windows-sys 0.59.0", "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]] [[package]]
name = "thread_local" name = "thread_local"
version = "1.1.8" version = "1.1.8"
@ -2039,6 +2353,12 @@ version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83"
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]] [[package]]
name = "unicode-xid" name = "unicode-xid"
version = "0.2.6" version = "0.2.6"
@ -2062,6 +2382,12 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]] [[package]]
name = "utf16_iter" name = "utf16_iter"
version = "1.0.5" version = "1.0.5"

View file

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

View file

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

View file

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

2
rust-toolchain.toml Normal file
View file

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

View file

@ -1,9 +1,14 @@
use std::net::ToSocketAddrs;
use favicon_scraper::{Icon, IconKind};
use rocket::serde::json::Json; use rocket::serde::json::Json;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use url::Url; use url::Url;
use crate::known_software::KNOWN_SOFTWARE_NODEINFO_NAMES; use crate::known_software::KNOWN_SOFTWARE_NODEINFO_NAMES;
const MINIMUM_ICON_SIZE: usize = 16;
#[derive(Serialize)] #[derive(Serialize)]
pub struct InstanceInfo { pub struct InstanceInfo {
name: String, name: String,
@ -12,28 +17,6 @@ pub struct InstanceInfo {
icon_url: Option<String>, 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)] #[derive(Deserialize)]
struct NodeInfoDiscovery { struct NodeInfoDiscovery {
links: Vec<NodeInfoLink>, links: Vec<NodeInfoLink>,
@ -62,40 +45,59 @@ struct NodeInfoMetadata {
name: Option<String>, name: Option<String>,
} }
async fn get_info_from_manifest(url: Url) -> Option<[Option<String>; 3]> { /// Scrapes icons and returns the smallest one where the smallest axis is bigger than or equal to [MINIMUM_ICON_SIZE]
// FIXME: Iceshrimp.NET doesn't have a manifest... async fn find_icon(host: &str) -> Option<String> {
let response = reqwest::get(url.clone()).await.ok()?.text().await.ok()?; let icons: Vec<Icon> = favicon_scraper::scrape(host)
let manifest: InstanceManifest = serde_json::from_str(&response).ok()?; .await
Some([ .ok()?
manifest.name, .into_iter()
manifest.short_name, .filter(|i| i.size.width.min(i.size.height) >= MINIMUM_ICON_SIZE)
manifest .collect();
.icons
.as_ref() let priority = |kind: &IconKind| match kind {
.and_then(|icons| icons.iter().min_by_key(|icon| icon.get_size())) IconKind::LinkedInHTML => 0,
.map(|icon| icon.src.to_owned()), 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())
} }
#[get("/instance_info/<secure>/<host>")] #[get("/instance_info/<secure>/<host>")]
pub async fn instance_info(secure: bool, host: &str) -> Option<Json<InstanceInfo>> { pub async fn instance_info(secure: bool, host: &str) -> Option<Json<InstanceInfo>> {
let mut url = Url::parse(&format!( let mut url = Url::parse(if secure {
"http{}://{host}/manifest.json", "https://temp.host/"
if secure { "s" } else { "" } } else {
)) "http://temp.host/"
.ok()?; })
// I'm not sure if you can sneak in a path, but better safe than sorry .unwrap();
// I don't really care about username/password/port, those are fine url.set_host(Some(host)).ok()?; // Using this to catch malformed hosts
if url.path() != "/manifest.json" { 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()
{
return None; return None;
} }
let [name, short_name, icon_url] = get_info_from_manifest(url.clone())
.await let icon_url = find_icon(url.as_str()).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"); url.set_path("/.well-known/nodeinfo");
let response = reqwest::get(url.clone()).await.ok()?.text().await.ok()?; let response = reqwest::get(url.clone()).await.ok()?.text().await.ok()?;
let nodeinfo_discovery: NodeInfoDiscovery = serde_json::from_str(&response).ok()?; let nodeinfo_discovery: NodeInfoDiscovery = serde_json::from_str(&response).ok()?;
@ -112,11 +114,12 @@ pub async fn instance_info(secure: bool, host: &str) -> Option<Json<InstanceInfo
.and_then(|v| fork_map.get(v.build.as_str())) .and_then(|v| fork_map.get(v.build.as_str()))
.unwrap_or(software_name) .unwrap_or(software_name)
.to_owned(); .to_owned();
Some(Json(InstanceInfo { Some(Json(InstanceInfo {
name: name name: nodeinfo
.or(short_name) .metadata
.or(nodeinfo.metadata.and_then(|m| m.name)) .and_then(|m| m.name)
.unwrap_or(url.host_str().unwrap().to_owned()), .unwrap_or(host.to_owned()),
software, software,
icon_url, icon_url,
})) }))

View file

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

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

View file

@ -1,5 +1,6 @@
// This file handles the "Add an instance" dialog // This file handles the "Add an instance" dialog
import { FormDialog, ONCE } from "./dialog.mjs";
import { findButtonOrFail, findFormOrFail, findInputOrFail } from "./dom.mjs"; import { findButtonOrFail, findFormOrFail, findInputOrFail } from "./dom.mjs";
export function parseHost(host: string): { host: string, secure: boolean } | null { 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( export type AddInstanceDialogData = {
dialog: HTMLDialogElement,
callback: (
host: string, host: string,
secure: boolean, secure: boolean,
autoQueryMetadata: boolean, autoQueryMetadata: boolean,
) => void };
): {
showAddInstanceDialog: () => void,
hideAddInstanceDialog: () => void,
} {
const showAddInstanceDialog = () => dialog.showModal();
const hideAddInstanceDialog = () => dialog.close();
const form = findFormOrFail(dialog, ".addInstanceForm"); export class AddInstanceDialog extends FormDialog {
const instanceHost = findInputOrFail(form, "#instanceHost"); protected instanceHost: HTMLInputElement;
const autoQueryMetadata = findInputOrFail(form, "#autoQueryMetadata"); protected autoQueryMetadata: HTMLInputElement;
const closeButton = findButtonOrFail(form, ".close"); protected closeButton: HTMLButtonElement;
instanceHost.addEventListener("input", e => { constructor(dialog: HTMLDialogElement, initializeDOM: boolean = true) {
if (parseHost(instanceHost.value) === null) super(dialog, findFormOrFail(dialog, ".addInstanceForm"));
instanceHost.setCustomValidity("Invalid instance hostname or URL");
else
instanceHost.setCustomValidity("");
});
form.addEventListener("submit", e => { this.instanceHost = findInputOrFail(this.form, "#instanceHost");
// A sane browser doesn't allow for submitting the form if the above validation fails this.autoQueryMetadata = findInputOrFail(this.form, "#autoQueryMetadata");
const { host, secure } = parseHost(instanceHost.value)!; this.closeButton = findButtonOrFail(this.form, ".close");
callback(host, secure, autoQueryMetadata.checked);
form.reset();
});
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 { return {
showAddInstanceDialog, host: parsedHost.host,
hideAddInstanceDialog 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 { AddInstanceDialog } from "./add_an_instance.mjs";
import { initializeInstanceDetailsDialog } from "./confirm_instance_details.mjs"; import { dialogDetailsToInstance, InstanceDetailsDialog, InstanceDetailsDialogData } from "./confirm_instance_details.mjs";
import { Dialog } from "./dialog.mjs";
import storageManager, { Instance } from "./storage_manager.mjs"; import storageManager, { Instance } from "./storage_manager.mjs";
export function initializeAddInstanceFlow( export class AddInstanceFlow {
detailsDialog: HTMLDialogElement, addDialog: AddInstanceDialog;
addDialog: HTMLDialogElement spinnerDialog: Dialog;
): { detailsDialog: InstanceDetailsDialog;
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);
};
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 { const {
showInstanceDetailsDialog, autoQueryMetadata,
hideInstanceDetailsDialog, host,
populateInstanceDetailsDialog secure,
} = initializeInstanceDetailsDialog(detailsDialog, instanceDetailsDialogCallback); } = 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 { try {
if (!autoQueryMetadata) throw new Error("Don't"); if (!autoQueryMetadata) throw null; // Skip to catch block
this.spinnerDialog.open();
const { name, software, iconURL } = const { name, software, iconURL } =
await fetch(`/api/instance_info/${secure}/${encodeURIComponent(host)}`) await fetch(`/api/instance_info/${secure}/${encodeURIComponent(host)}`)
.then(r => r.json()); .then(r => r.json());
if ( if (
typeof name !== "string" typeof name !== "string"
|| typeof software !== "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"); throw new Error("Invalid API response");
populateInstanceDetailsDialog(name, host, secure, software, iconURL as string | null);
} catch {
populateInstanceDetailsDialog(host, host, secure, "", null);
} finally {
showInstanceDetailsDialog();
}
}
const { detailsDialogData.name = name;
showAddInstanceDialog, detailsDialogData.software = software;
hideAddInstanceDialog detailsDialogData.iconURL = iconURL as string | null;
} = initializeAddInstanceDialog(addDialog, addInstanceDialogCallback); } catch { }
this.spinnerDialog.close();
return { const finalData = await this.detailsDialog.present(detailsDialogData);
showAddInstanceDialog, const instance = dialogDetailsToInstance(finalData, {});
hideAddInstanceDialog
}; 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"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FeDirect</title> <title>FeDirect</title>
<link rel="stylesheet" href="/static/main.css"> <link rel="stylesheet" href="/static/main.css">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src *;">
</head> </head>
<body> <body>
<script type="module"> <script type="module" src="/static/config.mjs"></script>
Object.assign(globalThis, await import("/static/config.mjs"));
getMainDialog().show(); // Don't show until the page is ready
</script>
<div class="flex-vcenter"> <div class="flex-vcenter">
<dialog id="mainDialog" class="half-width half-height"> <dialog id="mainDialog" class="half-width half-height">
<header class="separator-bottom margin-large-bottom"> <header class="separator-bottom margin-large-bottom">
@ -27,7 +25,7 @@
<center class="half-width"> <center class="half-width">
<ol id="instanceList" class="align-start wfit-content"></ol> <ol id="instanceList" class="align-start wfit-content"></ol>
<br> <br>
<button onclick="showAddInstanceDialog()">Add an instance</button> <button id="startAddInstanceFlow">Add an instance</button>
</center> </center>
</div> </div>
<div class="half-width align-self-start"> <div class="half-width align-self-start">
@ -52,9 +50,8 @@
<br> <br>
<input id="autoQueryMetadata" type="checkbox" name="autoQueryMetadata" checked /> <input id="autoQueryMetadata" type="checkbox" name="autoQueryMetadata" checked />
<label for="autoQueryMetadata"> <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
We do this on the backend to avoid CORS problems. title="This uses FeDirect's API to automatically try to detect instance metadata, such as the name, software, and an icon.">
We do not track or save any requests or data.">
Automatically query metadata Automatically query metadata
</abbr> </abbr>
</label> </label>
@ -104,14 +101,15 @@ Unchecking this is not recommended, and this option only exists for exceptional
<br> <br>
<label for="defaultsList">Default option for:</label><br> <label for="defaultsList">Default option for:</label><br>
<select id="defaultsList" class="full-width" multiple> <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> </select>
<button id="removeDefaults" disabled>Remove</button> <button id="removeDefaults" type="button" disabled>Remove</button>
<br><br> <br><br>
<button type="submit">OK</button> <button type="submit">OK</button>
<button type="reset" class="close">Cancel</button> <button type="reset" class="close">Cancel</button>
</form> </form>
</dialog> </dialog>
<dialog id="spinner"><span class="spinner"></span></dialog>
</body> </body>
</html> </html>

View file

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

View file

@ -1,88 +1,185 @@
// This file handles the "Confirm instance details" dialog // 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 { resize } from "./image.mjs"; import { FormDialog, ONCE } from "./dialog.mjs";
import knownSoftware from "./known_software.mjs"; import { findButtonOrFail, findFormOrFail, findImageOrFail, findInputOrFail, findOptionOrFail, findSelectOrFail } from "./dom.mjs";
import knownSoftware, { getName } 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( export type InstanceDetailsDialogData = {
dialog: HTMLDialogElement, name: string,
callback: ( host: string,
instanceName: string, hostSecure: boolean,
instanceHost: string, software: string,
instanceHostSecure: boolean, iconURL: string | null,
instanceSoftware: string, preferredFor: 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();
const form = findFormOrFail(dialog, ".instanceDetailsForm"); export function dialogDetailsFromInstance(instance: Instance): InstanceDetailsDialogData {
const instanceName = findInputOrFail(form, "#instanceName"); const host = parseHost(instance.origin)!;
const instanceHost = findInputOrFail(form, "#instanceHost"); return {
const instanceHostSecure = findInputOrFail(form, "#instanceHostSecure"); name: instance.name,
const instanceSoftware = findSelectOrFail(form, "#instanceSoftware"); host: host.host,
const instanceIcon = findImageOrFail(form, "#instanceIcon"); hostSecure: host.secure,
const closeButton = findButtonOrFail(form, ".close"); 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)) { for (const [name, software] of Object.entries(knownSoftware.software)) {
const option = new Option(software.name, name); const option = new Option(software.name, name);
instanceSoftware.appendChild(option); this.instanceSoftware.appendChild(option);
} }
instanceIcon.src = blankImage; this.instanceIcon.hidden = true;
const populateInstanceDetailsDialog = ( this.closeButton.addEventListener("click", e => this.close());
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)}`;
};
form.addEventListener("submit", e => { if (this.defaultsList) {
let image: string | null = null; this.defaultsList.list.addEventListener("change", e => this.#handleListSelectionChange());
if (instanceIcon.src !== blankImage) { this.defaultsList.removeButton.addEventListener("click", e => this.#removeSelectedListOptions());
try {
image = resize(instanceIcon);
} catch { }
} }
callback( }
instanceName.value,
instanceHost.value, #getRemainingListOptions(): string[] {
instanceHostSecure.checked, if (!this.defaultsList) return [];
instanceSoftware.value,
image const items: string[] = [];
);
form.reset(); 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 = getName(knownSoftware, item) ?? 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.hidden = false;
} else {
this.defaultsList.noDefaultsOption.hidden = true;
}
}
#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;
if (data.iconURL !== null) {
this.instanceIcon.src = data.iconURL;
this.instanceIcon.hidden = false;
} else this.instanceIcon.hidden = true;
this.#populateDefaultsList(data.preferredFor);
return new Promise((resolve, reject) => {
this.cancelOnceClosed(reject);
this.#handleSubmit(data, resolve);
this.open();
}); });
}
closeButton.addEventListener("click", e => {
instanceIcon.src = blankImage;
hideInstanceDetailsDialog();
});
return {
showInstanceDetailsDialog,
hideInstanceDetailsDialog,
populateInstanceDetailsDialog
};
} }

View file

@ -6,13 +6,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FeDirect</title> <title>FeDirect</title>
<link rel="stylesheet" href="/static/main.css"> <link rel="stylesheet" href="/static/main.css">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src *;">
</head> </head>
<body> <body>
<script type="module"> <script type="module" src="/static/crossroad.mjs"></script>
Object.assign(globalThis, await import("/static/crossroad.mjs"));
getMainDialog().show(); // Don't show until the page is ready
</script>
<div class="flex-vcenter"> <div class="flex-vcenter">
<dialog id="mainDialog" class="half-width half-height"> <dialog id="mainDialog" class="half-width half-height">
<header class="separator-bottom margin-large-bottom"> <header class="separator-bottom margin-large-bottom">
@ -26,12 +24,15 @@
<div class="flex-vcenter full-height"> <div class="flex-vcenter full-height">
<center class="half-width"> <center class="half-width">
You're about to go to You're about to go to
<pre id="path" class="inline-block"></pre>.<br> <pre id="path" class="inline-block margin-none"></pre>
on <span id="aOrAn"></span>
<span id="destination" class="inline-block margin-none"></span>
instance.<br>
<img src="/static/down_arrow.svg" alt="" class="medium-height" /> <img src="/static/down_arrow.svg" alt="" class="medium-height" />
<p id="no-instance">You currently don't have any instances. You should add one!</p> <p id="noInstance">You currently don't have any instances. You should add one!</p>
<form id="instanceSelectForm" class="align-start wfit-content"></form> <form id="instanceSelectForm" class="align-start wfit-content"></form>
<br> <br>
<button onclick="showAddInstanceDialog()">Add an instance</button> <button id="startAddInstanceFlow">Add an instance</button>
</center> </center>
</div> </div>
<div class="half-width align-self-start"> <div class="half-width align-self-start">
@ -56,9 +57,8 @@
<br> <br>
<input id="autoQueryMetadata" type="checkbox" name="autoQueryMetadata" checked /> <input id="autoQueryMetadata" type="checkbox" name="autoQueryMetadata" checked />
<label for="autoQueryMetadata"> <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
We do this on the backend to avoid CORS problems. title="This uses FeDirect's API to automatically try to detect instance metadata, such as the name, software, and an icon.">
We do not track or save any requests or data.">
Automatically query metadata Automatically query metadata
</abbr> </abbr>
</label> </label>
@ -110,6 +110,7 @@ Unchecking this is not recommended, and this option only exists for exceptional
<button type="reset" class="close">Cancel</button> <button type="reset" class="close">Cancel</button>
</form> </form>
</dialog> </dialog>
<dialog id="spinner"><span class="spinner"></span></dialog>
</body> </body>
</html> </html>

View file

@ -1,15 +1,45 @@
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 { findButtonOrFail, findDialogOrFail, findFormOrFail, findInputOrFail, findParagraphOrFail, findPreOrFail, findSpanOrFail } from "./dom.mjs";
import knownSoftware from "./known_software.mjs"; import knownSoftware, { getName } from "./known_software.mjs";
import storageManager from "./storage_manager.mjs"; import storageManager from "./storage_manager.mjs";
const radioButtonName = "instanceSelect"; const RADIO_BUTTON_NAME = "instanceSelect";
const detailsDialog = findDialogOrFail(document.body, "#instanceDetails"); let addInstanceFlow: AddInstanceFlow | undefined;
const mainDialog = findDialogOrFail(document.body, "#mainDialog");
const startAddInstanceFlowButton = findButtonOrFail(document.body, "#startAddInstanceFlow");
const addDialog = findDialogOrFail(document.body, "#addInstance"); 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 instanceSelectForm = findFormOrFail(document.body, "#instanceSelectForm");
const redirectButton = findButtonOrFail(document.body, "#redirect"); const redirectButton = findButtonOrFail(document.body, "#redirect");
const redirectAlwaysButton = findButtonOrFail(document.body, "#redirectAlways"); const redirectAlwaysButton = findButtonOrFail(document.body, "#redirectAlways");
const noInstanceParagraph = findParagraphOrFail(document.body, "#noInstance");
const pathText = findPreOrFail(document.body, "#path");
const aOrAnText = findSpanOrFail(document.body, "#aOrAn");
const destinationText = findSpanOrFail(document.body, "#destination");
// Don't bother initializing if we're performing autoredirect
if (!autoRedirect()) {
createInstanceSelectOptions();
storageManager.addSaveCallback(createInstanceSelectOptions);
updateNoInstanceHint();
storageManager.addSaveCallback(updateNoInstanceHint);
pathText.innerText = getTargetPath();
const targetID = getTargetSoftwareOrGroup();
const targetName = getName(knownSoftware, targetID) ?? targetID;
aOrAnText.innerText = "aeiou".includes(targetName[0].toLowerCase()) ? "an" : "a";
destinationText.innerText = targetName;
addInstanceFlow = new AddInstanceFlow(addDialog, spinnerDialog, detailsDialog);
mainDialog.show();
};
startAddInstanceFlowButton.addEventListener("click", e => addInstanceFlow?.start(true));
redirectButton.addEventListener("click", e => { 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 // Can be assumed to not fail because the button is disabled if there are no options and the first one is selected by default
@ -23,35 +53,9 @@ redirectAlwaysButton.addEventListener("click", e => {
redirect(option); 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() { function updateNoInstanceHint() {
findParagraphOrFail(document.body, "#no-instance").style.display = noInstanceParagraph.hidden =
storageManager.storage.instances.length > 0 storageManager.storage.instances.length > 0;
? "none"
: "";
} }
function createInstanceSelectOptions() { function createInstanceSelectOptions() {
@ -63,7 +67,7 @@ function createInstanceSelectOptions() {
radio.id = instance.origin; radio.id = instance.origin;
radio.value = instance.origin; radio.value = instance.origin;
radio.type = "radio"; radio.type = "radio";
radio.name = radioButtonName; radio.name = RADIO_BUTTON_NAME;
const label = document.createElement("label"); const label = document.createElement("label");
label.htmlFor = instance.origin; label.htmlFor = instance.origin;
label.innerText = instance.name + " "; label.innerText = instance.name + " ";
@ -110,7 +114,7 @@ function getTargetPath(): string {
function getSelectedOption(): string | null { function getSelectedOption(): string | null {
try { try {
return findInputOrFail(instanceSelectForm, `input[name="${radioButtonName}"]:checked`).value; return findInputOrFail(instanceSelectForm, `input[name="${RADIO_BUTTON_NAME}"]:checked`).value;
} catch { } catch {
return null; return null;
} }
@ -129,7 +133,6 @@ function autoRedirect(): boolean {
function setAutoRedirect(option: string) { function setAutoRedirect(option: string) {
const instance = storageManager.storage.instances.find(e => e.origin === option); const instance = storageManager.storage.instances.find(e => e.origin === option);
if (!instance) throw new Error("Invalid argument"); if (!instance) throw new Error("Invalid argument");
instance.preferredFor ??= [];
instance.preferredFor.push(getTargetSoftwareOrGroup()); instance.preferredFor.push(getTargetSoftwareOrGroup());
storageManager.save(); storageManager.save();
} }
@ -140,5 +143,3 @@ function redirect(to: string) {
url.pathname = getTargetPath(); url.pathname = getTargetPath();
location.href = url.toString(); location.href = url.toString();
} }
export { storageManager };

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,20 @@
// I would've LOVED to use generics for this but unfortunately that's not possible. // I would've LOVED to use generics for this but unfortunately that's not possible.
// Type safety, but at what cost... >~< thanks TypeScript // Type safety, but at what cost... >~< thanks TypeScript
export function findSpanOrFail(on: Element, selector: string): HTMLSpanElement {
const element = on.querySelector(selector);
if (!(element instanceof HTMLSpanElement))
throw new Error(`${selector} isn't a span`);
return element;
}
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 { export function findOlOrFail(on: Element, selector: string): HTMLOListElement {
const element = on.querySelector(selector); const element = on.querySelector(selector);
if (!(element instanceof HTMLOListElement)) if (!(element instanceof HTMLOListElement))

View file

@ -2,7 +2,7 @@ export function resize(image: HTMLImageElement, width: number = 16, height: numb
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = width; canvas.width = width;
canvas.height = height; canvas.height = height;
canvas.style.display = "none"; canvas.hidden = true;
document.body.appendChild(canvas); document.body.appendChild(canvas);
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
if (ctx === null) throw Error("Resize failed"); if (ctx === null) throw Error("Resize failed");

View file

@ -16,4 +16,8 @@ type KnownSoftware = {
groups: Record<string, Group>, groups: Record<string, Group>,
} }
export function getName(knownSoftware: KnownSoftware, id: string): string | undefined {
return knownSoftware.software[id]?.name ?? knownSoftware.groups[id].name;
}
export default await fetch("/known-software.json").then(r => r.json()) as KnownSoftware; export default await fetch("/known-software.json").then(r => r.json()) as KnownSoftware;

View file

@ -1,3 +1,5 @@
@import url("/static/spinner.css");
:root { :root {
--red: #cb0b0b; --red: #cb0b0b;
--blue: #2081c3; --blue: #2081c3;
@ -11,6 +13,7 @@ html,
body { body {
background: linear-gradient(300deg, var(--red), var(--blue)); background: linear-gradient(300deg, var(--red), var(--blue));
background-size: 100vw 100vh; background-size: 100vw 100vh;
background-attachment: fixed;
margin: 0; margin: 0;
min-height: 100vh; min-height: 100vh;
height: 100vh; height: 100vh;
@ -142,6 +145,10 @@ abbr[title] {
border-bottom: solid 1px var(--transparent-black); border-bottom: solid 1px var(--transparent-black);
} }
.margin-none {
margin: 0;
}
.margin-auto-top { .margin-auto-top {
margin-top: auto; margin-top: auto;
} }
@ -182,3 +189,17 @@ abbr[title] {
.buttonPanel>* { .buttonPanel>* {
margin-top: min(var(--xl), 6vh); 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 = { export type Instance = {
/** /**
* The instance's (nick)name * The instance's (nick)name
@ -21,18 +23,19 @@ export type Instance = {
software: string, software: string,
/** /**
* The instance's icon URL * The instance's icon URL
* * @example undefined
* Make sure to sanitize this! Could lead to XSS * @example "https://void.lgbt/favicon.png"
*/ */
iconURL?: string, iconURL?: string,
/** /**
* The list of software names and groups the user prefers to autoredirect to this instance * The list of software names and groups the user prefers to autoredirect to this instance
* @example ["sharkey", "misskey-compliant"] * @example ["sharkey", "misskey-compliant"]
*/ */
preferredFor?: string[], preferredFor: string[],
} }
type LocalStorage = { export type LocalStorage = {
version: number,
instances: Instance[], instances: Instance[],
} }
@ -46,12 +49,14 @@ export default new class StorageManager {
default(): LocalStorage { default(): LocalStorage {
return { return {
version: 1,
instances: [] instances: []
} }
} }
load() { 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() { save() {
@ -62,4 +67,8 @@ export default new class StorageManager {
addSaveCallback(callback: () => void) { addSaveCallback(callback: () => void) {
this.saveCallbacks.push(callback); this.saveCallbacks.push(callback);
} }
reset() {
this.storage = this.default();
}
}(); }();

View file

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