enhance: 非ログイン時には別サーバーに遷移できるように (#13089)
* enhance: 非ログイン時にはMisskey Hub経由で別サーバーに遷移できるように * fix * サーバーサイド照会を削除 * クライアント側の照会動作 * hubを経由せずにリモートで続行できるように * fix と pleaseLogin誘導箇所の追加 * fix * fix * Update CHANGELOG.md --------- Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
This commit is contained in:
		
							parent
							
								
									6dd6fcf88f
								
							
						
					
					
						commit
						3c032dd5b9
					
				
					 19 changed files with 330 additions and 113 deletions
				
			
		| 
						 | 
				
			
			@ -13,6 +13,7 @@
 | 
			
		|||
 | 
			
		||||
### Client
 | 
			
		||||
- Enhance: 内蔵APIドキュメントのデザイン・パフォーマンスを改善
 | 
			
		||||
- Enhance: 非ログイン時に他サーバーに遷移するアクションを追加
 | 
			
		||||
- Enhance: 非ログイン時のハイライトTLのデザインを改善
 | 
			
		||||
- Enhance: フロントエンドのアクセシビリティ改善  
 | 
			
		||||
  (Based on https://github.com/taiyme/misskey/pull/226)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										26
									
								
								locales/index.d.ts
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										26
									
								
								locales/index.d.ts
									
										
									
									
										vendored
									
									
								
							| 
						 | 
				
			
			@ -736,6 +736,22 @@ export interface Locale extends ILocale {
 | 
			
		|||
     * リモートで表示
 | 
			
		||||
     */
 | 
			
		||||
    "showOnRemote": string;
 | 
			
		||||
    /**
 | 
			
		||||
     * リモートで続行
 | 
			
		||||
     */
 | 
			
		||||
    "continueOnRemote": string;
 | 
			
		||||
    /**
 | 
			
		||||
     * Misskey Hubからサーバーを選択
 | 
			
		||||
     */
 | 
			
		||||
    "chooseServerOnMisskeyHub": string;
 | 
			
		||||
    /**
 | 
			
		||||
     * サーバーのドメインを直接指定
 | 
			
		||||
     */
 | 
			
		||||
    "specifyServerHost": string;
 | 
			
		||||
    /**
 | 
			
		||||
     * ドメインを入力してください
 | 
			
		||||
     */
 | 
			
		||||
    "inputHostName": string;
 | 
			
		||||
    /**
 | 
			
		||||
     * 全般
 | 
			
		||||
     */
 | 
			
		||||
| 
						 | 
				
			
			@ -1921,9 +1937,13 @@ export interface Locale extends ILocale {
 | 
			
		|||
     */
 | 
			
		||||
    "onlyOneFileCanBeAttached": string;
 | 
			
		||||
    /**
 | 
			
		||||
     * 続行する前に、サインアップまたはサインインが必要です
 | 
			
		||||
     * 続行する前に、登録またはログインが必要です
 | 
			
		||||
     */
 | 
			
		||||
    "signinRequired": string;
 | 
			
		||||
    /**
 | 
			
		||||
     * 続行するには、お使いのサーバーに移動するか、このサーバーに登録・ログインする必要があります
 | 
			
		||||
     */
 | 
			
		||||
    "signinOrContinueOnRemote": string;
 | 
			
		||||
    /**
 | 
			
		||||
     * 招待
 | 
			
		||||
     */
 | 
			
		||||
| 
						 | 
				
			
			@ -4984,6 +5004,10 @@ export interface Locale extends ILocale {
 | 
			
		|||
     * お問い合わせ
 | 
			
		||||
     */
 | 
			
		||||
    "inquiry": string;
 | 
			
		||||
     /**
 | 
			
		||||
     * もう一度お試しください。
 | 
			
		||||
     */
 | 
			
		||||
    "tryAgain": string;
 | 
			
		||||
    "_delivery": {
 | 
			
		||||
        /**
 | 
			
		||||
         * 配信状態
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -180,6 +180,10 @@ addAccount: "アカウントを追加"
 | 
			
		|||
reloadAccountsList: "アカウントリストの情報を更新"
 | 
			
		||||
loginFailed: "ログインに失敗しました"
 | 
			
		||||
showOnRemote: "リモートで表示"
 | 
			
		||||
continueOnRemote: "リモートで続行"
 | 
			
		||||
chooseServerOnMisskeyHub: "Misskey Hubからサーバーを選択"
 | 
			
		||||
specifyServerHost: "サーバーのドメインを直接指定"
 | 
			
		||||
inputHostName: "ドメインを入力してください"
 | 
			
		||||
general: "全般"
 | 
			
		||||
wallpaper: "壁紙"
 | 
			
		||||
setWallpaper: "壁紙を設定"
 | 
			
		||||
| 
						 | 
				
			
			@ -476,7 +480,8 @@ attachAsFileQuestion: "クリップボードのテキストが長いです。テ
 | 
			
		|||
noMessagesYet: "まだチャットはありません"
 | 
			
		||||
newMessageExists: "新しいメッセージがあります"
 | 
			
		||||
onlyOneFileCanBeAttached: "メッセージに添付できるファイルはひとつです"
 | 
			
		||||
signinRequired: "続行する前に、サインアップまたはサインインが必要です"
 | 
			
		||||
signinRequired: "続行する前に、登録またはログインが必要です"
 | 
			
		||||
signinOrContinueOnRemote: "続行するには、お使いのサーバーに移動するか、このサーバーに登録・ログインする必要があります"
 | 
			
		||||
invitations: "招待"
 | 
			
		||||
invitationCode: "招待コード"
 | 
			
		||||
checking: "確認しています"
 | 
			
		||||
| 
						 | 
				
			
			@ -1242,6 +1247,7 @@ keepOriginalFilenameDescription: "この設定をオフにすると、アップ
 | 
			
		|||
noDescription: "説明文はありません"
 | 
			
		||||
alwaysConfirmFollow: "フォローの際常に確認する"
 | 
			
		||||
inquiry: "お問い合わせ"
 | 
			
		||||
tryAgain: "もう一度お試しください。"
 | 
			
		||||
 | 
			
		||||
_delivery:
 | 
			
		||||
  status: "配信状態"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -42,6 +42,8 @@ import { misskeyApi } from '@/scripts/misskey-api.js';
 | 
			
		|||
import { useStream } from '@/stream.js';
 | 
			
		||||
import { i18n } from '@/i18n.js';
 | 
			
		||||
import { claimAchievement } from '@/scripts/achievements.js';
 | 
			
		||||
import { pleaseLogin } from '@/scripts/please-login.js';
 | 
			
		||||
import { host } from '@/config.js';
 | 
			
		||||
import { $i } from '@/account.js';
 | 
			
		||||
import { defaultStore } from '@/store.js';
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -63,7 +65,7 @@ const hasPendingFollowRequestFromYou = ref(props.user.hasPendingFollowRequestFro
 | 
			
		|||
const wait = ref(false);
 | 
			
		||||
const connection = useStream().useChannel('main');
 | 
			
		||||
 | 
			
		||||
if (props.user.isFollowing == null) {
 | 
			
		||||
if (props.user.isFollowing == null && $i) {
 | 
			
		||||
	misskeyApi('users/show', {
 | 
			
		||||
		userId: props.user.id,
 | 
			
		||||
	})
 | 
			
		||||
| 
						 | 
				
			
			@ -78,6 +80,8 @@ function onFollowChange(user: Misskey.entities.UserDetailed) {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
async function onClick() {
 | 
			
		||||
	pleaseLogin(undefined, { type: 'web', path: `/@${props.user.username}@${props.user.host ?? host}` });
 | 
			
		||||
 | 
			
		||||
	wait.value = true;
 | 
			
		||||
 | 
			
		||||
	try {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -196,6 +196,7 @@ import { MenuItem } from '@/types/menu.js';
 | 
			
		|||
import MkRippleEffect from '@/components/MkRippleEffect.vue';
 | 
			
		||||
import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
 | 
			
		||||
import { shouldCollapsed } from '@/scripts/collapsed.js';
 | 
			
		||||
import { host } from '@/config.js';
 | 
			
		||||
import { isEnabledUrlPreview } from '@/instance.js';
 | 
			
		||||
import { type Keymap } from '@/scripts/hotkey.js';
 | 
			
		||||
import { focusPrev, focusNext } from '@/scripts/focus.js';
 | 
			
		||||
| 
						 | 
				
			
			@ -278,6 +279,11 @@ const renoteCollapsed = ref(
 | 
			
		|||
	),
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const pleaseLoginContext = {
 | 
			
		||||
	type: 'lookup',
 | 
			
		||||
	path: `https://${host}/notes/${appearNote.value.id}`,
 | 
			
		||||
} as const;
 | 
			
		||||
 | 
			
		||||
/* Overload FunctionにLintが対応していないのでコメントアウト
 | 
			
		||||
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: true): boolean;
 | 
			
		||||
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: false): boolean | 'sensitiveMute';
 | 
			
		||||
| 
						 | 
				
			
			@ -411,7 +417,7 @@ if (!props.mock) {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
function renote(viaKeyboard = false) {
 | 
			
		||||
	pleaseLogin();
 | 
			
		||||
	pleaseLogin(undefined, pleaseLoginContext);
 | 
			
		||||
	showMovedDialog();
 | 
			
		||||
 | 
			
		||||
	const { menu } = getRenoteMenu({ note: note.value, renoteButton, mock: props.mock });
 | 
			
		||||
| 
						 | 
				
			
			@ -421,7 +427,7 @@ function renote(viaKeyboard = false) {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
function reply(): void {
 | 
			
		||||
	pleaseLogin();
 | 
			
		||||
	pleaseLogin(undefined, pleaseLoginContext);
 | 
			
		||||
	if (props.mock) {
 | 
			
		||||
		return;
 | 
			
		||||
	}
 | 
			
		||||
| 
						 | 
				
			
			@ -434,7 +440,7 @@ function reply(): void {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
function react(): void {
 | 
			
		||||
	pleaseLogin();
 | 
			
		||||
	pleaseLogin(undefined, pleaseLoginContext);
 | 
			
		||||
	showMovedDialog();
 | 
			
		||||
	if (appearNote.value.reactionAcceptance === 'likeOnly') {
 | 
			
		||||
		sound.playMisskeySfx('reaction');
 | 
			
		||||
| 
						 | 
				
			
			@ -565,7 +571,7 @@ function showRenoteMenu(): void {
 | 
			
		|||
	}
 | 
			
		||||
 | 
			
		||||
	if (isMyRenote) {
 | 
			
		||||
		pleaseLogin();
 | 
			
		||||
		pleaseLogin(undefined, pleaseLoginContext);
 | 
			
		||||
		os.popupMenu([
 | 
			
		||||
			getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote),
 | 
			
		||||
			{ type: 'divider' },
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -222,6 +222,7 @@ import { reactionPicker } from '@/scripts/reaction-picker.js';
 | 
			
		|||
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js';
 | 
			
		||||
import { $i } from '@/account.js';
 | 
			
		||||
import { i18n } from '@/i18n.js';
 | 
			
		||||
import { host } from '@/config.js';
 | 
			
		||||
import { getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/scripts/get-note-menu.js';
 | 
			
		||||
import { useNoteCapture } from '@/scripts/use-note-capture.js';
 | 
			
		||||
import { deepClone } from '@/scripts/clone.js';
 | 
			
		||||
| 
						 | 
				
			
			@ -296,6 +297,11 @@ const conversation = ref<Misskey.entities.Note[]>([]);
 | 
			
		|||
const replies = ref<Misskey.entities.Note[]>([]);
 | 
			
		||||
const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || appearNote.value.userId === $i?.id);
 | 
			
		||||
 | 
			
		||||
const pleaseLoginContext = {
 | 
			
		||||
	type: 'lookup',
 | 
			
		||||
	path: `https://${host}/notes/${appearNote.value.id}`,
 | 
			
		||||
} as const;
 | 
			
		||||
 | 
			
		||||
const keymap = {
 | 
			
		||||
	'r': () => reply(),
 | 
			
		||||
	'e|a|plus': () => react(),
 | 
			
		||||
| 
						 | 
				
			
			@ -396,7 +402,7 @@ if (appearNote.value.reactionAcceptance === 'likeOnly') {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
function renote() {
 | 
			
		||||
	pleaseLogin();
 | 
			
		||||
	pleaseLogin(undefined, pleaseLoginContext);
 | 
			
		||||
	showMovedDialog();
 | 
			
		||||
 | 
			
		||||
	const { menu } = getRenoteMenu({ note: note.value, renoteButton });
 | 
			
		||||
| 
						 | 
				
			
			@ -404,7 +410,7 @@ function renote() {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
function reply(): void {
 | 
			
		||||
	pleaseLogin();
 | 
			
		||||
	pleaseLogin(undefined, pleaseLoginContext);
 | 
			
		||||
	showMovedDialog();
 | 
			
		||||
	os.post({
 | 
			
		||||
		reply: appearNote.value,
 | 
			
		||||
| 
						 | 
				
			
			@ -415,7 +421,7 @@ function reply(): void {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
function react(): void {
 | 
			
		||||
	pleaseLogin();
 | 
			
		||||
	pleaseLogin(undefined, pleaseLoginContext);
 | 
			
		||||
	showMovedDialog();
 | 
			
		||||
	if (appearNote.value.reactionAcceptance === 'likeOnly') {
 | 
			
		||||
		sound.playMisskeySfx('reaction');
 | 
			
		||||
| 
						 | 
				
			
			@ -499,7 +505,7 @@ async function clip(): Promise<void> {
 | 
			
		|||
 | 
			
		||||
function showRenoteMenu(): void {
 | 
			
		||||
	if (!isMyRenote) return;
 | 
			
		||||
	pleaseLogin();
 | 
			
		||||
	pleaseLogin(undefined, pleaseLoginContext);
 | 
			
		||||
	os.popupMenu([{
 | 
			
		||||
		text: i18n.ts.unrenote,
 | 
			
		||||
		icon: 'ti ti-trash',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -34,6 +34,7 @@ import { pleaseLogin } from '@/scripts/please-login.js';
 | 
			
		|||
import * as os from '@/os.js';
 | 
			
		||||
import { misskeyApi } from '@/scripts/misskey-api.js';
 | 
			
		||||
import { i18n } from '@/i18n.js';
 | 
			
		||||
import { host } from '@/config.js';
 | 
			
		||||
import { useInterval } from '@/scripts/use-interval.js';
 | 
			
		||||
 | 
			
		||||
const props = defineProps<{
 | 
			
		||||
| 
						 | 
				
			
			@ -60,6 +61,11 @@ const timer = computed(() => i18n.tsx._poll[
 | 
			
		|||
 | 
			
		||||
const showResult = ref(props.readOnly || isVoted.value);
 | 
			
		||||
 | 
			
		||||
const pleaseLoginContext = {
 | 
			
		||||
	type: 'lookup',
 | 
			
		||||
	path: `https://${host}/notes/${props.note.id}`,
 | 
			
		||||
} as const;
 | 
			
		||||
 | 
			
		||||
// 期限付きアンケート
 | 
			
		||||
if (props.poll.expiresAt) {
 | 
			
		||||
	const tick = () => {
 | 
			
		||||
| 
						 | 
				
			
			@ -76,7 +82,7 @@ if (props.poll.expiresAt) {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
const vote = async (id) => {
 | 
			
		||||
	pleaseLogin();
 | 
			
		||||
	pleaseLogin(undefined, pleaseLoginContext);
 | 
			
		||||
 | 
			
		||||
	if (props.readOnly || closed.value || isVoted.value) return;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -6,10 +6,23 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
<template>
 | 
			
		||||
<form :class="{ signing, totpLogin }" @submit.prevent="onSubmit">
 | 
			
		||||
	<div class="_gaps_m">
 | 
			
		||||
		<div v-show="withAvatar" :class="$style.avatar" :style="{ backgroundImage: user ? `url('${ user.avatarUrl }')` : undefined, marginBottom: message ? '1.5em' : undefined }"></div>
 | 
			
		||||
		<div v-show="withAvatar" :class="$style.avatar" :style="{ backgroundImage: user ? `url('${user.avatarUrl}')` : undefined, marginBottom: message ? '1.5em' : undefined }"></div>
 | 
			
		||||
		<MkInfo v-if="message">
 | 
			
		||||
			{{ message }}
 | 
			
		||||
		</MkInfo>
 | 
			
		||||
		<div v-if="openOnRemote" class="_gaps_m">
 | 
			
		||||
			<div class="_gaps_s">
 | 
			
		||||
				<MkButton type="button" rounded primary style="margin: 0 auto;" @click="openRemote(openOnRemote)">
 | 
			
		||||
					{{ i18n.ts.continueOnRemote }} <i class="ti ti-external-link"></i>
 | 
			
		||||
				</MkButton>
 | 
			
		||||
				<button type="button" class="_button" :class="$style.instanceManualSelectButton" @click="specifyHostAndOpenRemote(openOnRemote)">
 | 
			
		||||
					{{ i18n.ts.specifyServerHost }}
 | 
			
		||||
				</button>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div :class="$style.orHr">
 | 
			
		||||
				<p :class="$style.orMsg">{{ i18n.ts.or }}</p>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
		<div v-if="!totpLogin" class="normal-signin _gaps_m">
 | 
			
		||||
			<MkInput v-model="username" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autocomplete="username webauthn" autofocus required data-cy-signin-username @update:modelValue="onUsernameChange">
 | 
			
		||||
				<template #prefix>@</template>
 | 
			
		||||
| 
						 | 
				
			
			@ -28,8 +41,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
					{{ i18n.ts.retry }}
 | 
			
		||||
				</MkButton>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div v-if="user && user.securityKeys" class="or-hr">
 | 
			
		||||
				<p class="or-msg">{{ i18n.ts.or }}</p>
 | 
			
		||||
			<div v-if="user && user.securityKeys" :class="$style.orHr">
 | 
			
		||||
				<p :class="$style.orMsg">{{ i18n.ts.or }}</p>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div class="twofa-group totp-group _gaps">
 | 
			
		||||
				<MkInput v-if="user && user.usePasswordLessLogin" v-model="password" type="password" autocomplete="current-password" :withPasswordToggle="true" required>
 | 
			
		||||
| 
						 | 
				
			
			@ -53,6 +66,7 @@ import { defineAsyncComponent, ref } from 'vue';
 | 
			
		|||
import { toUnicode } from 'punycode/';
 | 
			
		||||
import * as Misskey from 'misskey-js';
 | 
			
		||||
import { supported as webAuthnSupported, get as webAuthnRequest, parseRequestOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill';
 | 
			
		||||
import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
 | 
			
		||||
import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js';
 | 
			
		||||
import MkButton from '@/components/MkButton.vue';
 | 
			
		||||
import MkInput from '@/components/MkInput.vue';
 | 
			
		||||
| 
						 | 
				
			
			@ -60,6 +74,7 @@ import MkInfo from '@/components/MkInfo.vue';
 | 
			
		|||
import { host as configHost } from '@/config.js';
 | 
			
		||||
import * as os from '@/os.js';
 | 
			
		||||
import { misskeyApi } from '@/scripts/misskey-api.js';
 | 
			
		||||
import { query, extractDomain } from '@/scripts/url.js';
 | 
			
		||||
import { login } from '@/account.js';
 | 
			
		||||
import { i18n } from '@/i18n.js';
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -78,22 +93,16 @@ const emit = defineEmits<{
 | 
			
		|||
	(ev: 'login', v: any): void;
 | 
			
		||||
}>();
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
	withAvatar: {
 | 
			
		||||
		type: Boolean,
 | 
			
		||||
		required: false,
 | 
			
		||||
		default: true,
 | 
			
		||||
	},
 | 
			
		||||
	autoSet: {
 | 
			
		||||
		type: Boolean,
 | 
			
		||||
		required: false,
 | 
			
		||||
		default: false,
 | 
			
		||||
	},
 | 
			
		||||
	message: {
 | 
			
		||||
		type: String,
 | 
			
		||||
		required: false,
 | 
			
		||||
		default: '',
 | 
			
		||||
	},
 | 
			
		||||
const props = withDefaults(defineProps<{
 | 
			
		||||
	withAvatar?: boolean;
 | 
			
		||||
	autoSet?: boolean;
 | 
			
		||||
	message?: string,
 | 
			
		||||
	openOnRemote?: OpenOnRemoteOptions,
 | 
			
		||||
}>(), {
 | 
			
		||||
	withAvatar: true,
 | 
			
		||||
	autoSet: false,
 | 
			
		||||
	message: '',
 | 
			
		||||
	openOnRemote: undefined,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
function onUsernameChange(): void {
 | 
			
		||||
| 
						 | 
				
			
			@ -222,6 +231,60 @@ function resetPassword(): void {
 | 
			
		|||
		closed: () => dispose(),
 | 
			
		||||
	});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function openRemote(options: OpenOnRemoteOptions, targetHost?: string): void {
 | 
			
		||||
	switch (options.type) {
 | 
			
		||||
		case 'web':
 | 
			
		||||
		case 'lookup': {
 | 
			
		||||
			let _path = options.path;
 | 
			
		||||
 | 
			
		||||
			if (options.type === 'lookup') {
 | 
			
		||||
				// TODO: v2024.2.0以降が浸透してきたら正式なURLに変更する▼
 | 
			
		||||
				// _path = `/lookup?uri=${encodeURIComponent(_path)}`;
 | 
			
		||||
				_path = `/authorize-follow?acct=${encodeURIComponent(_path)}`;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if (targetHost) {
 | 
			
		||||
				window.open(`https://${targetHost}${_path}`, '_blank', 'noopener');
 | 
			
		||||
			} else {
 | 
			
		||||
				window.open(`https://misskey-hub.net/mi-web/?path=${encodeURIComponent(_path)}`, '_blank', 'noopener');
 | 
			
		||||
			}
 | 
			
		||||
			break;
 | 
			
		||||
		}
 | 
			
		||||
		case 'share': {
 | 
			
		||||
			const params = query(options.params);			
 | 
			
		||||
			if (targetHost) {
 | 
			
		||||
				window.open(`https://${targetHost}/share?${params}`, '_blank', 'noopener');
 | 
			
		||||
			} else {
 | 
			
		||||
				window.open(`https://misskey-hub.net/share/?${params}`, '_blank', 'noopener');
 | 
			
		||||
			}
 | 
			
		||||
			break;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function specifyHostAndOpenRemote(options: OpenOnRemoteOptions): Promise<void> {
 | 
			
		||||
	const { canceled, result: hostTemp } = await os.inputText({
 | 
			
		||||
		title: i18n.ts.inputHostName,
 | 
			
		||||
		placeholder: 'misskey.example.com',
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	if (canceled) return;
 | 
			
		||||
 | 
			
		||||
	let targetHost: string | null = hostTemp;
 | 
			
		||||
 | 
			
		||||
	// ドメイン部分だけを取り出す
 | 
			
		||||
	targetHost = extractDomain(targetHost);
 | 
			
		||||
	if (targetHost == null) {
 | 
			
		||||
		os.alert({
 | 
			
		||||
			type: 'error',
 | 
			
		||||
			title: i18n.ts.invalidValue,
 | 
			
		||||
			text: i18n.ts.tryAgain,
 | 
			
		||||
		});
 | 
			
		||||
		return;
 | 
			
		||||
	}
 | 
			
		||||
	openRemote(options, targetHost);
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" module>
 | 
			
		||||
| 
						 | 
				
			
			@ -234,4 +297,36 @@ function resetPassword(): void {
 | 
			
		|||
	background-size: cover;
 | 
			
		||||
	border-radius: 100%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.instanceManualSelectButton {
 | 
			
		||||
	display: block;
 | 
			
		||||
	text-align: center;
 | 
			
		||||
	opacity: .7;
 | 
			
		||||
	font-size: .8em;
 | 
			
		||||
 | 
			
		||||
	&:hover {
 | 
			
		||||
		text-decoration: underline;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.orHr {
 | 
			
		||||
	position: relative;
 | 
			
		||||
	margin: .4em auto;
 | 
			
		||||
	width: 100%;
 | 
			
		||||
	height: 1px;
 | 
			
		||||
	background: var(--divider);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.orMsg {
 | 
			
		||||
	position: absolute;
 | 
			
		||||
	top: -.6em;
 | 
			
		||||
	display: inline-block;
 | 
			
		||||
	padding: 0 1em;
 | 
			
		||||
	background: var(--panel);
 | 
			
		||||
	font-size: 0.8em;
 | 
			
		||||
	color: var(--fgOnPanel);
 | 
			
		||||
	margin: 0;
 | 
			
		||||
	left: 50%;
 | 
			
		||||
	transform: translateX(-50%);
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -6,21 +6,22 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
<template>
 | 
			
		||||
<MkModalWindow
 | 
			
		||||
	ref="dialog"
 | 
			
		||||
	:width="370"
 | 
			
		||||
	:height="400"
 | 
			
		||||
	:width="400"
 | 
			
		||||
	:height="430"
 | 
			
		||||
	@close="onClose"
 | 
			
		||||
	@closed="emit('closed')"
 | 
			
		||||
>
 | 
			
		||||
	<template #header>{{ i18n.ts.login }}</template>
 | 
			
		||||
 | 
			
		||||
	<MkSpacer :marginMin="20" :marginMax="28">
 | 
			
		||||
		<MkSignin :autoSet="autoSet" :message="message" @login="onLogin"/>
 | 
			
		||||
		<MkSignin :autoSet="autoSet" :message="message" :openOnRemote="openOnRemote" @login="onLogin"/>
 | 
			
		||||
	</MkSpacer>
 | 
			
		||||
</MkModalWindow>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { shallowRef } from 'vue';
 | 
			
		||||
import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
 | 
			
		||||
import MkSignin from '@/components/MkSignin.vue';
 | 
			
		||||
import MkModalWindow from '@/components/MkModalWindow.vue';
 | 
			
		||||
import { i18n } from '@/i18n.js';
 | 
			
		||||
| 
						 | 
				
			
			@ -28,9 +29,11 @@ import { i18n } from '@/i18n.js';
 | 
			
		|||
withDefaults(defineProps<{
 | 
			
		||||
	autoSet?: boolean;
 | 
			
		||||
	message?: string,
 | 
			
		||||
	openOnRemote?: OpenOnRemoteOptions,
 | 
			
		||||
}>(), {
 | 
			
		||||
	autoSet: false,
 | 
			
		||||
	message: '',
 | 
			
		||||
	openOnRemote: undefined,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const emit = defineEmits<{
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -29,7 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
			<p :class="$style.statusItemLabel">{{ i18n.ts.followers }}</p><span :class="$style.statusItemValue">{{ number(user.followersCount) }}</span>
 | 
			
		||||
		</div>
 | 
			
		||||
	</div>
 | 
			
		||||
	<MkFollowButton v-if="$i && user.id != $i.id" :class="$style.follow" :user="user" mini/>
 | 
			
		||||
	<MkFollowButton v-if="user.id != $i?.id" :class="$style.follow" :user="user" mini/>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -23,6 +23,7 @@ import MkPopupMenu from '@/components/MkPopupMenu.vue';
 | 
			
		|||
import MkContextMenu from '@/components/MkContextMenu.vue';
 | 
			
		||||
import { MenuItem } from '@/types/menu.js';
 | 
			
		||||
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
 | 
			
		||||
import { pleaseLogin } from '@/scripts/please-login.js';
 | 
			
		||||
import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
 | 
			
		||||
import { getHTMLElementOrNull } from '@/scripts/get-dom-node-or-null.js';
 | 
			
		||||
import { focusParent } from '@/scripts/focus.js';
 | 
			
		||||
| 
						 | 
				
			
			@ -670,6 +671,15 @@ export function contextMenu(items: MenuItem[], ev: MouseEvent): Promise<void> {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
export function post(props: Record<string, any> = {}): Promise<void> {
 | 
			
		||||
	pleaseLogin(undefined, (props.initialText || props.initialNote ? {
 | 
			
		||||
		type: 'share',
 | 
			
		||||
		params: {
 | 
			
		||||
			text: props.initialText ?? props.initialNote.text,
 | 
			
		||||
			visibility: props.initialVisibility ?? props.initialNote?.visibility,
 | 
			
		||||
			localOnly: (props.initialLocalOnly || props.initialNote?.localOnly) ? '1' : '0',
 | 
			
		||||
		},
 | 
			
		||||
	} : undefined));
 | 
			
		||||
 | 
			
		||||
	showMovedDialog();
 | 
			
		||||
	return new Promise(resolve => {
 | 
			
		||||
		// NOTE: MkPostFormDialogをdynamic importするとiOSでテキストエリアに自動フォーカスできない
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -79,6 +79,7 @@ import { defaultStore } from '@/store.js';
 | 
			
		|||
import { $i } from '@/account.js';
 | 
			
		||||
import { isSupportShare } from '@/scripts/navigator.js';
 | 
			
		||||
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
 | 
			
		||||
import { pleaseLogin } from '@/scripts/please-login.js';
 | 
			
		||||
 | 
			
		||||
const props = defineProps<{
 | 
			
		||||
	id: string;
 | 
			
		||||
| 
						 | 
				
			
			@ -143,6 +144,7 @@ function shareWithNote() {
 | 
			
		|||
 | 
			
		||||
function like() {
 | 
			
		||||
	if (!flash.value) return;
 | 
			
		||||
	pleaseLogin();
 | 
			
		||||
 | 
			
		||||
	os.apiWithDialog('flash/like', {
 | 
			
		||||
		flashId: flash.value.id,
 | 
			
		||||
| 
						 | 
				
			
			@ -154,6 +156,7 @@ function like() {
 | 
			
		|||
 | 
			
		||||
async function unlike() {
 | 
			
		||||
	if (!flash.value) return;
 | 
			
		||||
	pleaseLogin();
 | 
			
		||||
 | 
			
		||||
	const confirm = await os.confirm({
 | 
			
		||||
		type: 'warning',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,71 +0,0 @@
 | 
			
		|||
<!--
 | 
			
		||||
SPDX-FileCopyrightText: syuilo and misskey-project
 | 
			
		||||
SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
-->
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
<div>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { } from 'vue';
 | 
			
		||||
import * as Misskey from 'misskey-js';
 | 
			
		||||
import * as os from '@/os.js';
 | 
			
		||||
import { misskeyApi } from '@/scripts/misskey-api.js';
 | 
			
		||||
import { i18n } from '@/i18n.js';
 | 
			
		||||
import { defaultStore } from '@/store.js';
 | 
			
		||||
import { mainRouter } from '@/router/main.js';
 | 
			
		||||
 | 
			
		||||
async function follow(user): Promise<void> {
 | 
			
		||||
	const { canceled } = await os.confirm({
 | 
			
		||||
		type: 'question',
 | 
			
		||||
		text: i18n.tsx.followConfirm({ name: user.name || user.username }),
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	if (canceled) {
 | 
			
		||||
		window.close();
 | 
			
		||||
		return;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	os.apiWithDialog('following/create', {
 | 
			
		||||
		userId: user.id,
 | 
			
		||||
		withReplies: defaultStore.state.defaultWithReplies,
 | 
			
		||||
	});
 | 
			
		||||
	user.withReplies = defaultStore.state.defaultWithReplies;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const acct = new URL(location.href).searchParams.get('acct');
 | 
			
		||||
if (acct == null) {
 | 
			
		||||
	throw new Error('acct required');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
let promise;
 | 
			
		||||
 | 
			
		||||
if (acct.startsWith('https://')) {
 | 
			
		||||
	promise = misskeyApi('ap/show', {
 | 
			
		||||
		uri: acct,
 | 
			
		||||
	});
 | 
			
		||||
	promise.then(res => {
 | 
			
		||||
		if (res.type === 'User') {
 | 
			
		||||
			follow(res.object);
 | 
			
		||||
		} else if (res.type === 'Note') {
 | 
			
		||||
			mainRouter.push(`/notes/${res.object.id}`);
 | 
			
		||||
		} else {
 | 
			
		||||
			os.alert({
 | 
			
		||||
				type: 'error',
 | 
			
		||||
				text: 'Not a user',
 | 
			
		||||
			}).then(() => {
 | 
			
		||||
				window.close();
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
	});
 | 
			
		||||
} else {
 | 
			
		||||
	promise = misskeyApi('users/show', Misskey.acct.parse(acct));
 | 
			
		||||
	promise.then(user => {
 | 
			
		||||
		follow(user);
 | 
			
		||||
	});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject);
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										97
									
								
								packages/frontend/src/pages/lookup.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								packages/frontend/src/pages/lookup.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,97 @@
 | 
			
		|||
<!--
 | 
			
		||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
 | 
			
		||||
SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
-->
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
<MkStickyContainer>
 | 
			
		||||
	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
 | 
			
		||||
	<MkSpacer :contentMax="800">
 | 
			
		||||
		<div v-if="state === 'done'" class="_buttonsCenter">
 | 
			
		||||
			<MkButton @click="close">{{ i18n.ts.close }}</MkButton>
 | 
			
		||||
			<MkButton @click="goToMisskey">{{ i18n.ts.goToMisskey }}</MkButton>
 | 
			
		||||
		</div>
 | 
			
		||||
		<div v-else class="_fullInfo">
 | 
			
		||||
			<MkLoading/>
 | 
			
		||||
		</div>
 | 
			
		||||
	</MkSpacer>
 | 
			
		||||
</MkStickyContainer>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { computed, ref } from 'vue';
 | 
			
		||||
import * as Misskey from 'misskey-js';
 | 
			
		||||
import * as os from '@/os.js';
 | 
			
		||||
import { misskeyApi } from '@/scripts/misskey-api.js';
 | 
			
		||||
import { i18n } from '@/i18n.js';
 | 
			
		||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
 | 
			
		||||
import { mainRouter } from '@/router/main.js';
 | 
			
		||||
import MkButton from '@/components/MkButton.vue';
 | 
			
		||||
 | 
			
		||||
const state = ref<'fetching' | 'done'>('fetching');
 | 
			
		||||
 | 
			
		||||
function fetch() {
 | 
			
		||||
	const params = new URL(location.href).searchParams;
 | 
			
		||||
 | 
			
		||||
	// acctのほうはdeprecated
 | 
			
		||||
	let uri = params.get('uri') ?? params.get('acct');
 | 
			
		||||
	if (uri == null) {
 | 
			
		||||
		state.value = 'done';
 | 
			
		||||
		return;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	let promise: Promise<any>;
 | 
			
		||||
 | 
			
		||||
	if (uri.startsWith('https://')) {
 | 
			
		||||
		promise = misskeyApi('ap/show', {
 | 
			
		||||
			uri,
 | 
			
		||||
		});
 | 
			
		||||
		promise.then(res => {
 | 
			
		||||
			if (res.type === 'User') {
 | 
			
		||||
				mainRouter.replace(res.object.host ? `/@${res.object.username}@${res.object.host}` : `/@${res.object.username}`);
 | 
			
		||||
			} else if (res.type === 'Note') {
 | 
			
		||||
				mainRouter.replace(`/notes/${res.object.id}`);
 | 
			
		||||
			} else {
 | 
			
		||||
				os.alert({
 | 
			
		||||
					type: 'error',
 | 
			
		||||
					text: 'Not a user',
 | 
			
		||||
				});
 | 
			
		||||
			}
 | 
			
		||||
		});
 | 
			
		||||
	} else {
 | 
			
		||||
		if (uri.startsWith('acct:')) {
 | 
			
		||||
			uri = uri.slice(5);
 | 
			
		||||
		}
 | 
			
		||||
		promise = misskeyApi('users/show', Misskey.acct.parse(uri));
 | 
			
		||||
		promise.then(user => {
 | 
			
		||||
			mainRouter.replace(user.host ? `/@${user.username}@${user.host}` : `/@${user.username}`);
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function close(): void {
 | 
			
		||||
	window.close();
 | 
			
		||||
 | 
			
		||||
	// 閉じなければ100ms後タイムラインに
 | 
			
		||||
	window.setTimeout(() => {
 | 
			
		||||
		location.href = '/';
 | 
			
		||||
	}, 100);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function goToMisskey(): void {
 | 
			
		||||
	location.href = '/';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fetch();
 | 
			
		||||
 | 
			
		||||
const headerActions = computed(() => []);
 | 
			
		||||
 | 
			
		||||
const headerTabs = computed(() => []);
 | 
			
		||||
 | 
			
		||||
definePageMetadata({
 | 
			
		||||
	title: i18n.ts.lookup,
 | 
			
		||||
	icon: 'ti ti-world-search',
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
| 
						 | 
				
			
			@ -32,9 +32,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
						<span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ i18n.ts.followsYou }}</span>
 | 
			
		||||
						<div v-if="$i" class="actions">
 | 
			
		||||
						<div class="actions">
 | 
			
		||||
							<button class="menu _button" @click="menu"><i class="ti ti-dots"></i></button>
 | 
			
		||||
							<MkFollowButton v-if="$i.id != user.id" v-model:user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/>
 | 
			
		||||
							<MkFollowButton v-if="$i?.id != user.id" v-model:user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
					<MkAvatar class="avatar" :user="user" indicator/>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -237,8 +237,18 @@ const routes: RouteDef[] = [{
 | 
			
		|||
		origin: 'origin',
 | 
			
		||||
	},
 | 
			
		||||
}, {
 | 
			
		||||
	// Legacy Compatibility	
 | 
			
		||||
	path: '/authorize-follow',
 | 
			
		||||
	component: page(() => import('@/pages/follow.vue')),
 | 
			
		||||
	redirect: '/lookup',
 | 
			
		||||
	loginRequired: true,
 | 
			
		||||
}, {
 | 
			
		||||
	// Mastodon Compatibility
 | 
			
		||||
	path: '/authorize_interaction',
 | 
			
		||||
	redirect: '/lookup',
 | 
			
		||||
	loginRequired: true,
 | 
			
		||||
}, {
 | 
			
		||||
	path: '/lookup',
 | 
			
		||||
	component: page(() => import('@/pages/lookup.vue')),
 | 
			
		||||
	loginRequired: true,
 | 
			
		||||
}, {
 | 
			
		||||
	path: '/share',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -186,7 +186,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter
 | 
			
		|||
			const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`;
 | 
			
		||||
			copyToClipboard(`${url}/${canonical}`);
 | 
			
		||||
		},
 | 
			
		||||
	}, {
 | 
			
		||||
	}, ...($i ? [{
 | 
			
		||||
		icon: 'ti ti-mail',
 | 
			
		||||
		text: i18n.ts.sendMessage,
 | 
			
		||||
		action: () => {
 | 
			
		||||
| 
						 | 
				
			
			@ -259,7 +259,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter
 | 
			
		|||
				},
 | 
			
		||||
			}));
 | 
			
		||||
		},
 | 
			
		||||
	}] as any;
 | 
			
		||||
	}] : [])] as any;
 | 
			
		||||
 | 
			
		||||
	if ($i && meId !== user.id) {
 | 
			
		||||
		if (iAmModerator) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -8,12 +8,24 @@ import { $i } from '@/account.js';
 | 
			
		|||
import { i18n } from '@/i18n.js';
 | 
			
		||||
import { popup } from '@/os.js';
 | 
			
		||||
 | 
			
		||||
export function pleaseLogin(path?: string) {
 | 
			
		||||
export type OpenOnRemoteOptions = {
 | 
			
		||||
	type: 'web';
 | 
			
		||||
	path: string;
 | 
			
		||||
} | {
 | 
			
		||||
	type: 'lookup';
 | 
			
		||||
	path: string;	
 | 
			
		||||
} | {
 | 
			
		||||
	type: 'share';
 | 
			
		||||
	params: Record<string, string>;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function pleaseLogin(path?: string, openOnRemote?: OpenOnRemoteOptions) {
 | 
			
		||||
	if ($i) return;
 | 
			
		||||
 | 
			
		||||
	const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {
 | 
			
		||||
		autoSet: true,
 | 
			
		||||
		message: i18n.ts.signinRequired,
 | 
			
		||||
		message: openOnRemote ? i18n.ts.signinOrContinueOnRemote : i18n.ts.signinRequired,
 | 
			
		||||
		openOnRemote,
 | 
			
		||||
	}, {
 | 
			
		||||
		cancelled: () => {
 | 
			
		||||
			if (path) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -21,3 +21,8 @@ export function query(obj: Record<string, any>): string {
 | 
			
		|||
export function appendQuery(url: string, query: string): string {
 | 
			
		||||
	return `${url}${/\?/.test(url) ? url.endsWith('?') ? '' : '&' : '?'}${query}`;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function extractDomain(url: string) {
 | 
			
		||||
	const match = url.match(/^(https)?:?\/{0,2}([^\/]+)/);
 | 
			
		||||
	return match ? match[2] : null;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue