Merge branch 'develop' into feat-1714

This commit is contained in:
かっこかり 2024-07-15 10:11:15 +09:00 committed by GitHub
commit 8d144c8fdc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
85 changed files with 1222 additions and 831 deletions

View file

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

View file

@ -54,6 +54,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:class="['_button', $style.item]"
:href="item.href"
:target="item.target"
:rel="item.target === '_blank' ? 'noopener noreferrer' : undefined"
:download="item.download"
@click.passive="close(true)"
@mouseenter.passive="onItemMouseEnter"

View file

@ -192,7 +192,7 @@ import MkPoll from '@/components/MkPoll.vue';
import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
import MkUrlPreview from '@/components/MkUrlPreview.vue';
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
import { pleaseLogin } from '@/scripts/please-login.js';
import { pleaseLogin, type OpenOnRemoteOptions } from '@/scripts/please-login.js';
import { checkWordMute } from '@/scripts/check-word-mute.js';
import { userPage } from '@/filters/user.js';
import number from '@/filters/number.js';
@ -214,6 +214,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 { url } from '@/config.js';
import { type Keymap } from '@/scripts/hotkey.js';
@ -298,6 +299,11 @@ const renoteCollapsed = ref(
),
);
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup',
url: `https://${host}/notes/${appearNote.value.id}`,
}));
/* 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: false): boolean | 'sensitiveMute';
@ -431,7 +437,7 @@ if (!props.mock && !inEmbedPage) {
}
function renote(viaKeyboard = false) {
pleaseLogin();
pleaseLogin(undefined, pleaseLoginContext.value);
showMovedDialog();
const { menu } = getRenoteMenu({ note: note.value, renoteButton, mock: props.mock });
@ -441,7 +447,7 @@ function renote(viaKeyboard = false) {
}
function reply(): void {
pleaseLogin();
pleaseLogin(undefined, pleaseLoginContext.value);
if (props.mock) {
return;
}
@ -454,7 +460,7 @@ function reply(): void {
}
function react(): void {
pleaseLogin();
pleaseLogin(undefined, pleaseLoginContext.value);
showMovedDialog();
if (appearNote.value.reactionAcceptance === 'likeOnly') {
sound.playMisskeySfx('reaction');
@ -585,7 +591,7 @@ function showRenoteMenu(): void {
}
if (isMyRenote) {
pleaseLogin();
pleaseLogin(undefined, pleaseLoginContext.value);
os.popupMenu([
getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote),
{ type: 'divider' },

View file

@ -209,7 +209,7 @@ import MkPoll from '@/components/MkPoll.vue';
import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
import MkUrlPreview from '@/components/MkUrlPreview.vue';
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
import { pleaseLogin } from '@/scripts/please-login.js';
import { pleaseLogin, type OpenOnRemoteOptions } from '@/scripts/please-login.js';
import { checkWordMute } from '@/scripts/check-word-mute.js';
import { userPage } from '@/filters/user.js';
import { notePage } from '@/filters/note.js';
@ -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 = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup',
url: `https://${host}/notes/${appearNote.value.id}`,
}));
const keymap = {
'r': () => reply(),
'e|a|plus': () => react(),
@ -396,7 +402,7 @@ if (appearNote.value.reactionAcceptance === 'likeOnly') {
}
function renote() {
pleaseLogin();
pleaseLogin(undefined, pleaseLoginContext.value);
showMovedDialog();
const { menu } = getRenoteMenu({ note: note.value, renoteButton });
@ -404,7 +410,7 @@ function renote() {
}
function reply(): void {
pleaseLogin();
pleaseLogin(undefined, pleaseLoginContext.value);
showMovedDialog();
os.post({
reply: appearNote.value,
@ -415,7 +421,7 @@ function reply(): void {
}
function react(): void {
pleaseLogin();
pleaseLogin(undefined, pleaseLoginContext.value);
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.value);
os.popupMenu([{
text: i18n.ts.unrenote,
icon: 'ti ti-trash',

View file

@ -34,7 +34,9 @@ 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';
import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
const props = defineProps<{
noteId: string;
@ -60,6 +62,11 @@ const timer = computed(() => i18n.tsx._poll[
const showResult = ref(props.readOnly || isVoted.value);
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup',
url: `https://${host}/notes/${props.noteId}`,
}));
//
if (props.poll.expiresAt) {
const tick = () => {
@ -76,7 +83,7 @@ if (props.poll.expiresAt) {
}
const vote = async (id) => {
pleaseLogin();
pleaseLogin(undefined, pleaseLoginContext.value);
if (props.readOnly || closed.value || isVoted.value) return;

View file

@ -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,62 @@ function resetPassword(): void {
closed: () => dispose(),
});
}
function openRemote(options: OpenOnRemoteOptions, targetHost?: string): void {
switch (options.type) {
case 'web':
case 'lookup': {
let _path: string;
if (options.type === 'lookup') {
// TODO: v2024.7.0URL
// _path = `/lookup?uri=${encodeURIComponent(_path)}`;
_path = `/authorize-follow?acct=${encodeURIComponent(options.url)}`;
} else {
_path = options.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 +299,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>

View file

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

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

View file

@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div class="_gaps_s" :class="$style.mainActions">
<MkButton :class="$style.mainAction" full rounded gradate data-cy-signup style="margin-right: 12px;" @click="signup()">{{ i18n.ts.joinThisServer }}</MkButton>
<MkButton :class="$style.mainAction" full rounded @click="exploreOtherServers()">{{ i18n.ts.exploreOtherServers }}</MkButton>
<MkButton :class="$style.mainAction" full rounded link to="https://misskey-hub.net/servers/">{{ i18n.ts.exploreOtherServers }}</MkButton>
<MkButton :class="$style.mainAction" full rounded data-cy-signin @click="signin()">{{ i18n.ts.login }}</MkButton>
</div>
</div>
@ -65,7 +65,8 @@ import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
import MkNumber from '@/components/MkNumber.vue';
import XActiveUsersChart from '@/components/MkVisitorDashboard.ActiveUsersChart.vue';
import { openInstanceMenu } from '@/ui/_common_/common';
import { openInstanceMenu } from '@/ui/_common_/common.js';
import type { MenuItem } from '@/types/menu.js';
const stats = ref<Misskey.entities.StatsResponse | null>(null);
@ -89,13 +90,9 @@ function signup() {
});
}
function showMenu(ev) {
function showMenu(ev: MouseEvent) {
openInstanceMenu(ev);
}
function exploreOtherServers() {
window.open('https://misskey-hub.net/servers/', '_blank', 'noopener');
}
</script>
<style lang="scss" module>

View file

@ -65,7 +65,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven
const validTime = (t: string | boolean | null | undefined) => {
if (t == null) return null;
if (typeof t === 'boolean') return null;
return t.match(/^[0-9.]+s$/) ? t : null;
return t.match(/^\-?[0-9.]+s$/) ? t : null;
};
const validColor = (c: unknown): string | null => {

View file

@ -60,7 +60,7 @@ function onChange({ resolved, key: newKey }) {
if (current == null || 'redirect' in current.route) return;
currentPageComponent.value = current.route.component;
currentPageProps.value = current.props;
key.value = current.route.path + JSON.stringify(Object.fromEntries(current.props));
key.value = newKey + JSON.stringify(Object.fromEntries(current.props));
nextTick(() => {
//

View file

@ -87,6 +87,7 @@ export const ROLE_POLICIES = [
'canHideAds',
'driveCapacityMb',
'alwaysMarkNsfw',
'canUpdateBioMedia',
'pinLimit',
'antennaLimit',
'wordMuteLimit',

View file

@ -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 { embedPage } from '@/config.js';
import { getHTMLElementOrNull } from '@/scripts/get-dom-node-or-null.js';
@ -684,6 +685,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でテキストエリアに自動フォーカスできない

View file

@ -0,0 +1,205 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div class="_gaps_m">
<div :class="$style.banner" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }">
<div style="overflow: clip;">
<img :src="instance.iconUrl ?? instance.faviconUrl ?? '/favicon.ico'" alt="" :class="$style.bannerIcon"/>
<div :class="$style.bannerName">
<b>{{ instance.name ?? host }}</b>
</div>
</div>
</div>
<MkKeyValue>
<template #key>{{ i18n.ts.description }}</template>
<template #value><div v-html="instance.description"></div></template>
</MkKeyValue>
<FormSection>
<div class="_gaps_m">
<MkKeyValue :copy="version">
<template #key>Misskey</template>
<template #value>{{ version }}</template>
</MkKeyValue>
<div v-html="i18n.tsx.poweredByMisskeyDescription({ name: instance.name ?? host })">
</div>
<FormLink to="/about-misskey">
<template #icon><i class="ti ti-info-circle"></i></template>
{{ i18n.ts.aboutMisskey }}
</FormLink>
<FormLink v-if="instance.repositoryUrl || instance.providesTarball" :to="instance.repositoryUrl || `/tarball/misskey-${version}.tar.gz`" external>
<template #icon><i class="ti ti-code"></i></template>
{{ i18n.ts.sourceCode }}
</FormLink>
<MkInfo v-else warn>
{{ i18n.ts.sourceCodeIsNotYetProvided }}
</MkInfo>
</div>
</FormSection>
<FormSection>
<div class="_gaps_m">
<FormSplit>
<MkKeyValue :copy="instance.maintainerName">
<template #key>{{ i18n.ts.administrator }}</template>
<template #value>
<template v-if="instance.maintainerName">{{ instance.maintainerName }}</template>
<span v-else style="opacity: 0.7;">({{ i18n.ts.none }})</span>
</template>
</MkKeyValue>
<MkKeyValue :copy="instance.maintainerEmail">
<template #key>{{ i18n.ts.contact }}</template>
<template #value>
<template v-if="instance.maintainerEmail">{{ instance.maintainerEmail }}</template>
<span v-else style="opacity: 0.7;">({{ i18n.ts.none }})</span>
</template>
</MkKeyValue>
<MkKeyValue>
<template #key>{{ i18n.ts.inquiry }}</template>
<template #value>
<MkLink v-if="instance.inquiryUrl" :url="instance.inquiryUrl" target="_blank">{{ instance.inquiryUrl }}</MkLink>
<span v-else style="opacity: 0.7;">({{ i18n.ts.none }})</span>
</template>
</MkKeyValue>
</FormSplit>
<div class="_gaps_s">
<FormLink v-if="instance.impressumUrl" :to="instance.impressumUrl" external>
<template #icon><i class="ti ti-user-shield"></i></template>
<template #default>{{ i18n.ts.impressum }}</template>
</FormLink>
<MkFolder v-if="instance.serverRules.length > 0">
<template #icon><i class="ti ti-checkup-list"></i></template>
<template #label>{{ i18n.ts.serverRules }}</template>
<ol class="_gaps_s" :class="$style.rules">
<li v-for="item in instance.serverRules" :key="item" :class="$style.rule">
<div :class="$style.ruleText" v-html="item"></div>
</li>
</ol>
</MkFolder>
<FormLink v-if="instance.tosUrl" :to="instance.tosUrl" external>
<template #icon><i class="ti ti-license"></i></template>
<template #default>{{ i18n.ts.termsOfService }}</template>
</FormLink>
<FormLink v-if="instance.privacyPolicyUrl" :to="instance.privacyPolicyUrl" external>
<template #icon><i class="ti ti-shield-lock"></i></template>
<template #default>{{ i18n.ts.privacyPolicy }}</template>
</FormLink>
<FormLink v-if="instance.feedbackUrl" :to="instance.feedbackUrl" external>
<template #icon><i class="ti ti-message"></i></template>
<template #default>{{ i18n.ts.feedback }}</template>
</FormLink>
</div>
</div>
</FormSection>
<FormSuspense v-slot="{ result: stats }" :p="initStats">
<FormSection>
<template #label>{{ i18n.ts.statistics }}</template>
<FormSplit>
<MkKeyValue>
<template #key>{{ i18n.ts.users }}</template>
<template #value>{{ number(stats.originalUsersCount) }}</template>
</MkKeyValue>
<MkKeyValue>
<template #key>{{ i18n.ts.notes }}</template>
<template #value>{{ number(stats.originalNotesCount) }}</template>
</MkKeyValue>
</FormSplit>
</FormSection>
</FormSuspense>
<FormSection>
<template #label>Well-known resources</template>
<div class="_gaps_s">
<FormLink to="/.well-known/host-meta" external>host-meta</FormLink>
<FormLink to="/.well-known/host-meta.json" external>host-meta.json</FormLink>
<FormLink to="/.well-known/nodeinfo" external>nodeinfo</FormLink>
<FormLink to="/robots.txt" external>robots.txt</FormLink>
<FormLink to="/manifest.json" external>manifest.json</FormLink>
</div>
</FormSection>
</div>
</template>
<script lang="ts" setup>
import { host, version } from '@/config.js';
import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
import number from '@/filters/number.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import FormLink from '@/components/form/link.vue';
import FormSection from '@/components/form/section.vue';
import FormSplit from '@/components/form/split.vue';
import FormSuspense from '@/components/form/suspense.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkKeyValue from '@/components/MkKeyValue.vue';
import MkLink from '@/components/MkLink.vue';
const initStats = () => misskeyApi('stats', {});
</script>
<style lang="scss" module>
.banner {
text-align: center;
border-radius: 10px;
overflow: clip;
background-color: var(--panel);
background-size: cover;
background-position: center center;
}
.bannerIcon {
display: block;
margin: 16px auto 0 auto;
height: 64px;
border-radius: 8px;
}
.bannerName {
display: block;
padding: 16px;
color: #fff;
text-shadow: 0 0 8px #000;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
}
.rules {
counter-reset: item;
list-style: none;
padding: 0;
margin: 0;
}
.rule {
display: flex;
gap: 8px;
word-break: break-word;
&::before {
flex-shrink: 0;
display: flex;
position: sticky;
top: calc(var(--stickyTop, 0px) + 8px);
counter-increment: item;
content: counter(item);
width: 32px;
height: 32px;
line-height: 32px;
background-color: var(--accentedBg);
color: var(--accent);
font-size: 13px;
font-weight: bold;
align-items: center;
justify-content: center;
border-radius: 999px;
}
}
.ruleText {
padding-top: 6px;
}
</style>

View file

@ -8,113 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
<MkSpacer v-if="tab === 'overview'" :contentMax="600" :marginMin="20">
<div class="_gaps_m">
<div :class="$style.banner" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }">
<div style="overflow: clip;">
<img :src="instance.iconUrl ?? instance.faviconUrl ?? '/favicon.ico'" alt="" :class="$style.bannerIcon"/>
<div :class="$style.bannerName">
<b>{{ instance.name ?? host }}</b>
</div>
</div>
</div>
<MkKeyValue>
<template #key>{{ i18n.ts.description }}</template>
<template #value><div v-html="instance.description"></div></template>
</MkKeyValue>
<FormSection>
<div class="_gaps_m">
<MkKeyValue :copy="version">
<template #key>Misskey</template>
<template #value>{{ version }}</template>
</MkKeyValue>
<div v-html="i18n.tsx.poweredByMisskeyDescription({ name: instance.name ?? host })">
</div>
<FormLink to="/about-misskey">
<template #icon><i class="ti ti-info-circle"></i></template>
{{ i18n.ts.aboutMisskey }}
</FormLink>
<FormLink v-if="instance.repositoryUrl || instance.providesTarball" :to="instance.repositoryUrl || `/tarball/misskey-${version}.tar.gz`" external>
<template #icon><i class="ti ti-code"></i></template>
{{ i18n.ts.sourceCode }}
</FormLink>
<MkInfo v-else warn>
{{ i18n.ts.sourceCodeIsNotYetProvided }}
</MkInfo>
</div>
</FormSection>
<FormSection>
<div class="_gaps_m">
<FormSplit>
<MkKeyValue>
<template #key>{{ i18n.ts.administrator }}</template>
<template #value>{{ instance.maintainerName }}</template>
</MkKeyValue>
<MkKeyValue>
<template #key>{{ i18n.ts.contact }}</template>
<template #value>{{ instance.maintainerEmail }}</template>
</MkKeyValue>
</FormSplit>
<FormLink v-if="instance.impressumUrl" :to="instance.impressumUrl" external>
<template #icon><i class="ti ti-user-shield"></i></template>
{{ i18n.ts.impressum }}
</FormLink>
<div class="_gaps_s">
<MkFolder v-if="instance.serverRules.length > 0">
<template #label>
<i class="ti ti-checkup-list"></i>
{{ i18n.ts.serverRules }}
</template>
<ol class="_gaps_s" :class="$style.rules">
<li v-for="(item, index) in instance.serverRules" :key="index" :class="$style.rule"><div :class="$style.ruleText" v-html="item"></div></li>
</ol>
</MkFolder>
<FormLink v-if="instance.tosUrl" :to="instance.tosUrl" external>
<template #icon><i class="ti ti-license"></i></template>
{{ i18n.ts.termsOfService }}
</FormLink>
<FormLink v-if="instance.privacyPolicyUrl" :to="instance.privacyPolicyUrl" external>
<template #icon><i class="ti ti-shield-lock"></i></template>
{{ i18n.ts.privacyPolicy }}
</FormLink>
<FormLink v-if="instance.feedbackUrl" :to="instance.feedbackUrl" external>
<template #icon><i class="ti ti-message"></i></template>
{{ i18n.ts.feedback }}
</FormLink>
</div>
</div>
</FormSection>
<FormSuspense :p="initStats">
<FormSection>
<template #label>{{ i18n.ts.statistics }}</template>
<FormSplit>
<MkKeyValue>
<template #key>{{ i18n.ts.users }}</template>
<template #value>{{ number(stats.originalUsersCount) }}</template>
</MkKeyValue>
<MkKeyValue>
<template #key>{{ i18n.ts.notes }}</template>
<template #value>{{ number(stats.originalNotesCount) }}</template>
</MkKeyValue>
</FormSplit>
</FormSection>
</FormSuspense>
<FormSection>
<template #label>Well-known resources</template>
<div class="_gaps_s">
<FormLink :to="`/.well-known/host-meta`" external>host-meta</FormLink>
<FormLink :to="`/.well-known/host-meta.json`" external>host-meta.json</FormLink>
<FormLink :to="`/.well-known/nodeinfo`" external>nodeinfo</FormLink>
<FormLink :to="`/robots.txt`" external>robots.txt</FormLink>
<FormLink :to="`/manifest.json`" external>manifest.json</FormLink>
</div>
</FormSection>
</div>
<XOverview/>
</MkSpacer>
<MkSpacer v-else-if="tab === 'emojis'" :contentMax="1000" :marginMin="20">
<XEmojis/>
@ -130,26 +24,16 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, watch, ref } from 'vue';
import * as Misskey from 'misskey-js';
import XEmojis from './about.emojis.vue';
import XFederation from './about.federation.vue';
import { version, host } from '@/config.js';
import FormLink from '@/components/form/link.vue';
import FormSection from '@/components/form/section.vue';
import FormSuspense from '@/components/form/suspense.vue';
import FormSplit from '@/components/form/split.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkKeyValue from '@/components/MkKeyValue.vue';
import MkInfo from '@/components/MkInfo.vue';
import MkInstanceStats from '@/components/MkInstanceStats.vue';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
import number from '@/filters/number.js';
import { computed, defineAsyncComponent, ref, watch } from 'vue';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { claimAchievement } from '@/scripts/achievements.js';
import { instance } from '@/instance.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
const XOverview = defineAsyncComponent(() => import('@/pages/about.overview.vue'));
const XEmojis = defineAsyncComponent(() => import('@/pages/about.emojis.vue'));
const XFederation = defineAsyncComponent(() => import('@/pages/about.federation.vue'));
const MkInstanceStats = defineAsyncComponent(() => import('@/components/MkInstanceStats.vue'));
const props = withDefaults(defineProps<{
initialTab?: string;
@ -157,7 +41,6 @@ const props = withDefaults(defineProps<{
initialTab: 'overview',
});
const stats = ref<Misskey.entities.StatsResponse | null>(null);
const tab = ref(props.initialTab);
watch(tab, () => {
@ -166,11 +49,6 @@ watch(tab, () => {
}
});
const initStats = () => misskeyApi('stats', {
}).then((res) => {
stats.value = res;
});
const headerActions = computed(() => []);
const headerTabs = computed(() => [{
@ -195,64 +73,3 @@ definePageMetadata(() => ({
icon: 'ti ti-info-circle',
}));
</script>
<style lang="scss" module>
.banner {
text-align: center;
border-radius: 10px;
overflow: clip;
background-size: cover;
background-position: center center;
}
.bannerIcon {
display: block;
margin: 16px auto 0 auto;
height: 64px;
border-radius: 8px;
}
.bannerName {
display: block;
padding: 16px;
color: #fff;
text-shadow: 0 0 8px #000;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
}
.rules {
counter-reset: item;
list-style: none;
padding: 0;
margin: 0;
}
.rule {
display: flex;
gap: 8px;
word-break: break-word;
&::before {
flex-shrink: 0;
display: flex;
position: sticky;
top: calc(var(--stickyTop, 0px) + 8px);
counter-increment: item;
content: counter(item);
width: 32px;
height: 32px;
line-height: 32px;
background-color: var(--accentedBg);
color: var(--accent);
font-size: 13px;
font-weight: bold;
align-items: center;
justify-content: center;
border-radius: 999px;
}
}
.ruleText {
padding-top: 6px;
}
</style>

View file

@ -378,6 +378,26 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canUpdateBioMedia, 'canUpdateBioMedia'])">
<template #label>{{ i18n.ts._role._options.canUpdateBioMedia }}</template>
<template #suffix>
<span v-if="role.policies.canUpdateBioMedia.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.canUpdateBioMedia.value ? i18n.ts.yes : i18n.ts.no }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canUpdateBioMedia)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.canUpdateBioMedia.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkSwitch v-model="role.policies.canUpdateBioMedia.value" :disabled="role.policies.canUpdateBioMedia.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
<MkRange v-model="role.policies.canUpdateBioMedia.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.pinMax, 'pinLimit'])">
<template #label>{{ i18n.ts._role._options.pinMax }}</template>
<template #suffix>

View file

@ -134,6 +134,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canUpdateBioMedia, 'canUpdateBioMedia'])">
<template #label>{{ i18n.ts._role._options.canUpdateBioMedia }}</template>
<template #suffix>{{ policies.canUpdateBioMedia ? i18n.ts.yes : i18n.ts.no }}</template>
<MkSwitch v-model="policies.canUpdateBioMedia">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.pinMax, 'pinLimit'])">
<template #label>{{ i18n.ts._role._options.pinMax }}</template>
<template #suffix>{{ policies.pinLimit }}</template>

View file

@ -7,18 +7,26 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkStickyContainer>
<template #header><MkPageHeader/></template>
<MkSpacer :contentMax="600" :marginMin="20">
<div class="_gaps">
<MkKeyValue>
<template #key>{{ i18n.ts.inquiry }}</template>
<div class="_gaps_m">
<MkKeyValue :copy="instance.maintainerName">
<template #key>{{ i18n.ts.administrator }}</template>
<template #value>
<MkLink :url="instance.inquiryUrl" target="_blank">{{ instance.inquiryUrl }}</MkLink>
<template v-if="instance.maintainerName">{{ instance.maintainerName }}</template>
<span v-else style="opacity: 0.7;">({{ i18n.ts.none }})</span>
</template>
</MkKeyValue>
<MkKeyValue>
<template #key>{{ i18n.ts.email }}</template>
<MkKeyValue :copy="instance.maintainerEmail">
<template #key>{{ i18n.ts.contact }}</template>
<template #value>
<div>{{ instance.maintainerEmail }}</div>
<template v-if="instance.maintainerEmail">{{ instance.maintainerEmail }}</template>
<span v-else style="opacity: 0.7;">({{ i18n.ts.none }})</span>
</template>
</MkKeyValue>
<MkKeyValue :copy="instance.inquiryUrl">
<template #key>{{ i18n.ts.inquiry }}</template>
<template #value>
<MkLink v-if="instance.inquiryUrl" :url="instance.inquiryUrl" target="_blank">{{ instance.inquiryUrl }}</MkLink>
<span v-else style="opacity: 0.7;">({{ i18n.ts.none }})</span>
</template>
</MkKeyValue>
</div>
@ -28,8 +36,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { instance } from '@/instance.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import MkKeyValue from '@/components/MkKeyValue.vue';
import MkLink from '@/components/MkLink.vue';

View file

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

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

View file

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

View file

@ -197,7 +197,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: () => {
@ -270,7 +270,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter
},
}));
},
}] as any;
}] : [])] as any;
if ($i && meId !== user.id) {
if (iAmModerator) {

View file

@ -114,6 +114,7 @@ const matchPatterns = (ev: KeyboardEvent, action: Action) => {
const key = ev.key.toLowerCase();
return patterns.some(({ which, ctrl, shift, alt }) => {
if (
options.allowRepeat === false &&
latestHotkey != null &&
latestHotkey.which.includes(key) &&
latestHotkey.ctrl === ctrl &&

View file

@ -8,12 +8,49 @@ import { $i } from '@/account.js';
import { i18n } from '@/i18n.js';
import { popup } from '@/os.js';
export function pleaseLogin(path?: string) {
export type OpenOnRemoteOptions = {
/**
* Misskey Webで特定のパスを開く
*/
type: 'web';
/**
* : `/settings`
*/
path: string;
} | {
/**
* Misskey Webで照会する
*/
type: 'lookup';
/**
* URL
*
* : `https://misskey.example.com/notes/abcdexxxxyz`
*/
url: string;
} | {
/**
* Misskeyでートする
*/
type: 'share';
/**
* `/share`
*
* @see https://go.misskey-hub.net/spec/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) {

View file

@ -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?:)?(?:\/\/)?(?:[^@\n]+@)?([^:\/\n]+)/im);
return match ? match[1] : null;
}

View file

@ -85,29 +85,29 @@ export function openInstanceMenu(ev: MouseEvent) {
icon: 'ti ti-help-circle',
to: '/contact',
}, (instance.impressumUrl) ? {
type: 'a',
text: i18n.ts.impressum,
icon: 'ti ti-file-invoice',
action: () => {
window.open(instance.impressumUrl, '_blank', 'noopener');
},
href: instance.impressumUrl,
target: '_blank',
} : undefined, (instance.tosUrl) ? {
type: 'a',
text: i18n.ts.termsOfService,
icon: 'ti ti-notebook',
action: () => {
window.open(instance.tosUrl, '_blank', 'noopener');
},
href: instance.tosUrl,
target: '_blank',
} : undefined, (instance.privacyPolicyUrl) ? {
type: 'a',
text: i18n.ts.privacyPolicy,
icon: 'ti ti-shield-lock',
action: () => {
window.open(instance.privacyPolicyUrl, '_blank', 'noopener');
},
href: instance.privacyPolicyUrl,
target: '_blank',
} : undefined, (!instance.impressumUrl && !instance.tosUrl && !instance.privacyPolicyUrl) ? undefined : { type: 'divider' }, {
type: 'a',
text: i18n.ts.document,
icon: 'ti ti-bulb',
action: () => {
window.open('https://misskey-hub.net/docs/for-users/', '_blank', 'noopener');
},
href: 'https://misskey-hub.net/docs/for-users/',
target: '_blank',
}, ($i) ? {
text: i18n.ts._initialTutorial.launchTutorial,
icon: 'ti ti-presentation',