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:
かっこかり 2024-07-14 15:27:52 +09:00 committed by GitHub
parent 6dd6fcf88f
commit 3c032dd5b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 330 additions and 113 deletions

View file

@ -13,6 +13,7 @@
### Client ### Client
- Enhance: 内蔵APIドキュメントのデザイン・パフォーマンスを改善 - Enhance: 内蔵APIドキュメントのデザイン・パフォーマンスを改善
- Enhance: 非ログイン時に他サーバーに遷移するアクションを追加
- Enhance: 非ログイン時のハイライトTLのデザインを改善 - Enhance: 非ログイン時のハイライトTLのデザインを改善
- Enhance: フロントエンドのアクセシビリティ改善 - Enhance: フロントエンドのアクセシビリティ改善
(Based on https://github.com/taiyme/misskey/pull/226) (Based on https://github.com/taiyme/misskey/pull/226)

26
locales/index.d.ts vendored
View file

@ -736,6 +736,22 @@ export interface Locale extends ILocale {
* *
*/ */
"showOnRemote": string; "showOnRemote": string;
/**
*
*/
"continueOnRemote": string;
/**
* Misskey Hubからサーバーを選択
*/
"chooseServerOnMisskeyHub": string;
/**
*
*/
"specifyServerHost": string;
/**
*
*/
"inputHostName": string;
/** /**
* *
*/ */
@ -1921,9 +1937,13 @@ export interface Locale extends ILocale {
*/ */
"onlyOneFileCanBeAttached": string; "onlyOneFileCanBeAttached": string;
/** /**
* *
*/ */
"signinRequired": string; "signinRequired": string;
/**
* 使
*/
"signinOrContinueOnRemote": string;
/** /**
* *
*/ */
@ -4984,6 +5004,10 @@ export interface Locale extends ILocale {
* *
*/ */
"inquiry": string; "inquiry": string;
/**
*
*/
"tryAgain": string;
"_delivery": { "_delivery": {
/** /**
* *

View file

@ -180,6 +180,10 @@ addAccount: "アカウントを追加"
reloadAccountsList: "アカウントリストの情報を更新" reloadAccountsList: "アカウントリストの情報を更新"
loginFailed: "ログインに失敗しました" loginFailed: "ログインに失敗しました"
showOnRemote: "リモートで表示" showOnRemote: "リモートで表示"
continueOnRemote: "リモートで続行"
chooseServerOnMisskeyHub: "Misskey Hubからサーバーを選択"
specifyServerHost: "サーバーのドメインを直接指定"
inputHostName: "ドメインを入力してください"
general: "全般" general: "全般"
wallpaper: "壁紙" wallpaper: "壁紙"
setWallpaper: "壁紙を設定" setWallpaper: "壁紙を設定"
@ -476,7 +480,8 @@ attachAsFileQuestion: "クリップボードのテキストが長いです。テ
noMessagesYet: "まだチャットはありません" noMessagesYet: "まだチャットはありません"
newMessageExists: "新しいメッセージがあります" newMessageExists: "新しいメッセージがあります"
onlyOneFileCanBeAttached: "メッセージに添付できるファイルはひとつです" onlyOneFileCanBeAttached: "メッセージに添付できるファイルはひとつです"
signinRequired: "続行する前に、サインアップまたはサインインが必要です" signinRequired: "続行する前に、登録またはログインが必要です"
signinOrContinueOnRemote: "続行するには、お使いのサーバーに移動するか、このサーバーに登録・ログインする必要があります"
invitations: "招待" invitations: "招待"
invitationCode: "招待コード" invitationCode: "招待コード"
checking: "確認しています" checking: "確認しています"
@ -1242,6 +1247,7 @@ keepOriginalFilenameDescription: "この設定をオフにすると、アップ
noDescription: "説明文はありません" noDescription: "説明文はありません"
alwaysConfirmFollow: "フォローの際常に確認する" alwaysConfirmFollow: "フォローの際常に確認する"
inquiry: "お問い合わせ" inquiry: "お問い合わせ"
tryAgain: "もう一度お試しください。"
_delivery: _delivery:
status: "配信状態" status: "配信状態"

View file

@ -42,6 +42,8 @@ import { misskeyApi } from '@/scripts/misskey-api.js';
import { useStream } from '@/stream.js'; import { useStream } from '@/stream.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { claimAchievement } from '@/scripts/achievements.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 { $i } from '@/account.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
@ -63,7 +65,7 @@ const hasPendingFollowRequestFromYou = ref(props.user.hasPendingFollowRequestFro
const wait = ref(false); const wait = ref(false);
const connection = useStream().useChannel('main'); const connection = useStream().useChannel('main');
if (props.user.isFollowing == null) { if (props.user.isFollowing == null && $i) {
misskeyApi('users/show', { misskeyApi('users/show', {
userId: props.user.id, userId: props.user.id,
}) })
@ -78,6 +80,8 @@ function onFollowChange(user: Misskey.entities.UserDetailed) {
} }
async function onClick() { async function onClick() {
pleaseLogin(undefined, { type: 'web', path: `/@${props.user.username}@${props.user.host ?? host}` });
wait.value = true; wait.value = true;
try { try {

View file

@ -196,6 +196,7 @@ import { MenuItem } from '@/types/menu.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue'; import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
import { shouldCollapsed } from '@/scripts/collapsed.js'; import { shouldCollapsed } from '@/scripts/collapsed.js';
import { host } from '@/config.js';
import { isEnabledUrlPreview } from '@/instance.js'; import { isEnabledUrlPreview } from '@/instance.js';
import { type Keymap } from '@/scripts/hotkey.js'; import { type Keymap } from '@/scripts/hotkey.js';
import { focusPrev, focusNext } from '@/scripts/focus.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 FunctionLint /* Overload FunctionLint
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: true): boolean;
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: false): boolean | 'sensitiveMute'; 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) { function renote(viaKeyboard = false) {
pleaseLogin(); pleaseLogin(undefined, pleaseLoginContext);
showMovedDialog(); showMovedDialog();
const { menu } = getRenoteMenu({ note: note.value, renoteButton, mock: props.mock }); const { menu } = getRenoteMenu({ note: note.value, renoteButton, mock: props.mock });
@ -421,7 +427,7 @@ function renote(viaKeyboard = false) {
} }
function reply(): void { function reply(): void {
pleaseLogin(); pleaseLogin(undefined, pleaseLoginContext);
if (props.mock) { if (props.mock) {
return; return;
} }
@ -434,7 +440,7 @@ function reply(): void {
} }
function react(): void { function react(): void {
pleaseLogin(); pleaseLogin(undefined, pleaseLoginContext);
showMovedDialog(); showMovedDialog();
if (appearNote.value.reactionAcceptance === 'likeOnly') { if (appearNote.value.reactionAcceptance === 'likeOnly') {
sound.playMisskeySfx('reaction'); sound.playMisskeySfx('reaction');
@ -565,7 +571,7 @@ function showRenoteMenu(): void {
} }
if (isMyRenote) { if (isMyRenote) {
pleaseLogin(); pleaseLogin(undefined, pleaseLoginContext);
os.popupMenu([ os.popupMenu([
getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote), getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote),
{ type: 'divider' }, { type: 'divider' },

View file

@ -222,6 +222,7 @@ import { reactionPicker } from '@/scripts/reaction-picker.js';
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js'; import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js';
import { $i } from '@/account.js'; import { $i } from '@/account.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { host } from '@/config.js';
import { getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/scripts/get-note-menu.js'; import { getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/scripts/get-note-menu.js';
import { useNoteCapture } from '@/scripts/use-note-capture.js'; import { useNoteCapture } from '@/scripts/use-note-capture.js';
import { deepClone } from '@/scripts/clone.js'; import { deepClone } from '@/scripts/clone.js';
@ -296,6 +297,11 @@ const conversation = ref<Misskey.entities.Note[]>([]);
const replies = 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 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 = { const keymap = {
'r': () => reply(), 'r': () => reply(),
'e|a|plus': () => react(), 'e|a|plus': () => react(),
@ -396,7 +402,7 @@ if (appearNote.value.reactionAcceptance === 'likeOnly') {
} }
function renote() { function renote() {
pleaseLogin(); pleaseLogin(undefined, pleaseLoginContext);
showMovedDialog(); showMovedDialog();
const { menu } = getRenoteMenu({ note: note.value, renoteButton }); const { menu } = getRenoteMenu({ note: note.value, renoteButton });
@ -404,7 +410,7 @@ function renote() {
} }
function reply(): void { function reply(): void {
pleaseLogin(); pleaseLogin(undefined, pleaseLoginContext);
showMovedDialog(); showMovedDialog();
os.post({ os.post({
reply: appearNote.value, reply: appearNote.value,
@ -415,7 +421,7 @@ function reply(): void {
} }
function react(): void { function react(): void {
pleaseLogin(); pleaseLogin(undefined, pleaseLoginContext);
showMovedDialog(); showMovedDialog();
if (appearNote.value.reactionAcceptance === 'likeOnly') { if (appearNote.value.reactionAcceptance === 'likeOnly') {
sound.playMisskeySfx('reaction'); sound.playMisskeySfx('reaction');
@ -499,7 +505,7 @@ async function clip(): Promise<void> {
function showRenoteMenu(): void { function showRenoteMenu(): void {
if (!isMyRenote) return; if (!isMyRenote) return;
pleaseLogin(); pleaseLogin(undefined, pleaseLoginContext);
os.popupMenu([{ os.popupMenu([{
text: i18n.ts.unrenote, text: i18n.ts.unrenote,
icon: 'ti ti-trash', icon: 'ti ti-trash',

View file

@ -34,6 +34,7 @@ import { pleaseLogin } from '@/scripts/please-login.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js'; import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { host } from '@/config.js';
import { useInterval } from '@/scripts/use-interval.js'; import { useInterval } from '@/scripts/use-interval.js';
const props = defineProps<{ const props = defineProps<{
@ -60,6 +61,11 @@ const timer = computed(() => i18n.tsx._poll[
const showResult = ref(props.readOnly || isVoted.value); const showResult = ref(props.readOnly || isVoted.value);
const pleaseLoginContext = {
type: 'lookup',
path: `https://${host}/notes/${props.note.id}`,
} as const;
// //
if (props.poll.expiresAt) { if (props.poll.expiresAt) {
const tick = () => { const tick = () => {
@ -76,7 +82,7 @@ if (props.poll.expiresAt) {
} }
const vote = async (id) => { const vote = async (id) => {
pleaseLogin(); pleaseLogin(undefined, pleaseLoginContext);
if (props.readOnly || closed.value || isVoted.value) return; if (props.readOnly || closed.value || isVoted.value) return;

View file

@ -6,10 +6,23 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<form :class="{ signing, totpLogin }" @submit.prevent="onSubmit"> <form :class="{ signing, totpLogin }" @submit.prevent="onSubmit">
<div class="_gaps_m"> <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"> <MkInfo v-if="message">
{{ message }} {{ message }}
</MkInfo> </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"> <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"> <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> <template #prefix>@</template>
@ -28,8 +41,8 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts.retry }} {{ i18n.ts.retry }}
</MkButton> </MkButton>
</div> </div>
<div v-if="user && user.securityKeys" class="or-hr"> <div v-if="user && user.securityKeys" :class="$style.orHr">
<p class="or-msg">{{ i18n.ts.or }}</p> <p :class="$style.orMsg">{{ i18n.ts.or }}</p>
</div> </div>
<div class="twofa-group totp-group _gaps"> <div class="twofa-group totp-group _gaps">
<MkInput v-if="user && user.usePasswordLessLogin" v-model="password" type="password" autocomplete="current-password" :withPasswordToggle="true" required> <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 { toUnicode } from 'punycode/';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { supported as webAuthnSupported, get as webAuthnRequest, parseRequestOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill'; 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 { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.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 { host as configHost } from '@/config.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js'; import { misskeyApi } from '@/scripts/misskey-api.js';
import { query, extractDomain } from '@/scripts/url.js';
import { login } from '@/account.js'; import { login } from '@/account.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
@ -78,22 +93,16 @@ const emit = defineEmits<{
(ev: 'login', v: any): void; (ev: 'login', v: any): void;
}>(); }>();
const props = defineProps({ const props = withDefaults(defineProps<{
withAvatar: { withAvatar?: boolean;
type: Boolean, autoSet?: boolean;
required: false, message?: string,
default: true, openOnRemote?: OpenOnRemoteOptions,
}, }>(), {
autoSet: { withAvatar: true,
type: Boolean, autoSet: false,
required: false, message: '',
default: false, openOnRemote: undefined,
},
message: {
type: String,
required: false,
default: '',
},
}); });
function onUsernameChange(): void { function onUsernameChange(): void {
@ -222,6 +231,60 @@ function resetPassword(): void {
closed: () => dispose(), 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.0URL
// _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> </script>
<style lang="scss" module> <style lang="scss" module>
@ -234,4 +297,36 @@ function resetPassword(): void {
background-size: cover; background-size: cover;
border-radius: 100%; 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> </style>

View file

@ -6,21 +6,22 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<MkModalWindow <MkModalWindow
ref="dialog" ref="dialog"
:width="370" :width="400"
:height="400" :height="430"
@close="onClose" @close="onClose"
@closed="emit('closed')" @closed="emit('closed')"
> >
<template #header>{{ i18n.ts.login }}</template> <template #header>{{ i18n.ts.login }}</template>
<MkSpacer :marginMin="20" :marginMax="28"> <MkSpacer :marginMin="20" :marginMax="28">
<MkSignin :autoSet="autoSet" :message="message" @login="onLogin"/> <MkSignin :autoSet="autoSet" :message="message" :openOnRemote="openOnRemote" @login="onLogin"/>
</MkSpacer> </MkSpacer>
</MkModalWindow> </MkModalWindow>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { shallowRef } from 'vue'; import { shallowRef } from 'vue';
import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
import MkSignin from '@/components/MkSignin.vue'; import MkSignin from '@/components/MkSignin.vue';
import MkModalWindow from '@/components/MkModalWindow.vue'; import MkModalWindow from '@/components/MkModalWindow.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
@ -28,9 +29,11 @@ import { i18n } from '@/i18n.js';
withDefaults(defineProps<{ withDefaults(defineProps<{
autoSet?: boolean; autoSet?: boolean;
message?: string, message?: string,
openOnRemote?: OpenOnRemoteOptions,
}>(), { }>(), {
autoSet: false, autoSet: false,
message: '', message: '',
openOnRemote: undefined,
}); });
const emit = defineEmits<{ const emit = defineEmits<{

View file

@ -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> <p :class="$style.statusItemLabel">{{ i18n.ts.followers }}</p><span :class="$style.statusItemValue">{{ number(user.followersCount) }}</span>
</div> </div>
</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> </div>
</template> </template>

View file

@ -23,6 +23,7 @@ import MkPopupMenu from '@/components/MkPopupMenu.vue';
import MkContextMenu from '@/components/MkContextMenu.vue'; import MkContextMenu from '@/components/MkContextMenu.vue';
import { MenuItem } from '@/types/menu.js'; import { MenuItem } from '@/types/menu.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.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 { showMovedDialog } from '@/scripts/show-moved-dialog.js';
import { getHTMLElementOrNull } from '@/scripts/get-dom-node-or-null.js'; import { getHTMLElementOrNull } from '@/scripts/get-dom-node-or-null.js';
import { focusParent } from '@/scripts/focus.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> { 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(); showMovedDialog();
return new Promise(resolve => { return new Promise(resolve => {
// NOTE: MkPostFormDialogをdynamic importするとiOSでテキストエリアに自動フォーカスできない // NOTE: MkPostFormDialogをdynamic importするとiOSでテキストエリアに自動フォーカスできない

View file

@ -79,6 +79,7 @@ import { defaultStore } from '@/store.js';
import { $i } from '@/account.js'; import { $i } from '@/account.js';
import { isSupportShare } from '@/scripts/navigator.js'; import { isSupportShare } from '@/scripts/navigator.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import copyToClipboard from '@/scripts/copy-to-clipboard.js';
import { pleaseLogin } from '@/scripts/please-login.js';
const props = defineProps<{ const props = defineProps<{
id: string; id: string;
@ -143,6 +144,7 @@ function shareWithNote() {
function like() { function like() {
if (!flash.value) return; if (!flash.value) return;
pleaseLogin();
os.apiWithDialog('flash/like', { os.apiWithDialog('flash/like', {
flashId: flash.value.id, flashId: flash.value.id,
@ -154,6 +156,7 @@ function like() {
async function unlike() { async function unlike() {
if (!flash.value) return; if (!flash.value) return;
pleaseLogin();
const confirm = await os.confirm({ const confirm = await os.confirm({
type: 'warning', type: 'warning',

View file

@ -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>

View 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;
// acctdeprecated
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>

View file

@ -32,9 +32,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</div> </div>
<span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ i18n.ts.followsYou }}</span> <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> <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>
</div> </div>
<MkAvatar class="avatar" :user="user" indicator/> <MkAvatar class="avatar" :user="user" indicator/>

View file

@ -237,8 +237,18 @@ const routes: RouteDef[] = [{
origin: 'origin', origin: 'origin',
}, },
}, { }, {
// Legacy Compatibility
path: '/authorize-follow', 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, loginRequired: true,
}, { }, {
path: '/share', path: '/share',

View file

@ -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)}`; const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`;
copyToClipboard(`${url}/${canonical}`); copyToClipboard(`${url}/${canonical}`);
}, },
}, { }, ...($i ? [{
icon: 'ti ti-mail', icon: 'ti ti-mail',
text: i18n.ts.sendMessage, text: i18n.ts.sendMessage,
action: () => { action: () => {
@ -259,7 +259,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter
}, },
})); }));
}, },
}] as any; }] : [])] as any;
if ($i && meId !== user.id) { if ($i && meId !== user.id) {
if (iAmModerator) { if (iAmModerator) {

View file

@ -8,12 +8,24 @@ import { $i } from '@/account.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { popup } from '@/os.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; if ($i) return;
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), { const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {
autoSet: true, autoSet: true,
message: i18n.ts.signinRequired, message: openOnRemote ? i18n.ts.signinOrContinueOnRemote : i18n.ts.signinRequired,
openOnRemote,
}, { }, {
cancelled: () => { cancelled: () => {
if (path) { if (path) {

View file

@ -21,3 +21,8 @@ export function query(obj: Record<string, any>): string {
export function appendQuery(url: string, query: string): string { export function appendQuery(url: string, query: string): string {
return `${url}${/\?/.test(url) ? url.endsWith('?') ? '' : '&' : '?'}${query}`; return `${url}${/\?/.test(url) ? url.endsWith('?') ? '' : '&' : '?'}${query}`;
} }
export function extractDomain(url: string) {
const match = url.match(/^(https)?:?\/{0,2}([^\/]+)/);
return match ? match[2] : null;
}