Merge pull request 'feat/redirect' (#2) from feat/redirect into main

Reviewed-on: https://git.gay/Nekomata/FeDirect/pulls/2
This commit is contained in:
CenTdemeern1 2025-01-20 05:36:47 +00:00 committed by git.gay
commit beab5a52a5
No known key found for this signature in database
GPG key ID: BDA2A7586B5E1432
5 changed files with 190 additions and 15 deletions

View file

@ -32,6 +32,7 @@
"name": "Akkoma", "name": "Akkoma",
"nodeinfoName": "akkoma", "nodeinfoName": "akkoma",
"aliases": [ "aliases": [
"akkoma",
"akko" "akko"
], ],
"groups": [ "groups": [
@ -93,6 +94,7 @@
"nodeinfoName": "mastodon", "nodeinfoName": "mastodon",
"buildMetadata": "glitch", "buildMetadata": "glitch",
"aliases": [ "aliases": [
"glitch-soc",
"glitch" "glitch"
], ],
"groups": [ "groups": [
@ -155,7 +157,8 @@
"name": "Iceshrimp.NET", "name": "Iceshrimp.NET",
"nodeinfoName": "Iceshrimp.NET", "nodeinfoName": "Iceshrimp.NET",
"aliases": [ "aliases": [
"iceshrimp-dotnet" "iceshrimp-dotnet",
"iceshrimp.net"
], ],
"groups": [ "groups": [
"misskey-compliant", "misskey-compliant",

View file

@ -2,6 +2,7 @@
--red: #cb0b0b; --red: #cb0b0b;
--blue: #2081c3; --blue: #2081c3;
--transparent-black: #0008; --transparent-black: #0008;
--xl: 4em;
--large: 2em; --large: 2em;
--medium: 1em; --medium: 1em;
} }
@ -53,6 +54,10 @@ abbr[title] {
text-decoration-color: var(--blue); text-decoration-color: var(--blue);
} }
.align-start {
text-align: start;
}
.flex-vcenter { .flex-vcenter {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -61,6 +66,14 @@ abbr[title] {
height: 100%; height: 100%;
} }
.flex-hcenter {
display: flex;
flex-direction: row;
justify-content: center;
width: 100%;
height: 100%;
}
.flex-row { .flex-row {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -71,11 +84,27 @@ abbr[title] {
flex-direction: row-reverse; flex-direction: row-reverse;
} }
.flex-column {
display: flex;
flex-direction: column;
}
.flex-column-reverse { .flex-column-reverse {
display: flex; display: flex;
flex-direction: column-reverse; flex-direction: column-reverse;
} }
.flex-vevenly {
display: flex;
flex-direction: column;
justify-content: space-evenly;
height: 100%;
}
.wfit-content {
width: fit-content;
}
.half-width { .half-width {
min-width: 50%; min-width: 50%;
} }
@ -119,4 +148,17 @@ abbr[title] {
top: 50%; top: 50%;
left: 50%; left: 50%;
translate: -50% -50%; translate: -50% -50%;
}
.logo {
height: 4em;
}
.inlineIcon {
height: var(--medium);
vertical-align: text-top;
}
.buttonPanel>* {
margin-top: min(var(--xl), 6vh);
} }

View file

@ -20,20 +20,23 @@
<h1>FeDirect</h1> <h1>FeDirect</h1>
<p class="margin-auto-top">&ThickSpace;By Nekomata</p> <p class="margin-auto-top">&ThickSpace;By Nekomata</p>
</div> </div>
<img src="/static/nekomata_small.png" alt="Nekomata Logo" style="height: 4em;" /> <img src="/static/nekomata_small.png" alt="Nekomata Logo" class="logo" />
</header> </header>
<div class="flex-row"> <div class="flex-row">
<div class="half-width"> <center class="half-width">
<form> <form id="instanceSelectForm" class="align-start wfit-content"></form>
<input id="radio" type="radio" />
<label for="radio">
Instances and stuff go here!
</label>
</form>
<br> <br>
<button onclick="showAddInstanceDialog()">Add an instance</button> <button onclick="showAddInstanceDialog()">Add an instance</button>
</center>
<div class="half-width">
<div class="flex-hcenter">
<div class="flex-column buttonPanel">
<button id="redirect">Redirect</button>
<button id="redirectAlways">Redirect always</button>
<a href="/">Manage instances</a>
</div>
</div>
</div> </div>
<div class="half-width"></div>
</div> </div>
</dialog> </dialog>
</div> </div>

View file

@ -1,14 +1,130 @@
import { initializeAddInstanceFlow } from "./add_instance_flow.mjs"; 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 { const radioButtonName = "instanceSelect";
return document.getElementById('mainDialog') as HTMLDialogElement;
}
const detailsDialog = findDialogOrFail(document.body, "#instanceDetails"); const detailsDialog = findDialogOrFail(document.body, "#instanceDetails");
const addDialog = findDialogOrFail(document.body, "#addInstance"); 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 { export const {
showAddInstanceDialog, showAddInstanceDialog,
hideAddInstanceDialog 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();
}

View file

@ -25,6 +25,11 @@ export type Instance = {
* Make sure to sanitize this! Could lead to XSS * Make sure to sanitize this! Could lead to XSS
*/ */
iconURL?: string, 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 = { type LocalStorage = {
@ -33,6 +38,7 @@ type LocalStorage = {
export default new class StorageManager { export default new class StorageManager {
storage: LocalStorage; storage: LocalStorage;
saveCallbacks: (() => void)[] = [];
constructor() { constructor() {
this.load(); this.load();
@ -50,5 +56,10 @@ export default new class StorageManager {
save() { save() {
window.localStorage.setItem("storage", JSON.stringify(this.storage)); window.localStorage.setItem("storage", JSON.stringify(this.storage));
this.saveCallbacks.forEach(c => c());
}
addSaveCallback(callback: () => void) {
this.saveCallbacks.push(callback);
} }
}(); }();