diff --git a/known-software.json b/known-software.json
index 828ad18..eba5902 100644
--- a/known-software.json
+++ b/known-software.json
@@ -32,6 +32,7 @@
"name": "Akkoma",
"nodeinfoName": "akkoma",
"aliases": [
+ "akkoma",
"akko"
],
"groups": [
@@ -93,6 +94,7 @@
"nodeinfoName": "mastodon",
"buildMetadata": "glitch",
"aliases": [
+ "glitch-soc",
"glitch"
],
"groups": [
@@ -155,7 +157,8 @@
"name": "Iceshrimp.NET",
"nodeinfoName": "Iceshrimp.NET",
"aliases": [
- "iceshrimp-dotnet"
+ "iceshrimp-dotnet",
+ "iceshrimp.net"
],
"groups": [
"misskey-compliant",
diff --git a/static/crossroad.css b/static/crossroad.css
index 92bcd3c..544bc65 100644
--- a/static/crossroad.css
+++ b/static/crossroad.css
@@ -2,6 +2,7 @@
--red: #cb0b0b;
--blue: #2081c3;
--transparent-black: #0008;
+ --xl: 4em;
--large: 2em;
--medium: 1em;
}
@@ -53,6 +54,10 @@ abbr[title] {
text-decoration-color: var(--blue);
}
+.align-start {
+ text-align: start;
+}
+
.flex-vcenter {
display: flex;
flex-direction: column;
@@ -61,6 +66,14 @@ abbr[title] {
height: 100%;
}
+.flex-hcenter {
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+}
+
.flex-row {
display: flex;
flex-direction: row;
@@ -71,11 +84,27 @@ abbr[title] {
flex-direction: row-reverse;
}
+.flex-column {
+ display: flex;
+ flex-direction: column;
+}
+
.flex-column-reverse {
display: flex;
flex-direction: column-reverse;
}
+.flex-vevenly {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-evenly;
+ height: 100%;
+}
+
+.wfit-content {
+ width: fit-content;
+}
+
.half-width {
min-width: 50%;
}
@@ -119,4 +148,17 @@ abbr[title] {
top: 50%;
left: 50%;
translate: -50% -50%;
+}
+
+.logo {
+ height: 4em;
+}
+
+.inlineIcon {
+ height: var(--medium);
+ vertical-align: text-top;
+}
+
+.buttonPanel>* {
+ margin-top: min(var(--xl), 6vh);
}
\ No newline at end of file
diff --git a/static/crossroad.html b/static/crossroad.html
index 0ab8ba3..9fe5c11 100644
--- a/static/crossroad.html
+++ b/static/crossroad.html
@@ -20,20 +20,23 @@
FeDirect
  By Nekomata
-
+
-
-
+
+
+
+
-
diff --git a/static/crossroad.mts b/static/crossroad.mts
index 8d212c4..e7daae0 100644
--- a/static/crossroad.mts
+++ b/static/crossroad.mts
@@ -1,14 +1,130 @@
import { initializeAddInstanceFlow } from "./add_instance_flow.mjs";
-import { findDialogOrFail } from "./dom.mjs";
+import { findButtonOrFail, findDialogOrFail, findFormOrFail, findInputOrFail } from "./dom.mjs";
+import knownSoftware from "./known_software.mjs";
+import storageManager from "./storage_manager.mjs";
-export function getMainDialog(): HTMLDialogElement {
- return document.getElementById('mainDialog') as HTMLDialogElement;
-}
+const radioButtonName = "instanceSelect";
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");
+
+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
+ redirect(getSelectedOption()!);
+});
+
+redirectAlwaysButton.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
+ const option = getSelectedOption()!;
+ setAutoRedirect(option);
+ redirect(option);
+});
+
+export const getMainDialog = () => findDialogOrFail(document.body, "#mainDialog");
export const {
showAddInstanceDialog,
hideAddInstanceDialog
-} = initializeAddInstanceFlow(detailsDialog, addDialog);
+} = ((): {
+ showAddInstanceDialog: () => void,
+ hideAddInstanceDialog: () => void
+} => {
+ // Don't bother initializing if we're performing autoredirect
+ if (autoRedirect()) return {
+ showAddInstanceDialog: () => { },
+ hideAddInstanceDialog: () => { }
+ }
+ createInstanceSelectOptions();
+ storageManager.addSaveCallback(createInstanceSelectOptions);
+ return initializeAddInstanceFlow(detailsDialog, addDialog)
+})();
+
+function createInstanceSelectOptions() {
+ instanceSelectForm.replaceChildren(); // Erase all child nodes
+ for (const instance of storageManager.storage.instances) {
+ const div = document.createElement("div");
+ div.setAttribute("x-option", instance.origin);
+ const radio = document.createElement("input");
+ radio.id = instance.origin;
+ radio.value = instance.origin;
+ radio.type = "radio";
+ radio.name = radioButtonName;
+ const label = document.createElement("label");
+ label.htmlFor = instance.origin;
+ label.innerText = instance.name + " ";
+ if (instance.iconURL) {
+ const img = new Image();
+ img.src = instance.iconURL;
+ img.alt = `${instance.name} icon`;
+ img.className = "inlineIcon";
+ label.append(img, " ");
+ }
+ const small = document.createElement("small");
+ const softwareName = knownSoftware.software[instance.software].name;
+ small.innerText = `(${softwareName})`;
+ label.appendChild(small);
+ div.appendChild(radio);
+ div.appendChild(label);
+ instanceSelectForm.appendChild(div);
+ }
+ const firstInput = instanceSelectForm.querySelector("input");
+ if (firstInput) firstInput.checked = true;
+ setRedirectButtonState(firstInput !== null);
+}
+
+function setRedirectButtonState(enabled: boolean) {
+ redirectButton.disabled = !enabled;
+ redirectAlwaysButton.disabled = !enabled;
+}
+
+function getTargetSoftwareOrGroup(): string {
+ const currentURL = URL.parse(location.href)!;
+ const target = currentURL.pathname.match(/\/+([^\/]*)\/?/)?.[1];
+ if (target == null) throw new Error("Crossroad was served on an invalid path (likely a backend routing mistake)");
+ const softwareName = Object.entries(knownSoftware.software).find(([name, software]) => software.aliases.includes(target))?.[0];
+ if (softwareName) return softwareName;
+ const groupName = Object.entries(knownSoftware.groups).find(([name, group]) => group.aliases.includes(target))?.[0];
+ if (groupName) return groupName;
+ throw new Error("Could not identify target software or group");
+}
+
+function getTargetPath(): string {
+ const currentURL = URL.parse(location.href)!;
+ return currentURL.pathname.replace(/\/+[^\/]*\/?/, "/");
+}
+
+function getSelectedOption(): string | null {
+ try {
+ return findInputOrFail(instanceSelectForm, `input[name="${radioButtonName}"]:checked`).value;
+ } catch {
+ return null;
+ }
+}
+
+function autoRedirect(): boolean {
+ const targetSoftware = getTargetSoftwareOrGroup();
+ const preferredFor = storageManager.storage.instances.find(instance => instance.preferredFor?.includes(targetSoftware));
+ if (preferredFor) {
+ redirect(preferredFor.origin);
+ return true;
+ }
+ return false;
+}
+
+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();
+}
+
+function redirect(to: string) {
+ const url = URL.parse(to);
+ if (url === null) throw new Error("Couldn't parse destination");
+ url.pathname = getTargetPath();
+ location.href = url.toString();
+}
diff --git a/static/storage_manager.mts b/static/storage_manager.mts
index da88774..7ab3957 100644
--- a/static/storage_manager.mts
+++ b/static/storage_manager.mts
@@ -25,6 +25,11 @@ export type Instance = {
* 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[],
}
type LocalStorage = {
@@ -33,6 +38,7 @@ type LocalStorage = {
export default new class StorageManager {
storage: LocalStorage;
+ saveCallbacks: (() => void)[] = [];
constructor() {
this.load();
@@ -50,5 +56,10 @@ export default new class StorageManager {
save() {
window.localStorage.setItem("storage", JSON.stringify(this.storage));
+ this.saveCallbacks.forEach(c => c());
+ }
+
+ addSaveCallback(callback: () => void) {
+ this.saveCallbacks.push(callback);
}
}();
\ No newline at end of file