Fediverse Open on Main Server

Open Users or Notes on services that supports ActivityPub on your main Misskey server. Open the home page of this script and execute the user script command to set the main server.

As of 06/09/2023. See the latest version.

// ==UserScript==
// @name        Fediverse Open on Main Server
// @name:ja     Fediverse メインサーバーで開く
// @description Open Users or Notes on services that supports ActivityPub on your main Misskey server. Open the home page of this script and execute the user script command to set the main server.
// @description:ja ActivityPubに対応しているサービスのUser、またはNoteを、メインで利用しているMisskeyサーバーで開きます。このスクリプトのホームページを開いて、ユーザースクリプトコマンドを実行して、メインサーバーを指定してください。
// @namespace   https://greasyfork.org/users/137
// @version     0.1.0
// @match       https://greasyfork.org/*
// @match       https://mastodon.social/*
// @match       https://pawoo.net/*
// @match       https://mstdn.jp/*
// @match       https://misskey.io/*
// @match       https://mastodon.cloud/*
// @match       https://fedibird.com/*
// @match       https://nijimiss.moe/*
// @match       https://buicha.social/*
// @match       https://misskey.niri.la/*
// @match       https://vcasskey.net/*
// @require     https://greasyfork.org/scripts/19616/code/utilities.js?version=895049
// @license     MPL-2.0
// @contributionURL https://www.amazon.co.jp/registry/wishlist/E7PJ5C3K7AM2
// @compatible  Edge
// @compatible  Firefox Firefoxを推奨 / Firefox is recommended
// @compatible  Opera
// @compatible  Chrome
// @grant       GM.registerMenuCommand
// @grant       GM.setValue
// @grant       GM.getValue
// @grant       GM.deleteValue
// @run-at      document-start
// @noframes
// @icon        https://codeberg.org/fediverse/distributopia/raw/branch/main/all-logos-in-one-basket/public/basket/Fediverse_logo_proposal-1-min.svg
// @author      100の人
// @homepageURL https://greasyfork.org/users/137
// ==/UserScript==

/*global Gettext, _, h */

'use strict';

// L10N
Gettext.setLocalizedTexts({
	/*eslint-disable quote-props, max-len */
	'ja': {
		'Fediverse Open on Main Server': 'Fediverse メインサーバーで開く',
		'Fediverse Set your main server': 'Fediverse メインサーバーの設定',
		'Main server URL': 'メインサーバーのURL',
		'Add the URLs of the main server and the server to which you want to add the user script command to the “User @match” in the user script settings in the format like “https://example.com/*”.':
			'メインサーバー、およびユーザースクリプトコマンドを追加したいサーバーのURLを、「https://example.com/*」のような形式で、ユーザースクリプト設定の「ユーザー @match」へ追加してください。',
		'Cancel': 'キャンセル',
		'OK': 'OK',
		'Failed to look up.': '照会に失敗しました。',
		'Open the home page of this script and configure from the user script command.':
			'当スクリプトのホームページを開いて、ユーザースクリプトコマンドから設定を行ってください。',
	},
	/*eslint-enable quote-props, max-len */
});
Gettext.originalLocale = 'en';
Gettext.setLocale(navigator.language);

class HttpError extends Error
{
	name = 'HttpError';
	response;
	/**
	 * @param {Response} response
	 */
	constructor(response)
	{
		super(response.status + ' ' + response.statusText + '\n\n');
		this.response = response;
		response.text().then(body => {
			this.message += '\n\n' + body;
		});
	}
}

/**
 * @param {string} serverURL
 * @returns {Promise.<string>}
 */
async function miAuth(serverURL)
{
	const sessionId = crypto.randomUUID();
	await Promise.all([ GM.setValue('miAuthSessionId', sessionId), GM.setValue('urlWaitingMiAuth', location.href) ]);
	location.assign(`${serverURL}/miauth/${sessionId}?${new URLSearchParams({
		name: _('Fediverse Open on Main Server'),
		callback: serverURL,
	})}`);
}

/**
 * @param {string} accessToken
 * @param {string} url
 * @returns {Promise.<string>}
 */
async function lookUpOnMisskey(serverURL, accessToken, url)
{
	const response = await fetch(`${serverURL}/api/ap/show`, {
		method: 'POST',
		headers: { 'content-type': 'application/json' },
		body: JSON.stringify({ i: accessToken, uri: url }),
	});

	if (!response.ok) {
		return Promise.reject(new HttpError(response));
	}

	const { type, object: { username, host, id } } = await response.json();
	switch (type) {
		case 'User':
			return serverURL + '/@' + username + (host ? '@' + host : '');
		case 'Note':
			return serverURL + '/notes/' + id;
	}
}

switch (location.host) {
	case 'greasyfork.org': {
		/** @type {HTMLDialogElement} */
		let dialog, form;
		GM.registerMenuCommand(_('Fediverse Set your main server'), async function () {
			const [ url ] = await Promise.all([ 'url' ].map(name => GM.getValue(name, '')));
			if (!dialog) {
				document.body.insertAdjacentHTML('beforeend', h`<dialog>
					<form method="dialog">
						<input type="hidden" name="application" value="Misskey" />
						<p><label>
							${_('Main server URL')}
							<input type="url" name="url" placeholder="https://example.com" pattern="https?://[^\\/]+" />
						</label></p>
						<p>${_('Add the URLs of the main server and the server to which you want to add the user script command to the “User @match” in the user script settings in the format like “https://example.com/*”.' /* eslint-disable-line max-len */)}</p>
						<button name="cancel">${_('Cancel')}</button> <button>${_('OK')}</button>
					</form>
				</dialog>`);

				dialog = document.body.lastElementChild;
				form = dialog.getElementsByTagName('form')[0];
				form.url.addEventListener('change', function (event) {
					let url;
					try {
						url = new URL(event.target.value);
					} catch (exception) {
						if (exception.name !== 'TypeError') {
							throw exception;
						}
					}
					if (!url) {
						return;
					}
					event.target.value = url.origin;
				});
				form.addEventListener('submit', function (event) {
					if (event.submitter?.name === 'cancel') {
						event.preventDefault();
						dialog.close();
					}
				});
				form.addEventListener('formdata', function (event) {
					if (event.formData.get('url') !== url) {
						GM.deleteValue('accessToken');
					}

					for (const [ name, value ] of event.formData) {
						GM.setValue(name, value);
					}
				});
			}
			form.url.value = url;

			dialog.showModal();
		});
		break;
	}
	default:
		if (location.search.startsWith('?session=')) {
			// MiAuthで認証が終わった後のリダイレクトの可能性があれば
			Promise.all([ 'application', 'url', 'miAuthSessionId', 'urlWaitingMiAuth' ].map(name => GM.getValue(name)))
				.then(async function ([ application, serverURL, miAuthSessionId, urlWaitingMiAuth ]) {
					if (application !== 'Misskey' || location.origin !== serverURL) {
						return;
					}

					const session = new URLSearchParams(location.search).get('session');
					if (session !== miAuthSessionId) {
						return;
					}

					await Promise.all([ 'miAuthSessionId', 'urlWaitingMiAuth' ].map(name => GM.deleteValue(name)));

					// アクセストークンを取得
					const response
						= await fetch(`${serverURL}/api/miauth/${miAuthSessionId}/check`, { method: 'POST' });
					if (!response.ok) {
						console.error(response);
						return;
					}
					const { ok, token } = await response.json();
					if (!ok) {
						console.error(response);
						return;
					}

					await GM.setValue('accessToken', token);

					// 照会
					let url;
					try {
						url = await lookUpOnMisskey(serverURL, token, urlWaitingMiAuth);
					} catch (exception) {
						if (exception.name !== 'HttpError') {
							throw exception;
						}
						switch (exception.response.status) {
							case 500:
								alert(_('Failed to look up.')); //eslint-disable-line no-alert
								return;
							default:
								throw exception;
						}
					}
					location.assign(url);
				});
		} else {
			GM.registerMenuCommand(_('Fediverse Open on Main Server'), async function () {
				const [ application, serverURL, accessToken ]
					= await Promise.all([ 'application', 'url', 'accessToken' ].map(name => GM.getValue(name)));
				if (!application || !serverURL) {
					//eslint-disable-next-line no-alert
					alert(_('Open the home page of this script and configure from the user script command.'));
					return;
				}

				let url;
				switch (application) {
					case 'Misskey': {
						if (!accessToken) {
							await miAuth(serverURL);
							return;
						}

						try {
							url = await lookUpOnMisskey(serverURL, accessToken, location.href);
						} catch (exception) {
							if (exception.name !== 'HttpError') {
								throw exception;
							}
							switch (exception.response.status) {
								case 401:
									await miAuth(serverURL);
									return;
								case 500:
									break;
								default:
									throw exception;
							}
						}
					}
				}

				if (!url) {
					alert(_('Failed to look up.')); //eslint-disable-line no-alert
					return;
				}
				location.assign(url);
			});
		}
}