summaryrefslogtreecommitdiff
path: root/packages/frontend/src/pages
diff options
context:
space:
mode:
authormisskey-release-bot[bot] <157398866+misskey-release-bot[bot]@users.noreply.github.com>2024-07-31 11:20:31 +0000
committerGitHub <noreply@github.com>2024-07-31 11:20:31 +0000
commite98f66db51aabef925ea1a8faee6c37f67071107 (patch)
tree168fcc9219f7511bbf9bc198568406bd49fc31bb /packages/frontend/src/pages
parentfix: remove unreleased section (#14246) (diff)
parentRelease: 2024.7.0 (diff)
downloadsharkey-e98f66db51aabef925ea1a8faee6c37f67071107.tar.gz
sharkey-e98f66db51aabef925ea1a8faee6c37f67071107.tar.bz2
sharkey-e98f66db51aabef925ea1a8faee6c37f67071107.zip
Merge pull request #14233 from misskey-dev/develop
Release: 2024.7.0
Diffstat (limited to 'packages/frontend/src/pages')
-rw-r--r--packages/frontend/src/pages/about-misskey.vue31
-rw-r--r--packages/frontend/src/pages/about.federation.vue6
-rw-r--r--packages/frontend/src/pages/about.overview.vue205
-rw-r--r--packages/frontend/src/pages/about.vue201
-rw-r--r--packages/frontend/src/pages/admin-user.vue12
-rw-r--r--packages/frontend/src/pages/admin/_header_.vue5
-rw-r--r--packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue321
-rw-r--r--packages/frontend/src/pages/admin/abuse-report/notification-recipient.item.vue114
-rw-r--r--packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue177
-rw-r--r--packages/frontend/src/pages/admin/abuses.vue85
-rw-r--r--packages/frontend/src/pages/admin/announcements.vue160
-rw-r--r--packages/frontend/src/pages/admin/federation.vue6
-rw-r--r--packages/frontend/src/pages/admin/index.vue5
-rw-r--r--packages/frontend/src/pages/admin/instance-block.vue27
-rw-r--r--packages/frontend/src/pages/admin/invites.vue2
-rw-r--r--packages/frontend/src/pages/admin/modlog.ModLog.vue48
-rw-r--r--packages/frontend/src/pages/admin/roles.editor.vue20
-rw-r--r--packages/frontend/src/pages/admin/roles.vue11
-rw-r--r--packages/frontend/src/pages/admin/system-webhook.item.vue117
-rw-r--r--packages/frontend/src/pages/admin/system-webhook.vue96
-rw-r--r--packages/frontend/src/pages/announcement.vue9
-rw-r--r--packages/frontend/src/pages/antenna-timeline.vue5
-rw-r--r--packages/frontend/src/pages/channel.vue2
-rw-r--r--packages/frontend/src/pages/clip.vue2
-rw-r--r--packages/frontend/src/pages/contact.vue26
-rw-r--r--packages/frontend/src/pages/custom-emojis-manager.vue10
-rw-r--r--packages/frontend/src/pages/drive.file.info.vue49
-rw-r--r--packages/frontend/src/pages/drop-and-fusion.game.vue16
-rw-r--r--packages/frontend/src/pages/emoji-edit-dialog.vue6
-rw-r--r--packages/frontend/src/pages/emojis.emoji.vue10
-rw-r--r--packages/frontend/src/pages/flash/flash-edit.vue11
-rw-r--r--packages/frontend/src/pages/flash/flash.vue5
-rw-r--r--packages/frontend/src/pages/follow.vue71
-rw-r--r--packages/frontend/src/pages/gallery/post.vue2
-rw-r--r--packages/frontend/src/pages/games.vue11
-rw-r--r--packages/frontend/src/pages/instance-info.vue15
-rw-r--r--packages/frontend/src/pages/lookup.vue97
-rw-r--r--packages/frontend/src/pages/my-antennas/create.vue32
-rw-r--r--packages/frontend/src/pages/my-antennas/edit.vue17
-rw-r--r--packages/frontend/src/pages/my-antennas/editor.vue146
-rw-r--r--packages/frontend/src/pages/my-clips/index.vue2
-rw-r--r--packages/frontend/src/pages/my-lists/list.vue31
-rw-r--r--packages/frontend/src/pages/page.vue3
-rw-r--r--packages/frontend/src/pages/preview.vue26
-rw-r--r--packages/frontend/src/pages/reset-password.vue4
-rw-r--r--packages/frontend/src/pages/reversi/game.board.vue2
-rw-r--r--packages/frontend/src/pages/reversi/game.vue3
-rw-r--r--packages/frontend/src/pages/search.note.vue202
-rw-r--r--packages/frontend/src/pages/search.stories.impl.ts88
-rw-r--r--packages/frontend/src/pages/search.user.vue72
-rw-r--r--packages/frontend/src/pages/search.vue34
-rw-r--r--packages/frontend/src/pages/settings/2fa.vue6
-rw-r--r--packages/frontend/src/pages/settings/accounts.vue10
-rw-r--r--packages/frontend/src/pages/settings/api.vue5
-rw-r--r--packages/frontend/src/pages/settings/avatar-decoration.vue5
-rw-r--r--packages/frontend/src/pages/settings/drive-cleaner.vue6
-rw-r--r--packages/frontend/src/pages/settings/drive.vue2
-rw-r--r--packages/frontend/src/pages/settings/general.vue11
-rw-r--r--packages/frontend/src/pages/settings/plugin.vue2
-rw-r--r--packages/frontend/src/pages/settings/preferences-backups.vue2
-rw-r--r--packages/frontend/src/pages/settings/profile.vue1
-rw-r--r--packages/frontend/src/pages/settings/sounds.sound.vue44
-rw-r--r--packages/frontend/src/pages/settings/sounds.vue12
-rw-r--r--packages/frontend/src/pages/settings/statusbar.statusbar.vue4
-rw-r--r--packages/frontend/src/pages/settings/theme.manage.vue2
-rw-r--r--packages/frontend/src/pages/settings/theme.vue6
-rw-r--r--packages/frontend/src/pages/settings/webhook.edit.vue2
-rw-r--r--packages/frontend/src/pages/settings/webhook.new.vue2
-rw-r--r--packages/frontend/src/pages/timeline.vue73
-rw-r--r--packages/frontend/src/pages/user/home.vue23
-rw-r--r--packages/frontend/src/pages/welcome.timeline.note.vue109
-rw-r--r--packages/frontend/src/pages/welcome.timeline.vue98
72 files changed, 2274 insertions, 809 deletions
diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue
index b55ae220d8..8db71c8881 100644
--- a/packages/frontend/src/pages/about-misskey.vue
+++ b/packages/frontend/src/pages/about-misskey.vue
@@ -102,13 +102,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>Special thanks</template>
<div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(130px, 1fr));grid-gap:24px;align-items:center;">
<div>
- <a style="display: inline-block;" class="masknetwork" title="Mask Network" href="https://mask.io/" target="_blank"><img style="width: 100%;" src="https://misskey-hub.net/sponsors/masknetwork.png" alt="Mask Network"></a>
+ <a style="display: inline-block;" class="masknetwork" title="Mask Network" href="https://mask.io/" target="_blank"><img style="width: 100%;" src="https://assets.misskey-hub.net/sponsors/masknetwork.png" alt="Mask Network"></a>
</div>
<div>
- <a style="display: inline-block;" class="xserver" title="XServer" href="https://www.xserver.ne.jp/" target="_blank"><img style="width: 100%;" src="https://misskey-hub.net/sponsors/xserver.png" alt="XServer"></a>
+ <a style="display: inline-block;" class="xserver" title="XServer" href="https://www.xserver.ne.jp/" target="_blank"><img style="width: 100%;" src="https://assets.misskey-hub.net/sponsors/xserver.png" alt="XServer"></a>
</div>
<div>
- <a style="display: inline-block;" class="skeb" title="Skeb" href="https://skeb.jp/" target="_blank"><img style="width: 100%;" src="https://misskey-hub.net/sponsors/skeb.svg" alt="Skeb"></a>
+ <a style="display: inline-block;" class="skeb" title="Skeb" href="https://skeb.jp/" target="_blank"><img style="width: 100%;" src="https://assets.misskey-hub.net/sponsors/skeb.svg" alt="Skeb"></a>
+ </div>
+ <div>
+ <a style="display: inline-block;" class="pepabo" title="GMO Pepabo" href="https://pepabo.com/" target="_blank"><img style="width: 100%;" src="https://assets.misskey-hub.net/sponsors/gmo_pepabo.svg" alt="GMO Pepabo"></a>
</div>
</div>
</FormSection>
@@ -240,6 +243,27 @@ const patronsWithIcon = [{
}, {
name: '越貝鯛丸',
icon: 'https://assets.misskey-hub.net/patrons/86c7374de37849b882d8ebbc833dc968.jpg',
+}, {
+ name: '☔あめ🍬(灬˘╰╯˘灬)',
+ icon: 'https://assets.misskey-hub.net/patrons/676eea72d4884d3f89aababbb62533fb.jpg',
+}, {
+ name: '貯水よび',
+ icon: 'https://assets.misskey-hub.net/patrons/2974506d53244bbe94a67707b27099e2.jpg',
+}, {
+ name: 'はるかさ',
+ icon: 'https://assets.misskey-hub.net/patrons/26ce2432739a400aa3aa0de0ef67a107.jpg',
+}, {
+ name: '天鈴のあ',
+ icon: 'https://assets.misskey-hub.net/patrons/995cdbb00bd6421184461a883adfe1d9.jpg',
+}, {
+ name: 'えとゔぁす',
+ icon: 'https://assets.misskey-hub.net/patrons/2578f441b82a44cfaa55ba83a318b26e.jpg',
+}, {
+ name: 'Soli',
+ icon: 'https://assets.misskey-hub.net/patrons/448070c81ebd41eda4ea2328291b2efe.jpg',
+}, {
+ name: 'ささくれりょう',
+ icon: 'https://assets.misskey-hub.net/patrons/cf55022cee6c41da8e70a43587aaad9a.jpg',
}];
const patrons = [
@@ -344,6 +368,7 @@ const patrons = [
'SHO SEKIGUCHI',
'塩キャベツ',
'はとぽぷさん',
+ '100の人 (エスパー・イーシア)',
];
const thereIsTreasure = ref($i && !claimedAchievements.includes('foundTreasure'));
diff --git a/packages/frontend/src/pages/about.federation.vue b/packages/frontend/src/pages/about.federation.vue
index 24e96b4f4e..b3776c67e6 100644
--- a/packages/frontend/src/pages/about.federation.vue
+++ b/packages/frontend/src/pages/about.federation.vue
@@ -71,9 +71,9 @@ const pagination = {
sort: sort.value,
host: host.value !== '' ? host.value : null,
...(
- state.value === 'federating' ? { federating: true } :
- state.value === 'subscribing' ? { subscribing: true } :
- state.value === 'publishing' ? { publishing: true } :
+ state.value === 'federating' ? { federating: true, suspended: false, blocked: false } :
+ state.value === 'subscribing' ? { subscribing: true, suspended: false, blocked: false } :
+ state.value === 'publishing' ? { publishing: true, suspended: false, blocked: false } :
state.value === 'suspended' ? { suspended: true } :
state.value === 'blocked' ? { blocked: true } :
state.value === 'silenced' ? { silenced: true } :
diff --git a/packages/frontend/src/pages/about.overview.vue b/packages/frontend/src/pages/about.overview.vue
new file mode 100644
index 0000000000..84419b3bef
--- /dev/null
+++ b/packages/frontend/src/pages/about.overview.vue
@@ -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>
diff --git a/packages/frontend/src/pages/about.vue b/packages/frontend/src/pages/about.vue
index 324d1c11de..8dfeb6d2a7 100644
--- a/packages/frontend/src/pages/about.vue
+++ b/packages/frontend/src/pages/about.vue
@@ -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>
diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue
index f57aa51b5b..1459997dcb 100644
--- a/packages/frontend/src/pages/admin-user.vue
+++ b/packages/frontend/src/pages/admin-user.vue
@@ -464,16 +464,20 @@ function toggleRoleItem(role) {
}
function createAnnouncement() {
- os.popup(defineAsyncComponent(() => import('@/components/MkUserAnnouncementEditDialog.vue')), {
+ const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkUserAnnouncementEditDialog.vue')), {
user: user.value,
- }, {}, 'closed');
+ }, {
+ closed: () => dispose(),
+ });
}
function editAnnouncement(announcement) {
- os.popup(defineAsyncComponent(() => import('@/components/MkUserAnnouncementEditDialog.vue')), {
+ const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkUserAnnouncementEditDialog.vue')), {
user: user.value,
announcement,
- }, {}, 'closed');
+ }, {
+ closed: () => dispose(),
+ });
}
watch(() => props.userId, () => {
diff --git a/packages/frontend/src/pages/admin/_header_.vue b/packages/frontend/src/pages/admin/_header_.vue
index c5a9609e6e..b88f078598 100644
--- a/packages/frontend/src/pages/admin/_header_.vue
+++ b/packages/frontend/src/pages/admin/_header_.vue
@@ -24,8 +24,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="buttons right">
<template v-if="actions">
<template v-for="action in actions">
- <MkButton v-if="action.asFullButton" class="fullButton" primary @click.stop="action.handler"><i :class="action.icon" style="margin-right: 6px;"></i>{{ action.text }}</MkButton>
- <button v-else v-tooltip.noDelay="action.text" class="_button button" :class="{ highlighted: action.highlighted }" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button>
+ <MkButton v-if="action.asFullButton" class="fullButton" primary :disabled="action.disabled" @click.stop="action.handler"><i :class="action.icon" style="margin-right: 6px;"></i>{{ action.text }}</MkButton>
+ <button v-else v-tooltip.noDelay="action.text" class="_button button" :class="{ highlighted: action.highlighted }" :disabled="action.disabled" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button>
</template>
</template>
</div>
@@ -56,6 +56,7 @@ const props = defineProps<{
text: string;
icon: string;
asFullButton?: boolean;
+ disabled?: boolean;
handler: (ev: MouseEvent) => void;
}[];
thin?: boolean;
diff --git a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue
new file mode 100644
index 0000000000..827e22e8ae
--- /dev/null
+++ b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue
@@ -0,0 +1,321 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkModalWindow
+ ref="dialogEl"
+ :width="400"
+ :height="490"
+ :withOkButton="false"
+ :okButtonDisabled="false"
+ @close="onCancelClicked"
+ @closed="emit('closed')"
+>
+ <template #header>
+ {{ mode === 'create' ? i18n.ts._abuseReport._notificationRecipient.createRecipient : i18n.ts._abuseReport._notificationRecipient.modifyRecipient }}
+ </template>
+ <div v-if="loading === 0" style="display: flex; flex-direction: column; min-height: 100%;">
+ <MkSpacer :marginMin="20" :marginMax="28" style="flex-grow: 1;">
+ <div :class="$style.root" class="_gaps_m">
+ <MkInput v-model="title">
+ <template #label>{{ i18n.ts.title }}</template>
+ </MkInput>
+ <MkSelect v-model="method">
+ <template #label>{{ i18n.ts._abuseReport._notificationRecipient.recipientType }}</template>
+ <option value="email">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.mail }}</option>
+ <option value="webhook">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.webhook }}</option>
+ <template #caption>
+ {{ methodCaption }}
+ </template>
+ </MkSelect>
+ <div>
+ <MkSelect v-if="method === 'email'" v-model="userId">
+ <template #label>{{ i18n.ts._abuseReport._notificationRecipient.notifiedUser }}</template>
+ <option v-for="user in moderators" :key="user.id" :value="user.id">
+ {{ user.name ? `${user.name}(${user.username})` : user.username }}
+ </option>
+ </MkSelect>
+ <div v-else-if="method === 'webhook'" :class="$style.systemWebhook">
+ <MkSelect v-model="systemWebhookId" style="flex: 1">
+ <template #label>{{ i18n.ts._abuseReport._notificationRecipient.notifiedWebhook }}</template>
+ <option v-for="webhook in systemWebhooks" :key="webhook.id ?? undefined" :value="webhook.id">
+ {{ webhook.name }}
+ </option>
+ </MkSelect>
+ <MkButton rounded :class="$style.systemWebhookEditButton" @click="onEditSystemWebhookClicked">
+ <span v-if="systemWebhookId === null" class="ti ti-plus" style="line-height: normal"/>
+ <span v-else class="ti ti-settings" style="line-height: normal"/>
+ </MkButton>
+ </div>
+ </div>
+
+ <MkDivider/>
+
+ <MkSwitch v-model="isActive">
+ <template #label>{{ i18n.ts.enable }}</template>
+ </MkSwitch>
+ </div>
+ </MkSpacer>
+
+ <div :class="$style.footer" class="_buttonsCenter">
+ <MkButton primary rounded :disabled="disableSubmitButton" @click="onSubmitClicked"><i class="ti ti-check"></i> {{ i18n.ts.ok }}</MkButton>
+ <MkButton rounded @click="onCancelClicked"><i class="ti ti-x"></i> {{ i18n.ts.cancel }}</MkButton>
+ </div>
+ </div>
+ <div v-else>
+ <MkLoading/>
+ </div>
+</MkModalWindow>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, ref, shallowRef, toRefs } from 'vue';
+import { entities } from 'misskey-js';
+import MkButton from '@/components/MkButton.vue';
+import MkModalWindow from '@/components/MkModalWindow.vue';
+import { i18n } from '@/i18n.js';
+import MkInput from '@/components/MkInput.vue';
+import { misskeyApi } from '@/scripts/misskey-api.js';
+import MkSelect from '@/components/MkSelect.vue';
+import { MkSystemWebhookResult, showSystemWebhookEditorDialog } from '@/components/MkSystemWebhookEditor.impl.js';
+import MkSwitch from '@/components/MkSwitch.vue';
+import MkDivider from '@/components/MkDivider.vue';
+import * as os from '@/os.js';
+
+type NotificationRecipientMethod = 'email' | 'webhook';
+
+const emit = defineEmits<{
+ (ev: 'submitted'): void;
+ (ev: 'canceled'): void;
+ (ev: 'closed'): void;
+}>();
+
+const props = defineProps<{
+ mode: 'create' | 'edit';
+ id?: string;
+}>();
+
+const { mode, id } = toRefs(props);
+
+const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>();
+
+const loading = ref<number>(0);
+
+const title = ref<string>('');
+const method = ref<NotificationRecipientMethod>('email');
+const userId = ref<string | null>(null);
+const systemWebhookId = ref<string | null>(null);
+const isActive = ref<boolean>(true);
+
+const moderators = ref<entities.User[]>([]);
+const systemWebhooks = ref<(entities.SystemWebhook | { id: null, name: string })[]>([]);
+
+const methodCaption = computed(() => {
+ switch (method.value) {
+ case 'email': {
+ return i18n.ts._abuseReport._notificationRecipient._recipientType._captions.mail;
+ }
+ case 'webhook': {
+ return i18n.ts._abuseReport._notificationRecipient._recipientType._captions.webhook;
+ }
+ default: {
+ return '';
+ }
+ }
+});
+
+const disableSubmitButton = computed(() => {
+ if (!title.value) {
+ return true;
+ }
+
+ switch (method.value) {
+ case 'email': {
+ return userId.value === null;
+ }
+ case 'webhook': {
+ return systemWebhookId.value === null;
+ }
+ default: {
+ return true;
+ }
+ }
+});
+
+async function onSubmitClicked() {
+ await loadingScope(async () => {
+ const _userId = (method.value === 'email') ? userId.value : null;
+ const _systemWebhookId = (method.value === 'webhook') ? systemWebhookId.value : null;
+ const params = {
+ isActive: isActive.value,
+ name: title.value,
+ method: method.value,
+ userId: _userId ?? undefined,
+ systemWebhookId: _systemWebhookId ?? undefined,
+ };
+
+ try {
+ switch (mode.value) {
+ case 'create': {
+ await misskeyApi('admin/abuse-report/notification-recipient/create', params);
+ break;
+ }
+ case 'edit': {
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ await misskeyApi('admin/abuse-report/notification-recipient/update', { id: id.value!, ...params });
+ break;
+ }
+ }
+
+ dialogEl.value?.close();
+ emit('submitted');
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } catch (ex: any) {
+ const msg = ex.message ?? i18n.ts.internalServerErrorDescription;
+ await os.alert({ type: 'error', title: i18n.ts.error, text: msg });
+ dialogEl.value?.close();
+ emit('canceled');
+ }
+ });
+}
+
+function onCancelClicked() {
+ dialogEl.value?.close();
+ emit('canceled');
+}
+
+async function onEditSystemWebhookClicked() {
+ let result: MkSystemWebhookResult | null;
+ if (systemWebhookId.value === null) {
+ result = await showSystemWebhookEditorDialog({
+ mode: 'create',
+ });
+ } else {
+ result = await showSystemWebhookEditorDialog({
+ mode: 'edit',
+ id: systemWebhookId.value,
+ });
+ }
+ if (!result) {
+ return;
+ }
+
+ await fetchSystemWebhooks();
+ systemWebhookId.value = result.id ?? null;
+}
+
+async function fetchSystemWebhooks() {
+ await loadingScope(async () => {
+ systemWebhooks.value = [
+ { id: null, name: i18n.ts.createNew },
+ ...await misskeyApi('admin/system-webhook/list', { }),
+ ];
+ });
+}
+
+async function fetchModerators() {
+ await loadingScope(async () => {
+ const users = Array.of<entities.User>();
+ for (; ;) {
+ const res = await misskeyApi('admin/show-users', {
+ limit: 100,
+ state: 'adminOrModerator',
+ origin: 'local',
+ offset: users.length,
+ });
+
+ if (res.length === 0) {
+ break;
+ }
+
+ users.push(...res);
+ }
+
+ moderators.value = users;
+ });
+}
+
+async function loadingScope<T>(fn: () => Promise<T>): Promise<T> {
+ loading.value++;
+ try {
+ return await fn();
+ } finally {
+ loading.value--;
+ }
+}
+
+onMounted(async () => {
+ await loadingScope(async () => {
+ await fetchModerators();
+ await fetchSystemWebhooks();
+
+ if (mode.value === 'edit') {
+ if (!id.value) {
+ throw new Error('id is required');
+ }
+
+ try {
+ const res = await misskeyApi('admin/abuse-report/notification-recipient/show', { id: id.value });
+
+ title.value = res.name;
+ method.value = res.method;
+ userId.value = res.userId ?? null;
+ systemWebhookId.value = res.systemWebhookId ?? null;
+ isActive.value = res.isActive;
+ // eslint-disable-next-line
+ } catch (ex: any) {
+ const msg = ex.message ?? i18n.ts.internalServerErrorDescription;
+ await os.alert({ type: 'error', title: i18n.ts.error, text: msg });
+ dialogEl.value?.close();
+ emit('canceled');
+ }
+ } else {
+ userId.value = moderators.value[0]?.id ?? null;
+ systemWebhookId.value = systemWebhooks.value[0]?.id ?? null;
+ }
+ });
+});
+
+</script>
+
+<style lang="scss" module>
+.root {
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ align-items: stretch;
+}
+
+.footer {
+ position: sticky;
+ z-index: 10000;
+ bottom: 0;
+ left: 0;
+ padding: 12px;
+ border-top: solid 0.5px var(--divider);
+ background: var(--acrylicBg);
+ -webkit-backdrop-filter: var(--blur, blur(15px));
+ backdrop-filter: var(--blur, blur(15px));
+}
+
+.systemWebhook {
+ display: flex;
+ flex-direction: row;
+ justify-content: stretch;
+ align-items: flex-end;
+ gap: 8px;
+}
+
+.systemWebhookEditButton {
+ min-width: 0;
+ min-height: 0;
+ width: 34px;
+ height: 34px;
+ flex-shrink: 0;
+ box-sizing: border-box;
+ margin: 1px 0;
+ padding: 6px;
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.item.vue b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.item.vue
new file mode 100644
index 0000000000..0b86808faf
--- /dev/null
+++ b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.item.vue
@@ -0,0 +1,114 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="$style.root" class="_panel _gaps_s">
+ <div :class="$style.rightDivider" style="width: 80px;"><span :class="`ti ${methodIcon}`"/> {{ methodName }}</div>
+ <div :class="$style.rightDivider" style="flex: 0.5">{{ entity.name }}</div>
+ <div :class="$style.rightDivider" style="flex: 1">
+ <div v-if="method === 'email' && user">
+ {{
+ `${i18n.ts._abuseReport._notificationRecipient.notifiedUser}: ` + ((user.name) ? `${user.name}(${user.username})` : user.username)
+ }}
+ </div>
+ <div v-if="method === 'webhook' && systemWebhook">
+ {{ `${i18n.ts._abuseReport._notificationRecipient.notifiedWebhook}: ` + systemWebhook.name }}
+ </div>
+ </div>
+ <div :class="$style.recipientButtons" style="margin-left: auto">
+ <button :class="$style.recipientButton" @click="onEditButtonClicked()">
+ <span class="ti ti-settings"/>
+ </button>
+ <button :class="$style.recipientButton" @click="onDeleteButtonClicked()">
+ <span class="ti ti-trash"/>
+ </button>
+ </div>
+</div>
+</template>
+
+<script setup lang="ts">
+import { entities } from 'misskey-js';
+import { computed, toRefs } from 'vue';
+import { i18n } from '@/i18n.js';
+
+const emit = defineEmits<{
+ (ev: 'edit', id: entities.AbuseReportNotificationRecipient['id']): void;
+ (ev: 'delete', id: entities.AbuseReportNotificationRecipient['id']): void;
+}>();
+
+const props = defineProps<{
+ entity: entities.AbuseReportNotificationRecipient;
+}>();
+
+const { entity } = toRefs(props);
+
+const method = computed(() => entity.value.method);
+const user = computed(() => entity.value.user);
+const systemWebhook = computed(() => entity.value.systemWebhook);
+const methodIcon = computed(() => {
+ switch (entity.value.method) {
+ case 'email':
+ return 'ti-mail';
+ case 'webhook':
+ return 'ti-webhook';
+ default:
+ return 'ti-help';
+ }
+});
+const methodName = computed(() => {
+ switch (entity.value.method) {
+ case 'email':
+ return i18n.ts._abuseReport._notificationRecipient._recipientType.mail;
+ case 'webhook':
+ return i18n.ts._abuseReport._notificationRecipient._recipientType.webhook;
+ default:
+ return '不明';
+ }
+});
+
+function onEditButtonClicked() {
+ emit('edit', entity.value.id);
+}
+
+function onDeleteButtonClicked() {
+ emit('delete', entity.value.id);
+}
+</script>
+
+<style module lang="scss">
+.root {
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ align-items: center;
+ padding: 4px 8px;
+}
+
+.rightDivider {
+ border-right: 0.5px solid var(--divider);
+}
+
+.recipientButtons {
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ align-items: center;
+ margin-right: -4;
+}
+
+.recipientButton {
+ background-color: transparent;
+ border: none;
+ border-radius: 9999px;
+ box-sizing: border-box;
+ margin-top: -2px;
+ margin-bottom: -2px;
+ padding: 8px;
+
+ &:hover {
+ background-color: var(--buttonBg);
+ }
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue
new file mode 100644
index 0000000000..f5249261be
--- /dev/null
+++ b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue
@@ -0,0 +1,177 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkStickyContainer>
+ <template #header>
+ <XHeader :actions="headerActions" :tabs="headerTabs"/>
+ </template>
+
+ <MkSpacer :contentMax="900">
+ <div :class="$style.root" class="_gaps_m">
+ <div :class="$style.addButton">
+ <MkButton primary @click="onAddButtonClicked">
+ <span class="ti ti-plus"/> {{ i18n.ts._abuseReport._notificationRecipient.createRecipient }}
+ </MkButton>
+ </div>
+ <div :class="$style.subMenus" class="_gaps_s">
+ <MkSelect v-model="filterMethod" style="flex: 1">
+ <template #label>{{ i18n.ts._abuseReport._notificationRecipient.recipientType }}</template>
+ <option :value="null">-</option>
+ <option :value="'email'">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.mail }}</option>
+ <option :value="'webhook'">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.webhook }}</option>
+ </MkSelect>
+ <MkInput v-model="filterText" type="search" style="flex: 1">
+ <template #label>{{ i18n.ts._abuseReport._notificationRecipient.keywords }}</template>
+ </MkInput>
+ </div>
+
+ <MkDivider/>
+
+ <div :class="$style.recipients" class="_gaps_s">
+ <XRecipient
+ v-for="r in filteredRecipients"
+ :key="r.id"
+ :entity="r"
+ @edit="onEditButtonClicked"
+ @delete="onDeleteButtonClicked"
+ />
+ </div>
+ </div>
+ </MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script setup lang="ts">
+import { entities } from 'misskey-js';
+import { computed, defineAsyncComponent, onMounted, ref } from 'vue';
+import XRecipient from './notification-recipient.item.vue';
+import XHeader from '@/pages/admin/_header_.vue';
+import { misskeyApi } from '@/scripts/misskey-api.js';
+import MkInput from '@/components/MkInput.vue';
+import MkSelect from '@/components/MkSelect.vue';
+import MkButton from '@/components/MkButton.vue';
+import * as os from '@/os.js';
+import MkDivider from '@/components/MkDivider.vue';
+import { i18n } from '@/i18n.js';
+
+const recipients = ref<entities.AbuseReportNotificationRecipient[]>([]);
+
+const filterMethod = ref<string | null>(null);
+const filterText = ref<string>('');
+
+const filteredRecipients = computed(() => {
+ const method = filterMethod.value;
+ const text = filterText.value.trim().length === 0 ? null : filterText.value;
+
+ return recipients.value.filter(it => {
+ if (method ?? text) {
+ if (text) {
+ const keywords = [it.name, it.systemWebhook?.name, it.user?.name, it.user?.username];
+ if (keywords.filter(k => k?.includes(text)).length !== 0) {
+ return true;
+ }
+ }
+
+ if (method) {
+ return it.method.includes(method);
+ }
+
+ return false;
+ }
+
+ return true;
+ });
+});
+const headerActions = computed(() => []);
+const headerTabs = computed(() => []);
+
+async function onAddButtonClicked() {
+ await showEditor('create');
+}
+
+async function onEditButtonClicked(id: string) {
+ await showEditor('edit', id);
+}
+
+async function onDeleteButtonClicked(id: string) {
+ const res = await os.confirm({
+ type: 'warning',
+ title: i18n.ts._abuseReport._notificationRecipient.deleteConfirm,
+ });
+ if (!res.canceled) {
+ await misskeyApi('admin/abuse-report/notification-recipient/delete', { id: id });
+ await fetchRecipients();
+ }
+}
+
+async function showEditor(mode: 'create' | 'edit', id?: string) {
+ const { needLoad } = await new Promise<{ needLoad: boolean }>(async resolve => {
+ const { dispose } = os.popup(
+ defineAsyncComponent(() => import('./notification-recipient.editor.vue')),
+ {
+ mode,
+ id,
+ },
+ {
+ submitted: () => {
+ resolve({ needLoad: true });
+ },
+ canceled: () => {
+ resolve({ needLoad: false });
+ },
+ closed: () => {
+ dispose();
+ },
+ },
+ );
+ });
+
+ if (needLoad) {
+ await fetchRecipients();
+ }
+}
+
+async function fetchRecipients() {
+ const result = await misskeyApi('admin/abuse-report/notification-recipient/list', {
+ method: ['email', 'webhook'],
+ });
+
+ recipients.value = result.sort((a, b) => (a.method + a.id).localeCompare(b.method + b.id));
+}
+
+onMounted(async () => {
+ await fetchRecipients();
+});
+</script>
+
+<style module lang="scss">
+.root {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: stretch;
+}
+
+.addButton {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
+.subMenus {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: flex-end;
+}
+
+.recipients {
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ align-items: stretch;
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/abuses.vue b/packages/frontend/src/pages/admin/abuses.vue
index d2f4a4b531..9a9fa472a5 100644
--- a/packages/frontend/src/pages/admin/abuses.vue
+++ b/packages/frontend/src/pages/admin/abuses.vue
@@ -7,30 +7,33 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkStickyContainer>
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="900">
- <div>
- <div class="reports">
- <div class="">
- <div class="inputs" style="display: flex;">
- <MkSelect v-model="state" style="margin: 0; flex: 1;">
- <template #label>{{ i18n.ts.state }}</template>
- <option value="all">{{ i18n.ts.all }}</option>
- <option value="unresolved">{{ i18n.ts.unresolved }}</option>
- <option value="resolved">{{ i18n.ts.resolved }}</option>
- </MkSelect>
- <MkSelect v-model="targetUserOrigin" style="margin: 0; flex: 1;">
- <template #label>{{ i18n.ts.reporteeOrigin }}</template>
- <option value="combined">{{ i18n.ts.all }}</option>
- <option value="local">{{ i18n.ts.local }}</option>
- <option value="remote">{{ i18n.ts.remote }}</option>
- </MkSelect>
- <MkSelect v-model="reporterOrigin" style="margin: 0; flex: 1;">
- <template #label>{{ i18n.ts.reporterOrigin }}</template>
- <option value="combined">{{ i18n.ts.all }}</option>
- <option value="local">{{ i18n.ts.local }}</option>
- <option value="remote">{{ i18n.ts.remote }}</option>
- </MkSelect>
- </div>
- <!-- TODO
+ <div :class="$style.root" class="_gaps">
+ <div :class="$style.subMenus" class="_gaps">
+ <MkButton link to="/admin/abuse-report-notification-recipient" primary>{{ "通知設定" }}</MkButton>
+ </div>
+
+ <div :class="$style.inputs" class="_gaps">
+ <MkSelect v-model="state" style="margin: 0; flex: 1;">
+ <template #label>{{ i18n.ts.state }}</template>
+ <option value="all">{{ i18n.ts.all }}</option>
+ <option value="unresolved">{{ i18n.ts.unresolved }}</option>
+ <option value="resolved">{{ i18n.ts.resolved }}</option>
+ </MkSelect>
+ <MkSelect v-model="targetUserOrigin" style="margin: 0; flex: 1;">
+ <template #label>{{ i18n.ts.reporteeOrigin }}</template>
+ <option value="combined">{{ i18n.ts.all }}</option>
+ <option value="local">{{ i18n.ts.local }}</option>
+ <option value="remote">{{ i18n.ts.remote }}</option>
+ </MkSelect>
+ <MkSelect v-model="reporterOrigin" style="margin: 0; flex: 1;">
+ <template #label>{{ i18n.ts.reporterOrigin }}</template>
+ <option value="combined">{{ i18n.ts.all }}</option>
+ <option value="local">{{ i18n.ts.local }}</option>
+ <option value="remote">{{ i18n.ts.remote }}</option>
+ </MkSelect>
+ </div>
+
+ <!-- TODO
<div class="inputs" style="display: flex; padding-top: 1.2em;">
<MkInput v-model="searchUsername" style="margin: 0; flex: 1;" type="text" :spellcheck="false">
<span>{{ i18n.ts.username }}</span>
@@ -41,11 +44,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
-->
- <MkPagination v-slot="{items}" ref="reports" :pagination="pagination" style="margin-top: var(--margin);">
- <XAbuseReport v-for="report in items" :key="report.id" :report="report" @resolved="resolved"/>
- </MkPagination>
- </div>
- </div>
+ <MkPagination v-slot="{items}" ref="reports" :pagination="pagination" style="margin-top: var(--margin);">
+ <XAbuseReport v-for="report in items" :key="report.id" :report="report" @resolved="resolved"/>
+ </MkPagination>
</div>
</MkSpacer>
</MkStickyContainer>
@@ -60,6 +61,7 @@ import MkPagination from '@/components/MkPagination.vue';
import XAbuseReport from '@/components/MkAbuseReport.vue';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
+import MkButton from '@/components/MkButton.vue';
const reports = shallowRef<InstanceType<typeof MkPagination>>();
@@ -80,7 +82,7 @@ const pagination = {
};
function resolved(reportId) {
- reports.value.removeItem(reportId);
+ reports.value?.removeItem(reportId);
}
const headerActions = computed(() => []);
@@ -92,3 +94,26 @@ definePageMetadata(() => ({
icon: 'ti ti-exclamation-circle',
}));
</script>
+
+<style module lang="scss">
+.root {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: stretch;
+}
+
+.subMenus {
+ display: flex;
+ flex-direction: row;
+ justify-content: flex-end;
+ align-items: center;
+}
+
+.inputs {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/announcements.vue b/packages/frontend/src/pages/admin/announcements.vue
index e7fb62ec1d..b9e09c8d03 100644
--- a/packages/frontend/src/pages/admin/announcements.vue
+++ b/packages/frontend/src/pages/admin/announcements.vue
@@ -11,70 +11,83 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInfo>{{ i18n.ts._announcement.shouldNotBeUsedToPresentPermanentInfo }}</MkInfo>
<MkInfo v-if="announcements.length > 5" warn>{{ i18n.ts._announcement.tooManyActiveAnnouncementDescription }}</MkInfo>
- <MkFolder v-for="announcement in announcements" :key="announcement.id ?? announcement._id" :defaultOpen="announcement.id == null">
- <template #label>{{ announcement.title }}</template>
- <template #icon>
- <i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i>
- <i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--warn);"></i>
- <i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--error);"></i>
- <i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i>
- </template>
- <template #caption>{{ announcement.text }}</template>
+ <MkSelect v-model="announcementsStatus">
+ <template #label>{{ i18n.ts.filter }}</template>
+ <option value="active">{{ i18n.ts.active }}</option>
+ <option value="archived">{{ i18n.ts.archived }}</option>
+ </MkSelect>
- <div class="_gaps_m">
- <MkInput v-model="announcement.title">
- <template #label>{{ i18n.ts.title }}</template>
- </MkInput>
- <MkTextarea v-model="announcement.text" mfmAutocomplete :mfmPreview="true">
- <template #label>{{ i18n.ts.text }}</template>
- </MkTextarea>
- <MkInput v-model="announcement.imageUrl" type="url">
- <template #label>{{ i18n.ts.imageUrl }}</template>
- </MkInput>
- <MkRadios v-model="announcement.icon">
- <template #label>{{ i18n.ts.icon }}</template>
- <option value="info"><i class="ti ti-info-circle"></i></option>
- <option value="warning"><i class="ti ti-alert-triangle" style="color: var(--warn);"></i></option>
- <option value="error"><i class="ti ti-circle-x" style="color: var(--error);"></i></option>
- <option value="success"><i class="ti ti-check" style="color: var(--success);"></i></option>
- </MkRadios>
- <MkRadios v-model="announcement.display">
- <template #label>{{ i18n.ts.display }}</template>
- <option value="normal">{{ i18n.ts.normal }}</option>
- <option value="banner">{{ i18n.ts.banner }}</option>
- <option value="dialog">{{ i18n.ts.dialog }}</option>
- </MkRadios>
- <MkInfo v-if="announcement.display === 'dialog'" warn>{{ i18n.ts._announcement.dialogAnnouncementUxWarn }}</MkInfo>
- <MkSwitch v-model="announcement.forExistingUsers" :helpText="i18n.ts._announcement.forExistingUsersDescription">
- {{ i18n.ts._announcement.forExistingUsers }}
- </MkSwitch>
- <MkSwitch v-model="announcement.silence" :helpText="i18n.ts._announcement.silenceDescription">
- {{ i18n.ts._announcement.silence }}
- </MkSwitch>
- <MkSwitch v-model="announcement.needConfirmationToRead" :helpText="i18n.ts._announcement.needConfirmationToReadDescription">
- {{ i18n.ts._announcement.needConfirmationToRead }}
- </MkSwitch>
- <p v-if="announcement.reads">{{ i18n.tsx.nUsersRead({ n: announcement.reads }) }}</p>
- <div class="buttons _buttons">
- <MkButton class="button" inline primary @click="save(announcement)"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
- <MkButton v-if="announcement.id != null" class="button" inline @click="archive(announcement)"><i class="ti ti-check"></i> {{ i18n.ts._announcement.end }} ({{ i18n.ts.archive }})</MkButton>
- <MkButton v-if="announcement.id != null" class="button" inline danger @click="del(announcement)"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
+ <MkLoading v-if="loading"/>
+
+ <template v-else>
+ <MkFolder v-for="announcement in announcements" :key="announcement.id ?? announcement._id" :defaultOpen="announcement.id == null">
+ <template #label>{{ announcement.title }}</template>
+ <template #icon>
+ <i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i>
+ <i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--warn);"></i>
+ <i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--error);"></i>
+ <i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i>
+ </template>
+ <template #caption>{{ announcement.text }}</template>
+
+ <div class="_gaps_m">
+ <MkInput v-model="announcement.title">
+ <template #label>{{ i18n.ts.title }}</template>
+ </MkInput>
+ <MkTextarea v-model="announcement.text" mfmAutocomplete :mfmPreview="true">
+ <template #label>{{ i18n.ts.text }}</template>
+ </MkTextarea>
+ <MkInput v-model="announcement.imageUrl" type="url">
+ <template #label>{{ i18n.ts.imageUrl }}</template>
+ </MkInput>
+ <MkRadios v-model="announcement.icon">
+ <template #label>{{ i18n.ts.icon }}</template>
+ <option value="info"><i class="ti ti-info-circle"></i></option>
+ <option value="warning"><i class="ti ti-alert-triangle" style="color: var(--warn);"></i></option>
+ <option value="error"><i class="ti ti-circle-x" style="color: var(--error);"></i></option>
+ <option value="success"><i class="ti ti-check" style="color: var(--success);"></i></option>
+ </MkRadios>
+ <MkRadios v-model="announcement.display">
+ <template #label>{{ i18n.ts.display }}</template>
+ <option value="normal">{{ i18n.ts.normal }}</option>
+ <option value="banner">{{ i18n.ts.banner }}</option>
+ <option value="dialog">{{ i18n.ts.dialog }}</option>
+ </MkRadios>
+ <MkInfo v-if="announcement.display === 'dialog'" warn>{{ i18n.ts._announcement.dialogAnnouncementUxWarn }}</MkInfo>
+ <MkSwitch v-model="announcement.forExistingUsers" :helpText="i18n.ts._announcement.forExistingUsersDescription">
+ {{ i18n.ts._announcement.forExistingUsers }}
+ </MkSwitch>
+ <MkSwitch v-model="announcement.silence" :helpText="i18n.ts._announcement.silenceDescription">
+ {{ i18n.ts._announcement.silence }}
+ </MkSwitch>
+ <MkSwitch v-model="announcement.needConfirmationToRead" :helpText="i18n.ts._announcement.needConfirmationToReadDescription">
+ {{ i18n.ts._announcement.needConfirmationToRead }}
+ </MkSwitch>
+ <p v-if="announcement.reads">{{ i18n.tsx.nUsersRead({ n: announcement.reads }) }}</p>
+ <div class="buttons _buttons">
+ <MkButton class="button" inline primary @click="save(announcement)"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
+ <MkButton v-if="announcement.id != null && announcement.isActive" class="button" inline @click="archive(announcement)"><i class="ti ti-check"></i> {{ i18n.ts._announcement.end }} ({{ i18n.ts.archive }})</MkButton>
+ <MkButton v-if="announcement.id != null && !announcement.isActive" class="button" inline @click="unarchive(announcement)"><i class="ti ti-restore"></i> {{ i18n.ts.unarchive }}</MkButton>
+ <MkButton v-if="announcement.id != null" class="button" inline danger @click="del(announcement)"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
+ </div>
</div>
- </div>
- </MkFolder>
- <MkButton class="button" @click="more()">
- <i class="ti ti-reload"></i>{{ i18n.ts.more }}
- </MkButton>
+ </MkFolder>
+ <MkLoading v-if="loadingMore"/>
+ <MkButton class="button" @click="more()">
+ <i class="ti ti-reload"></i>{{ i18n.ts.more }}
+ </MkButton>
+ </template>
</div>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
-import { ref, computed } from 'vue';
+import { ref, computed, watch } from 'vue';
import XHeader from './_header_.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
+import MkSelect from '@/components/MkSelect.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkRadios from '@/components/MkRadios.vue';
import MkInfo from '@/components/MkInfo.vue';
@@ -85,11 +98,22 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
import MkFolder from '@/components/MkFolder.vue';
import MkTextarea from '@/components/MkTextarea.vue';
+const announcementsStatus = ref<'active' | 'archived'>('active');
+
+const loading = ref(true);
+const loadingMore = ref(false);
+
const announcements = ref<any[]>([]);
-misskeyApi('admin/announcements/list').then(announcementResponse => {
- announcements.value = announcementResponse;
-});
+watch(announcementsStatus, (to) => {
+ loading.value = true;
+ misskeyApi('admin/announcements/list', {
+ status: to,
+ }).then(announcementResponse => {
+ announcements.value = announcementResponse;
+ loading.value = false;
+ });
+}, { immediate: true });
function add() {
announcements.value.unshift({
@@ -125,6 +149,14 @@ async function archive(announcement) {
refresh();
}
+async function unarchive(announcement) {
+ await os.apiWithDialog('admin/announcements/update', {
+ ...announcement,
+ isActive: true,
+ });
+ refresh();
+}
+
async function save(announcement) {
if (announcement.id == null) {
await os.apiWithDialog('admin/announcements/create', announcement);
@@ -135,24 +167,32 @@ async function save(announcement) {
}
function more() {
- misskeyApi('admin/announcements/list', { untilId: announcements.value.reduce((acc, announcement) => announcement.id != null ? announcement : acc).id }).then(announcementResponse => {
+ loadingMore.value = true;
+ misskeyApi('admin/announcements/list', {
+ status: announcementsStatus.value,
+ untilId: announcements.value.reduce((acc, announcement) => announcement.id != null ? announcement : acc).id
+ }).then(announcementResponse => {
announcements.value = announcements.value.concat(announcementResponse);
+ loadingMore.value = false;
});
}
function refresh() {
- misskeyApi('admin/announcements/list').then(announcementResponse => {
+ loading.value = true;
+ misskeyApi('admin/announcements/list', {
+ status: announcementsStatus.value,
+ }).then(announcementResponse => {
announcements.value = announcementResponse;
+ loading.value = false;
});
}
-refresh();
-
const headerActions = computed(() => [{
asFullButton: true,
icon: 'ti ti-plus',
text: i18n.ts.add,
handler: add,
+ disabled: announcementsStatus.value === 'archived',
}]);
const headerTabs = computed(() => []);
diff --git a/packages/frontend/src/pages/admin/federation.vue b/packages/frontend/src/pages/admin/federation.vue
index 0aaa398584..debf684c9b 100644
--- a/packages/frontend/src/pages/admin/federation.vue
+++ b/packages/frontend/src/pages/admin/federation.vue
@@ -80,9 +80,9 @@ const pagination = {
sort: sort.value,
host: host.value !== '' ? host.value : null,
...(
- state.value === 'federating' ? { federating: true } :
- state.value === 'subscribing' ? { subscribing: true } :
- state.value === 'publishing' ? { publishing: true } :
+ state.value === 'federating' ? { federating: true, suspended: false, blocked: false } :
+ state.value === 'subscribing' ? { subscribing: true, suspended: false, blocked: false } :
+ state.value === 'publishing' ? { publishing: true, suspended: false, blocked: false } :
state.value === 'suspended' ? { suspended: true } :
state.value === 'blocked' ? { blocked: true } :
state.value === 'silenced' ? { silenced: true } :
diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue
index 794feae202..292f10da1a 100644
--- a/packages/frontend/src/pages/admin/index.vue
+++ b/packages/frontend/src/pages/admin/index.vue
@@ -215,6 +215,11 @@ const menuDef = computed(() => [{
to: '/admin/external-services',
active: currentPage.value?.route.name === 'external-services',
}, {
+ icon: 'ti ti-webhook',
+ text: 'Webhook',
+ to: '/admin/system-webhook',
+ active: currentPage.value?.route.name === 'system-webhook',
+ }, {
icon: 'ti ti-adjustments',
text: i18n.ts.other,
to: '/admin/other-settings',
diff --git a/packages/frontend/src/pages/admin/instance-block.vue b/packages/frontend/src/pages/admin/instance-block.vue
index 6b14bd42c2..e090616b26 100644
--- a/packages/frontend/src/pages/admin/instance-block.vue
+++ b/packages/frontend/src/pages/admin/instance-block.vue
@@ -8,14 +8,22 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header><XHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="700" :marginMin="16" :marginMax="32">
<FormSuspense :p="init">
- <MkTextarea v-if="tab === 'block'" v-model="blockedHosts">
- <span>{{ i18n.ts.blockedInstances }}</span>
- <template #caption>{{ i18n.ts.blockedInstancesDescription }}</template>
- </MkTextarea>
- <MkTextarea v-else-if="tab === 'silence'" v-model="silencedHosts" class="_formBlock">
- <span>{{ i18n.ts.silencedInstances }}</span>
- <template #caption>{{ i18n.ts.silencedInstancesDescription }}</template>
- </MkTextarea>
+ <template v-if="tab === 'block'">
+ <MkTextarea v-model="blockedHosts">
+ <span>{{ i18n.ts.blockedInstances }}</span>
+ <template #caption>{{ i18n.ts.blockedInstancesDescription }}</template>
+ </MkTextarea>
+ </template>
+ <template v-else-if="tab === 'silence'">
+ <MkTextarea v-model="silencedHosts" class="_formBlock">
+ <span>{{ i18n.ts.silencedInstances }}</span>
+ <template #caption>{{ i18n.ts.silencedInstancesDescription }}</template>
+ </MkTextarea>
+ <MkTextarea v-model="mediaSilencedHosts" class="_formBlock">
+ <span>{{ i18n.ts.mediaSilencedInstances }}</span>
+ <template #caption>{{ i18n.ts.mediaSilencedInstancesDescription }}</template>
+ </MkTextarea>
+ </template>
<MkButton primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
</FormSuspense>
</MkSpacer>
@@ -36,18 +44,21 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
const blockedHosts = ref<string>('');
const silencedHosts = ref<string>('');
+const mediaSilencedHosts = ref<string>('');
const tab = ref('block');
async function init() {
const meta = await misskeyApi('admin/meta');
blockedHosts.value = meta.blockedHosts.join('\n');
silencedHosts.value = meta.silencedHosts.join('\n');
+ mediaSilencedHosts.value = meta.mediaSilencedHosts.join('\n');
}
function save() {
os.apiWithDialog('admin/update-meta', {
blockedHosts: blockedHosts.value.split('\n') || [],
silencedHosts: silencedHosts.value.split('\n') || [],
+ mediaSilencedHosts: mediaSilencedHosts.value.split('\n') || [],
}).then(() => {
fetchInstance(true);
diff --git a/packages/frontend/src/pages/admin/invites.vue b/packages/frontend/src/pages/admin/invites.vue
index 95727fb14c..9cb430b0fe 100644
--- a/packages/frontend/src/pages/admin/invites.vue
+++ b/packages/frontend/src/pages/admin/invites.vue
@@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInput v-if="!noExpirationDate" v-model="expiresAt" type="datetime-local">
<template #label>{{ i18n.ts.expirationDate }}</template>
</MkInput>
- <MkInput v-model="createCount" type="number">
+ <MkInput v-model="createCount" type="number" min="1">
<template #label>{{ i18n.ts.createCount }}</template>
</MkInput>
<MkButton primary rounded @click="createWithOptions">{{ i18n.ts.create }}</MkButton>
diff --git a/packages/frontend/src/pages/admin/modlog.ModLog.vue b/packages/frontend/src/pages/admin/modlog.ModLog.vue
index e33c882721..91f1c7c5e6 100644
--- a/packages/frontend/src/pages/admin/modlog.ModLog.vue
+++ b/packages/frontend/src/pages/admin/modlog.ModLog.vue
@@ -8,9 +8,35 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>
<b
:class="{
- [$style.logGreen]: ['createRole', 'addCustomEmoji', 'createGlobalAnnouncement', 'createUserAnnouncement', 'createAd', 'createInvitation', 'createAvatarDecoration'].includes(log.type),
- [$style.logYellow]: ['markSensitiveDriveFile', 'resetPassword'].includes(log.type),
- [$style.logRed]: ['suspend', 'deleteRole', 'suspendRemoteInstance', 'deleteGlobalAnnouncement', 'deleteUserAnnouncement', 'deleteCustomEmoji', 'deleteNote', 'deleteDriveFile', 'deleteAd', 'deleteAvatarDecoration'].includes(log.type)
+ [$style.logGreen]: [
+ 'createRole',
+ 'addCustomEmoji',
+ 'createGlobalAnnouncement',
+ 'createUserAnnouncement',
+ 'createAd',
+ 'createInvitation',
+ 'createAvatarDecoration',
+ 'createSystemWebhook',
+ 'createAbuseReportNotificationRecipient',
+ ].includes(log.type),
+ [$style.logYellow]: [
+ 'markSensitiveDriveFile',
+ 'resetPassword'
+ ].includes(log.type),
+ [$style.logRed]: [
+ 'suspend',
+ 'deleteRole',
+ 'suspendRemoteInstance',
+ 'deleteGlobalAnnouncement',
+ 'deleteUserAnnouncement',
+ 'deleteCustomEmoji',
+ 'deleteNote',
+ 'deleteDriveFile',
+ 'deleteAd',
+ 'deleteAvatarDecoration',
+ 'deleteSystemWebhook',
+ 'deleteAbuseReportNotificationRecipient',
+ ].includes(log.type)
}"
>{{ i18n.ts._moderationLogTypes[log.type] }}</b>
<span v-if="log.type === 'updateUserNote'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
@@ -40,6 +66,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="log.type === 'createAvatarDecoration'">: {{ log.info.avatarDecoration.name }}</span>
<span v-else-if="log.type === 'updateAvatarDecoration'">: {{ log.info.before.name }}</span>
<span v-else-if="log.type === 'deleteAvatarDecoration'">: {{ log.info.avatarDecoration.name }}</span>
+ <span v-else-if="log.type === 'createSystemWebhook'">: {{ log.info.webhook.name }}</span>
+ <span v-else-if="log.type === 'updateSystemWebhook'">: {{ log.info.before.name }}</span>
+ <span v-else-if="log.type === 'deleteSystemWebhook'">: {{ log.info.webhook.name }}</span>
+ <span v-else-if="log.type === 'createAbuseReportNotificationRecipient'">: {{ log.info.recipient.name }}</span>
+ <span v-else-if="log.type === 'updateAbuseReportNotificationRecipient'">: {{ log.info.before.name }}</span>
+ <span v-else-if="log.type === 'deleteAbuseReportNotificationRecipient'">: {{ log.info.recipient.name }}</span>
</template>
<template #icon>
<MkAvatar :user="log.user" :class="$style.avatar"/>
@@ -116,6 +148,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<CodeDiff :context="5" :hideHeader="true" :oldString="log.info.before ?? ''" :newString="log.info.after ?? ''" maxHeight="300px"/>
</div>
</template>
+ <template v-else-if="log.type === 'updateSystemWebhook'">
+ <div :class="$style.diff">
+ <CodeDiff :context="5" :hideHeader="true" :oldString="JSON5.stringify(log.info.before, null, '\t')" :newString="JSON5.stringify(log.info.after, null, '\t')" language="javascript" maxHeight="300px"/>
+ </div>
+ </template>
+ <template v-else-if="log.type === 'updateAbuseReportNotificationRecipient'">
+ <div :class="$style.diff">
+ <CodeDiff :context="5" :hideHeader="true" :oldString="JSON5.stringify(log.info.before, null, '\t')" :newString="JSON5.stringify(log.info.after, null, '\t')" language="javascript" maxHeight="300px"/>
+ </div>
+ </template>
<details>
<summary>raw</summary>
diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue
index eb8a59b34f..3e948abdf1 100644
--- a/packages/frontend/src/pages/admin/roles.editor.vue
+++ b/packages/frontend/src/pages/admin/roles.editor.vue
@@ -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>
diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue
index 9753d9f6cb..6fb950494b 100644
--- a/packages/frontend/src/pages/admin/roles.vue
+++ b/packages/frontend/src/pages/admin/roles.vue
@@ -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>
@@ -243,7 +251,7 @@ 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 { instance } from '@/instance.js';
+import { instance, fetchInstance } from '@/instance.js';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
import { ROLE_POLICIES } from '@/const.js';
import { useRouter } from '@/router/supplier.js';
@@ -267,6 +275,7 @@ async function updateBaseRole() {
await os.apiWithDialog('admin/roles/update-default-policies', {
policies,
});
+ fetchInstance(true);
}
function create() {
diff --git a/packages/frontend/src/pages/admin/system-webhook.item.vue b/packages/frontend/src/pages/admin/system-webhook.item.vue
new file mode 100644
index 0000000000..0c07122af3
--- /dev/null
+++ b/packages/frontend/src/pages/admin/system-webhook.item.vue
@@ -0,0 +1,117 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="$style.main">
+ <span :class="$style.icon">
+ <i v-if="!entity.isActive" class="ti ti-player-pause"/>
+ <i v-else-if="entity.latestStatus === null" class="ti ti-circle"/>
+ <i
+ v-else-if="[200, 201, 204].includes(entity.latestStatus)"
+ class="ti ti-check"
+ :style="{ color: 'var(--success)' }"
+ />
+ <i v-else class="ti ti-alert-triangle" :style="{ color: 'var(--error)' }"/>
+ </span>
+ <span :class="$style.text">{{ entity.name || entity.url }}</span>
+ <span :class="$style.suffix">
+ <MkTime v-if="entity.latestSentAt" :time="entity.latestSentAt" style="margin-right: 8px"/>
+ <button :class="$style.suffixButton" @click="onEditClick">
+ <i class="ti ti-settings"></i>
+ </button>
+ <button :class="$style.suffixButton" @click="onDeleteClick">
+ <i class="ti ti-trash"></i>
+ </button>
+ </span>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { entities } from 'misskey-js';
+import { toRefs } from 'vue';
+
+const emit = defineEmits<{
+ (ev: 'edit', value: entities.SystemWebhook): void;
+ (ev: 'delete', value: entities.SystemWebhook): void;
+}>();
+
+const props = defineProps<{
+ entity: entities.SystemWebhook;
+}>();
+
+const { entity } = toRefs(props);
+
+function onEditClick() {
+ emit('edit', entity.value);
+}
+
+function onDeleteClick() {
+ emit('delete', entity.value);
+}
+
+</script>
+
+<style module lang="scss">
+.main {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ box-sizing: border-box;
+ padding: 10px 14px;
+ background: var(--buttonBg);
+ border: none;
+ border-radius: 6px;
+ font-size: 0.9em;
+
+ &:hover {
+ text-decoration: none;
+ background: var(--buttonHoverBg);
+ }
+
+ &.active {
+ color: var(--accent);
+ background: var(--buttonHoverBg);
+ }
+}
+
+.icon {
+ margin-right: 0.75em;
+ flex-shrink: 0;
+ text-align: center;
+ color: var(--fgTransparentWeak);
+}
+
+.text {
+ flex-shrink: 1;
+ white-space: normal;
+ padding-right: 12px;
+ text-align: center;
+}
+
+.suffix {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+ gaps: 4px;
+ margin-left: auto;
+ margin-right: -8px;
+ opacity: 0.7;
+ white-space: nowrap;
+}
+
+.suffixButton {
+ background: transparent;
+ border: none;
+ border-radius: 9999px;
+ margin-top: -8px;
+ margin-bottom: -8px;
+ padding: 8px;
+
+ &:hover {
+ background: var(--buttonBg);
+ }
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/system-webhook.vue b/packages/frontend/src/pages/admin/system-webhook.vue
new file mode 100644
index 0000000000..7a40eec944
--- /dev/null
+++ b/packages/frontend/src/pages/admin/system-webhook.vue
@@ -0,0 +1,96 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkStickyContainer>
+ <template #header>
+ <XHeader :actions="headerActions" :tabs="headerTabs"/>
+ </template>
+
+ <MkSpacer :contentMax="900">
+ <div class="_gaps_m">
+ <MkButton :class="$style.linkButton" full @click="onCreateWebhookClicked">
+ {{ i18n.ts._webhookSettings.createWebhook }}
+ </MkButton>
+
+ <FormSection>
+ <div class="_gaps">
+ <XItem v-for="item in webhooks" :key="item.id" :entity="item" @edit="onEditButtonClicked" @delete="onDeleteButtonClicked"/>
+ </div>
+ </FormSection>
+ </div>
+ </MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, ref } from 'vue';
+import { entities } from 'misskey-js';
+import XItem from './system-webhook.item.vue';
+import FormSection from '@/components/form/section.vue';
+import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { i18n } from '@/i18n.js';
+import XHeader from '@/pages/admin/_header_.vue';
+import MkButton from '@/components/MkButton.vue';
+import { misskeyApi } from '@/scripts/misskey-api.js';
+import { showSystemWebhookEditorDialog } from '@/components/MkSystemWebhookEditor.impl.js';
+import * as os from '@/os.js';
+
+const webhooks = ref<entities.SystemWebhook[]>([]);
+
+const headerActions = computed(() => []);
+const headerTabs = computed(() => []);
+
+async function onCreateWebhookClicked() {
+ await showSystemWebhookEditorDialog({
+ mode: 'create',
+ });
+
+ await fetchWebhooks();
+}
+
+async function onEditButtonClicked(webhook: entities.SystemWebhook) {
+ await showSystemWebhookEditorDialog({
+ mode: 'edit',
+ id: webhook.id,
+ });
+
+ await fetchWebhooks();
+}
+
+async function onDeleteButtonClicked(webhook: entities.SystemWebhook) {
+ const result = await os.confirm({
+ type: 'warning',
+ title: i18n.ts._webhookSettings.deleteConfirm,
+ });
+ if (!result.canceled) {
+ await misskeyApi('admin/system-webhook/delete', {
+ id: webhook.id,
+ });
+ await fetchWebhooks();
+ }
+}
+
+async function fetchWebhooks() {
+ const result = await misskeyApi('admin/system-webhook/list', {});
+ webhooks.value = result.sort((a, b) => a.id.localeCompare(b.id));
+}
+
+onMounted(async () => {
+ await fetchWebhooks();
+});
+
+definePageMetadata(() => ({
+ title: 'SystemWebhook',
+ icon: 'ti ti-webhook',
+}));
+</script>
+
+<style module lang="scss">
+.linkButton {
+ text-align: left;
+ padding: 10px 18px;
+}
+</style>
diff --git a/packages/frontend/src/pages/announcement.vue b/packages/frontend/src/pages/announcement.vue
index 85ae9062d4..802a6bf399 100644
--- a/packages/frontend/src/pages/announcement.vue
+++ b/packages/frontend/src/pages/announcement.vue
@@ -109,6 +109,15 @@ definePageMetadata(() => ({
</script>
<style lang="scss" module>
+.fadeEnterActive,
+.fadeLeaveActive {
+ transition: opacity 0.125s ease;
+}
+.fadeEnterFrom,
+.fadeLeaveTo {
+ opacity: 0;
+}
+
.announcement {
padding: 16px;
}
diff --git a/packages/frontend/src/pages/antenna-timeline.vue b/packages/frontend/src/pages/antenna-timeline.vue
index 273250d1d0..ea64e457e3 100644
--- a/packages/frontend/src/pages/antenna-timeline.vue
+++ b/packages/frontend/src/pages/antenna-timeline.vue
@@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="800">
- <div ref="rootEl" v-hotkey.global="keymap">
+ <div ref="rootEl">
<div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>
<div :class="$style.tl">
<MkTimeline
@@ -44,9 +44,6 @@ const antenna = ref<Misskey.entities.Antenna | null>(null);
const queue = ref(0);
const rootEl = shallowRef<HTMLElement>();
const tlEl = shallowRef<InstanceType<typeof MkTimeline>>();
-const keymap = computed(() => ({
- 't': focus,
-}));
function queueUpdated(q) {
queue.value = q;
diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue
index a895df76e8..3c3ff08aee 100644
--- a/packages/frontend/src/pages/channel.vue
+++ b/packages/frontend/src/pages/channel.vue
@@ -93,7 +93,7 @@ import MkFoldableSection from '@/components/MkFoldableSection.vue';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
import { PageHeaderItem } from '@/types/page-header.js';
import { isSupportShare } from '@/scripts/navigator.js';
-import copyToClipboard from '@/scripts/copy-to-clipboard.js';
+import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { miLocalStorage } from '@/local-storage.js';
import { useRouter } from '@/router/supplier.js';
diff --git a/packages/frontend/src/pages/clip.vue b/packages/frontend/src/pages/clip.vue
index fd64a55c65..fb984de368 100644
--- a/packages/frontend/src/pages/clip.vue
+++ b/packages/frontend/src/pages/clip.vue
@@ -43,7 +43,7 @@ import { url } from '@/config.js';
import MkButton from '@/components/MkButton.vue';
import { clipsCache } from '@/cache.js';
import { isSupportShare } from '@/scripts/navigator.js';
-import copyToClipboard from '@/scripts/copy-to-clipboard.js';
+import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
const props = defineProps<{
clipId: string,
diff --git a/packages/frontend/src/pages/contact.vue b/packages/frontend/src/pages/contact.vue
index bcdcf43275..1f2bee5a77 100644
--- a/packages/frontend/src/pages/contact.vue
+++ b/packages/frontend/src/pages/contact.vue
@@ -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>
+ <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>
- <div>{{ instance.maintainerEmail }}</div>
+ <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';
diff --git a/packages/frontend/src/pages/custom-emojis-manager.vue b/packages/frontend/src/pages/custom-emojis-manager.vue
index 3e2332e408..eea3f68130 100644
--- a/packages/frontend/src/pages/custom-emojis-manager.vue
+++ b/packages/frontend/src/pages/custom-emojis-manager.vue
@@ -129,18 +129,19 @@ const toggleSelect = (emoji) => {
};
const add = async (ev: MouseEvent) => {
- os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.vue')), {
+ const { dispose } = os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.vue')), {
}, {
done: result => {
if (result.created) {
emojisPaginationComponent.value.prepend(result.created);
}
},
- }, 'closed');
+ closed: () => dispose(),
+ });
};
const edit = (emoji) => {
- os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.vue')), {
+ const { dispose } = os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.vue')), {
emoji: emoji,
}, {
done: result => {
@@ -153,7 +154,8 @@ const edit = (emoji) => {
emojisPaginationComponent.value.removeItem(emoji.id);
}
},
- }, 'closed');
+ closed: () => dispose(),
+ });
};
const importEmoji = (emoji) => {
diff --git a/packages/frontend/src/pages/drive.file.info.vue b/packages/frontend/src/pages/drive.file.info.vue
index 8077edff5f..3026d00a2c 100644
--- a/packages/frontend/src/pages/drive.file.info.vue
+++ b/packages/frontend/src/pages/drive.file.info.vue
@@ -37,11 +37,17 @@ SPDX-License-Identifier: AGPL-3.0-only
</button>
</div>
</div>
- <div>
- <button class="_button" :class="$style.fileAltEditBtn" @click="describe()">
+ <div class="_gaps_s">
+ <button class="_button" :class="$style.kvEditBtn" @click="move()">
+ <MkKeyValue>
+ <template #key>{{ i18n.ts.folder }}</template>
+ <template #value>{{ folderHierarchy.join(' > ') }}<i class="ti ti-pencil" :class="$style.kvEditIcon"></i></template>
+ </MkKeyValue>
+ </button>
+ <button class="_button" :class="$style.kvEditBtn" @click="describe()">
<MkKeyValue>
<template #key>{{ i18n.ts.description }}</template>
- <template #value>{{ file.comment ? file.comment : `(${i18n.ts.none})` }}<i class="ti ti-pencil" :class="$style.fileAltEditIcon"></i></template>
+ <template #value>{{ file.comment ? file.comment : `(${i18n.ts.none})` }}<i class="ti ti-pencil" :class="$style.kvEditIcon"></i></template>
</MkKeyValue>
</button>
<MkKeyValue :class="$style.fileMetaDataChildren">
@@ -90,6 +96,18 @@ const props = defineProps<{
const fetching = ref(true);
const file = ref<Misskey.entities.DriveFile>();
+const folderHierarchy = computed(() => {
+ if (!file.value) return [i18n.ts.drive];
+ const folderNames = [i18n.ts.drive];
+
+ function get(folder: Misskey.entities.DriveFolder) {
+ if (folder.parent) get(folder.parent);
+ folderNames.push(folder.name);
+ }
+
+ if (file.value.folder) get(file.value.folder);
+ return folderNames;
+});
const isImage = computed(() => file.value?.type.startsWith('image/'));
async function fetch() {
@@ -122,6 +140,19 @@ function crop() {
});
}
+function move() {
+ if (!file.value) return;
+
+ os.selectDriveFolder(false).then(folder => {
+ misskeyApi('drive/files/update', {
+ fileId: file.value.id,
+ folderId: folder[0] ? folder[0].id : null,
+ }).then(async () => {
+ await fetch();
+ });
+ });
+}
+
function toggleSensitive() {
if (!file.value) return;
@@ -160,7 +191,7 @@ function rename() {
function describe() {
if (!file.value) return;
- os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), {
+ const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), {
default: file.value.comment ?? '',
file: file.value,
}, {
@@ -172,7 +203,8 @@ function describe() {
await fetch();
});
},
- }, 'closed');
+ closed: () => dispose(),
+ });
}
async function deleteFile() {
@@ -233,6 +265,7 @@ onMounted(async () => {
background-color: var(--accentedBg);
color: var(--accent);
text-decoration: none;
+ outline: none;
}
&.danger {
@@ -280,14 +313,14 @@ onMounted(async () => {
padding: .5rem 1rem;
}
-.fileAltEditBtn {
+.kvEditBtn {
text-align: start;
display: block;
width: 100%;
padding: .5rem 1rem;
border-radius: var(--radius);
- .fileAltEditIcon {
+ .kvEditIcon {
display: inline-block;
color: transparent;
visibility: hidden;
@@ -298,7 +331,7 @@ onMounted(async () => {
color: var(--accent);
background-color: var(--accentedBg);
- .fileAltEditIcon {
+ .kvEditIcon {
color: var(--accent);
visibility: visible;
}
diff --git a/packages/frontend/src/pages/drop-and-fusion.game.vue b/packages/frontend/src/pages/drop-and-fusion.game.vue
index eba5b92154..0f0b7e1ea8 100644
--- a/packages/frontend/src/pages/drop-and-fusion.game.vue
+++ b/packages/frontend/src/pages/drop-and-fusion.game.vue
@@ -210,7 +210,7 @@ import { apiUrl } from '@/config.js';
import { $i } from '@/account.js';
import * as sound from '@/scripts/sound.js';
import MkRange from '@/components/MkRange.vue';
-import copyToClipboard from '@/scripts/copy-to-clipboard.js';
+import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
type FrontendMonoDefinition = {
id: string;
@@ -1008,8 +1008,18 @@ function attachGameEvents() {
const domX = rect.left + (x * viewScale);
const domY = rect.top + (y * viewScale);
const scoreUnit = getScoreUnit(props.gameMode);
- os.popup(MkRippleEffect, { x: domX, y: domY }, {}, 'end');
- os.popup(MkPlusOneEffect, { x: domX, y: domY, value: scoreDelta + (scoreUnit === 'pt' ? '' : scoreUnit) }, {}, 'end');
+
+ {
+ const { dispose } = os.popup(MkRippleEffect, { x: domX, y: domY }, {
+ end: () => dispose(),
+ });
+ }
+
+ {
+ const { dispose } = os.popup(MkPlusOneEffect, { x: domX, y: domY, value: scoreDelta + (scoreUnit === 'pt' ? '' : scoreUnit) }, {
+ end: () => dispose(),
+ });
+ }
if (nextMono) {
const def = monoDefinitions.value.find(x => x.id === nextMono.id)!;
diff --git a/packages/frontend/src/pages/emoji-edit-dialog.vue b/packages/frontend/src/pages/emoji-edit-dialog.vue
index 16769ef360..853c1d6b0b 100644
--- a/packages/frontend/src/pages/emoji-edit-dialog.vue
+++ b/packages/frontend/src/pages/emoji-edit-dialog.vue
@@ -15,8 +15,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<template v-if="emoji" #header>:{{ emoji.name }}:</template>
<template v-else #header>New emoji</template>
- <div>
- <MkSpacer :marginMin="20" :marginMax="28">
+ <div style="display: flex; flex-direction: column; min-height: 100%;">
+ <MkSpacer :marginMin="20" :marginMax="28" style="flex-grow: 1;">
<div class="_gaps_m">
<div v-if="imgUrl != null" :class="$style.imgs">
<div style="background: #000;" :class="$style.imgContainer">
@@ -239,10 +239,12 @@ async function del() {
.footer {
position: sticky;
+ z-index: 10000;
bottom: 0;
left: 0;
padding: 12px;
border-top: solid 0.5px var(--divider);
+ background: var(--acrylicBg);
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));
}
diff --git a/packages/frontend/src/pages/emojis.emoji.vue b/packages/frontend/src/pages/emojis.emoji.vue
index 5301a08521..97429c29a4 100644
--- a/packages/frontend/src/pages/emojis.emoji.vue
+++ b/packages/frontend/src/pages/emojis.emoji.vue
@@ -14,10 +14,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
-import * as os from '@/os.js';
import * as Misskey from 'misskey-js';
+import * as os from '@/os.js';
import { misskeyApiGet } from '@/scripts/misskey-api.js';
-import copyToClipboard from '@/scripts/copy-to-clipboard.js';
+import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { i18n } from '@/i18n.js';
import MkCustomEmojiDetailedDialog from '@/components/MkCustomEmojiDetailedDialog.vue';
@@ -40,12 +40,12 @@ function menu(ev) {
text: i18n.ts.info,
icon: 'ti ti-info-circle',
action: async () => {
- os.popup(MkCustomEmojiDetailedDialog, {
+ const { dispose } = os.popup(MkCustomEmojiDetailedDialog, {
emoji: await misskeyApiGet('emoji', {
name: props.emoji.name,
- })
+ }),
}, {
- anchor: ev.target,
+ closed: () => dispose(),
});
},
}], ev.currentTarget ?? ev.target);
diff --git a/packages/frontend/src/pages/flash/flash-edit.vue b/packages/frontend/src/pages/flash/flash-edit.vue
index 3445da26a2..0b9f4dfe58 100644
--- a/packages/frontend/src/pages/flash/flash-edit.vue
+++ b/packages/frontend/src/pages/flash/flash-edit.vue
@@ -37,6 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, ref } from 'vue';
import * as Misskey from 'misskey-js';
+import { AISCRIPT_VERSION } from '@syuilo/aiscript';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
@@ -48,7 +49,7 @@ import MkInput from '@/components/MkInput.vue';
import MkSelect from '@/components/MkSelect.vue';
import { useRouter } from '@/router/supplier.js';
-const PRESET_DEFAULT = `/// @ 0.18.0
+const PRESET_DEFAULT = `/// @ ${AISCRIPT_VERSION}
var name = ""
@@ -66,7 +67,7 @@ Ui:render([
])
`;
-const PRESET_OMIKUJI = `/// @ 0.18.0
+const PRESET_OMIKUJI = `/// @ ${AISCRIPT_VERSION}
// ユーザーごとに日替わりのおみくじのプリセット
// 選択肢
@@ -109,7 +110,7 @@ Ui:render([
])
`;
-const PRESET_SHUFFLE = `/// @ 0.18.0
+const PRESET_SHUFFLE = `/// @ ${AISCRIPT_VERSION}
// 巻き戻し可能な文字シャッフルのプリセット
let string = "ペペロンチーノ"
@@ -188,7 +189,7 @@ var cursor = 0
do()
`;
-const PRESET_QUIZ = `/// @ 0.18.0
+const PRESET_QUIZ = `/// @ ${AISCRIPT_VERSION}
let title = '地理クイズ'
let qas = [{
@@ -301,7 +302,7 @@ qaEls.push(Ui:C:container({
Ui:render(qaEls)
`;
-const PRESET_TIMELINE = `/// @ 0.18.0
+const PRESET_TIMELINE = `/// @ ${AISCRIPT_VERSION}
// APIリクエストを行いローカルタイムラインを表示するプリセット
@fetch() {
diff --git a/packages/frontend/src/pages/flash/flash.vue b/packages/frontend/src/pages/flash/flash.vue
index 40499fde0e..020463a133 100644
--- a/packages/frontend/src/pages/flash/flash.vue
+++ b/packages/frontend/src/pages/flash/flash.vue
@@ -78,7 +78,8 @@ import MkCode from '@/components/MkCode.vue';
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 { 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',
diff --git a/packages/frontend/src/pages/follow.vue b/packages/frontend/src/pages/follow.vue
deleted file mode 100644
index 247b0ac639..0000000000
--- a/packages/frontend/src/pages/follow.vue
+++ /dev/null
@@ -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>
diff --git a/packages/frontend/src/pages/gallery/post.vue b/packages/frontend/src/pages/gallery/post.vue
index 615675225d..fc2b5f810c 100644
--- a/packages/frontend/src/pages/gallery/post.vue
+++ b/packages/frontend/src/pages/gallery/post.vue
@@ -77,7 +77,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
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 { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { useRouter } from '@/router/supplier.js';
const router = useRouter();
diff --git a/packages/frontend/src/pages/games.vue b/packages/frontend/src/pages/games.vue
index afd6df1ad9..b52f4decaa 100644
--- a/packages/frontend/src/pages/games.vue
+++ b/packages/frontend/src/pages/games.vue
@@ -8,12 +8,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header><MkPageHeader/></template>
<MkSpacer :contentMax="800">
<div class="_gaps">
- <div class="_panel">
+ <div class="_panel" :class="$style.link">
<MkA to="/bubble-game">
<img src="/client-assets/drop-and-fusion/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/>
</MkA>
</div>
- <div class="_panel">
+ <div class="_panel" :class="$style.link">
<MkA to="/reversi">
<img src="/client-assets/reversi/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/>
</MkA>
@@ -32,3 +32,10 @@ definePageMetadata(() => ({
icon: 'ti ti-device-gamepad',
}));
</script>
+
+<style module>
+.link:focus-within {
+ outline: 2px solid var(--focus);
+ outline-offset: -2px;
+}
+</style>
diff --git a/packages/frontend/src/pages/instance-info.vue b/packages/frontend/src/pages/instance-info.vue
index 26797ba85e..4ba428d536 100644
--- a/packages/frontend/src/pages/instance-info.vue
+++ b/packages/frontend/src/pages/instance-info.vue
@@ -47,6 +47,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton v-if="suspensionState !== 'none'" :disabled="!instance" @click="resumeDelivery">{{ i18n.ts._delivery.resume }}</MkButton>
<MkSwitch v-model="isBlocked" :disabled="!meta || !instance" @update:modelValue="toggleBlock">{{ i18n.ts.blockThisInstance }}</MkSwitch>
<MkSwitch v-model="isSilenced" :disabled="!meta || !instance" @update:modelValue="toggleSilenced">{{ i18n.ts.silenceThisInstance }}</MkSwitch>
+ <MkSwitch v-model="isMediaSilenced" :disabled="!meta || !instance" @update:modelValue="toggleMediaSilenced">{{ i18n.ts.mediaSilenceThisInstance }}</MkSwitch>
<MkButton @click="refreshMetadata"><i class="ti ti-refresh"></i> Refresh metadata</MkButton>
<MkTextarea v-model="moderationNote" manualSave>
<template #label>{{ i18n.ts.moderationNote }}</template>
@@ -167,6 +168,7 @@ const instance = ref<Misskey.entities.FederationInstance | null>(null);
const suspensionState = ref<'none' | 'manuallySuspended' | 'goneSuspended' | 'autoSuspendedForNotResponding'>('none');
const isBlocked = ref(false);
const isSilenced = ref(false);
+const isMediaSilenced = ref(false);
const faviconUrl = ref<string | null>(null);
const moderationNote = ref('');
@@ -195,8 +197,9 @@ async function fetch(): Promise<void> {
suspensionState.value = instance.value?.suspensionState ?? 'none';
isBlocked.value = instance.value?.isBlocked ?? false;
isSilenced.value = instance.value?.isSilenced ?? false;
+ isMediaSilenced.value = instance.value?.isMediaSilenced ?? false;
faviconUrl.value = getProxiedImageUrlNullable(instance.value?.faviconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.value?.iconUrl, 'preview');
- moderationNote.value = instance.value?.moderationNote;
+ moderationNote.value = instance.value?.moderationNote ?? '';
}
async function toggleBlock(): Promise<void> {
@@ -218,6 +221,16 @@ async function toggleSilenced(): Promise<void> {
});
}
+async function toggleMediaSilenced(): Promise<void> {
+ if (!meta.value) throw new Error('No meta?');
+ if (!instance.value) throw new Error('No instance?');
+ const { host } = instance.value;
+ const mediaSilencedHosts = meta.value.mediaSilencedHosts ?? [];
+ await misskeyApi('admin/update-meta', {
+ mediaSilencedHosts: isMediaSilenced.value ? mediaSilencedHosts.concat([host]) : mediaSilencedHosts.filter(x => x !== host),
+ });
+}
+
async function stopDelivery(): Promise<void> {
if (!instance.value) throw new Error('No instance?');
suspensionState.value = 'manuallySuspended';
diff --git a/packages/frontend/src/pages/lookup.vue b/packages/frontend/src/pages/lookup.vue
new file mode 100644
index 0000000000..3233953942
--- /dev/null
+++ b/packages/frontend/src/pages/lookup.vue
@@ -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>
diff --git a/packages/frontend/src/pages/my-antennas/create.vue b/packages/frontend/src/pages/my-antennas/create.vue
index 2d026d2fa9..2b8518747f 100644
--- a/packages/frontend/src/pages/my-antennas/create.vue
+++ b/packages/frontend/src/pages/my-antennas/create.vue
@@ -4,43 +4,33 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
-<div>
- <XAntenna :antenna="draft" @created="onAntennaCreated"/>
-</div>
+<MkStickyContainer>
+ <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+
+ <MkAntennaEditor @created="onAntennaCreated"/>
+</MkStickyContainer>
</template>
<script lang="ts" setup>
-import { ref } from 'vue';
-import XAntenna from './editor.vue';
+import { computed } from 'vue';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { antennasCache } from '@/cache.js';
import { useRouter } from '@/router/supplier.js';
+import MkAntennaEditor from '@/components/MkAntennaEditor.vue';
const router = useRouter();
-const draft = ref({
- name: '',
- src: 'all',
- userListId: null,
- users: [],
- keywords: [],
- excludeKeywords: [],
- excludeBots: false,
- withReplies: false,
- caseSensitive: false,
- localOnly: false,
- withFile: false,
- notify: false,
-});
-
function onAntennaCreated() {
antennasCache.delete();
router.push('/my/antennas');
}
+const headerActions = computed(() => []);
+const headerTabs = computed(() => []);
+
definePageMetadata(() => ({
- title: i18n.ts.manageAntennas,
+ title: i18n.ts.createAntenna,
icon: 'ti ti-antenna',
}));
</script>
diff --git a/packages/frontend/src/pages/my-antennas/edit.vue b/packages/frontend/src/pages/my-antennas/edit.vue
index 9471be8575..9f927cd1a0 100644
--- a/packages/frontend/src/pages/my-antennas/edit.vue
+++ b/packages/frontend/src/pages/my-antennas/edit.vue
@@ -4,15 +4,17 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
-<div class="">
- <XAntenna v-if="antenna" :antenna="antenna" @updated="onAntennaUpdated"/>
-</div>
+<MkStickyContainer>
+ <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+
+ <MkAntennaEditor v-if="antenna" :antenna="antenna" @updated="onAntennaUpdated"/>
+</MkStickyContainer>
</template>
<script lang="ts" setup>
-import { ref } from 'vue';
+import { ref, computed } from 'vue';
import * as Misskey from 'misskey-js';
-import XAntenna from './editor.vue';
+import MkAntennaEditor from '@/components/MkAntennaEditor.vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
@@ -36,8 +38,11 @@ misskeyApi('antennas/show', { antennaId: props.antennaId }).then((antennaRespons
antenna.value = antennaResponse;
});
+const headerActions = computed(() => []);
+const headerTabs = computed(() => []);
+
definePageMetadata(() => ({
- title: i18n.ts.manageAntennas,
+ title: i18n.ts.editAntenna,
icon: 'ti ti-antenna',
}));
</script>
diff --git a/packages/frontend/src/pages/my-antennas/editor.vue b/packages/frontend/src/pages/my-antennas/editor.vue
deleted file mode 100644
index 2949bfc02c..0000000000
--- a/packages/frontend/src/pages/my-antennas/editor.vue
+++ /dev/null
@@ -1,146 +0,0 @@
-<!--
-SPDX-FileCopyrightText: syuilo and misskey-project
-SPDX-License-Identifier: AGPL-3.0-only
--->
-
-<template>
-<MkSpacer :contentMax="700">
- <div>
- <div class="_gaps_m">
- <MkInput v-model="name">
- <template #label>{{ i18n.ts.name }}</template>
- </MkInput>
- <MkSelect v-model="src">
- <template #label>{{ i18n.ts.antennaSource }}</template>
- <option value="all">{{ i18n.ts._antennaSources.all }}</option>
- <!--<option value="home">{{ i18n.ts._antennaSources.homeTimeline }}</option>-->
- <option value="users">{{ i18n.ts._antennaSources.users }}</option>
- <!--<option value="list">{{ i18n.ts._antennaSources.userList }}</option>-->
- <option value="users_blacklist">{{ i18n.ts._antennaSources.userBlacklist }}</option>
- </MkSelect>
- <MkSelect v-if="src === 'list'" v-model="userListId">
- <template #label>{{ i18n.ts.userList }}</template>
- <option v-for="list in userLists" :key="list.id" :value="list.id">{{ list.name }}</option>
- </MkSelect>
- <MkTextarea v-else-if="src === 'users' || src === 'users_blacklist'" v-model="users">
- <template #label>{{ i18n.ts.users }}</template>
- <template #caption>{{ i18n.ts.antennaUsersDescription }} <button class="_textButton" @click="addUser">{{ i18n.ts.addUser }}</button></template>
- </MkTextarea>
- <MkSwitch v-model="excludeBots">{{ i18n.ts.antennaExcludeBots }}</MkSwitch>
- <MkSwitch v-model="withReplies">{{ i18n.ts.withReplies }}</MkSwitch>
- <MkTextarea v-model="keywords">
- <template #label>{{ i18n.ts.antennaKeywords }}</template>
- <template #caption>{{ i18n.ts.antennaKeywordsDescription }}</template>
- </MkTextarea>
- <MkTextarea v-model="excludeKeywords">
- <template #label>{{ i18n.ts.antennaExcludeKeywords }}</template>
- <template #caption>{{ i18n.ts.antennaKeywordsDescription }}</template>
- </MkTextarea>
- <MkSwitch v-model="localOnly">{{ i18n.ts.localOnly }}</MkSwitch>
- <MkSwitch v-model="caseSensitive">{{ i18n.ts.caseSensitive }}</MkSwitch>
- <MkSwitch v-model="withFile">{{ i18n.ts.withFileAntenna }}</MkSwitch>
- </div>
- <div :class="$style.actions">
- <MkButton inline primary @click="saveAntenna()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
- <MkButton v-if="antenna.id != null" inline danger @click="deleteAntenna()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
- </div>
- </div>
-</MkSpacer>
-</template>
-
-<script lang="ts" setup>
-import { watch, ref } from 'vue';
-import * as Misskey from 'misskey-js';
-import MkButton from '@/components/MkButton.vue';
-import MkInput from '@/components/MkInput.vue';
-import MkTextarea from '@/components/MkTextarea.vue';
-import MkSelect from '@/components/MkSelect.vue';
-import MkSwitch from '@/components/MkSwitch.vue';
-import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { i18n } from '@/i18n.js';
-
-const props = defineProps<{
- antenna: Misskey.entities.Antenna
-}>();
-
-const emit = defineEmits<{
- (ev: 'created'): void,
- (ev: 'updated'): void,
- (ev: 'deleted'): void,
-}>();
-
-const name = ref<string>(props.antenna.name);
-const src = ref<Misskey.entities.AntennasCreateRequest['src']>(props.antenna.src);
-const userListId = ref<string | null>(props.antenna.userListId);
-const users = ref<string>(props.antenna.users.join('\n'));
-const keywords = ref<string>(props.antenna.keywords.map(x => x.join(' ')).join('\n'));
-const excludeKeywords = ref<string>(props.antenna.excludeKeywords.map(x => x.join(' ')).join('\n'));
-const caseSensitive = ref<boolean>(props.antenna.caseSensitive);
-const localOnly = ref<boolean>(props.antenna.localOnly);
-const excludeBots = ref<boolean>(props.antenna.excludeBots);
-const withReplies = ref<boolean>(props.antenna.withReplies);
-const withFile = ref<boolean>(props.antenna.withFile);
-const userLists = ref<Misskey.entities.UserList[] | null>(null);
-
-watch(() => src.value, async () => {
- if (src.value === 'list' && userLists.value === null) {
- userLists.value = await misskeyApi('users/lists/list');
- }
-});
-
-async function saveAntenna() {
- const antennaData = {
- name: name.value,
- src: src.value,
- userListId: userListId.value,
- excludeBots: excludeBots.value,
- withReplies: withReplies.value,
- withFile: withFile.value,
- caseSensitive: caseSensitive.value,
- localOnly: localOnly.value,
- users: users.value.trim().split('\n').map(x => x.trim()),
- keywords: keywords.value.trim().split('\n').map(x => x.trim().split(' ')),
- excludeKeywords: excludeKeywords.value.trim().split('\n').map(x => x.trim().split(' ')),
- };
-
- if (props.antenna.id == null) {
- await os.apiWithDialog('antennas/create', antennaData);
- emit('created');
- } else {
- await os.apiWithDialog('antennas/update', { ...antennaData, antennaId: props.antenna.id });
- emit('updated');
- }
-}
-
-async function deleteAntenna() {
- const { canceled } = await os.confirm({
- type: 'warning',
- text: i18n.tsx.removeAreYouSure({ x: props.antenna.name }),
- });
- if (canceled) return;
-
- await misskeyApi('antennas/delete', {
- antennaId: props.antenna.id,
- });
-
- os.success();
- emit('deleted');
-}
-
-function addUser() {
- os.selectUser({ includeSelf: true }).then(user => {
- users.value = users.value.trim();
- users.value += '\n@' + Misskey.acct.toString(user as any);
- users.value = users.value.trim();
- });
-}
-</script>
-
-<style lang="scss" module>
-.actions {
- margin-top: 16px;
- padding: 24px 0;
- border-top: solid 0.5px var(--divider);
-}
-</style>
diff --git a/packages/frontend/src/pages/my-clips/index.vue b/packages/frontend/src/pages/my-clips/index.vue
index 1a0d7177fc..ece998a7a5 100644
--- a/packages/frontend/src/pages/my-clips/index.vue
+++ b/packages/frontend/src/pages/my-clips/index.vue
@@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton primary rounded class="add" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
<MkPagination v-slot="{ items }" ref="pagingComponent" :pagination="pagination" class="_gaps">
- <MkClipPreview v-for="item in items" :key="item.id" :clip="item"/>
+ <MkClipPreview v-for="item in items" :key="item.id" :clip="item" :noUserInfo="true"/>
</MkPagination>
</div>
<div v-else-if="tab === 'favorites'" key="favorites" class="_gaps">
diff --git a/packages/frontend/src/pages/my-lists/list.vue b/packages/frontend/src/pages/my-lists/list.vue
index 7492b099ea..a2ceb222fe 100644
--- a/packages/frontend/src/pages/my-lists/list.vue
+++ b/packages/frontend/src/pages/my-lists/list.vue
@@ -133,22 +133,25 @@ async function removeUser(item, ev) {
}
async function showMembershipMenu(item, ev) {
+ const withRepliesRef = ref(item.withReplies);
os.popupMenu([{
- text: item.withReplies ? i18n.ts.hideRepliesToOthersInTimeline : i18n.ts.showRepliesToOthersInTimeline,
- icon: item.withReplies ? 'ti ti-messages-off' : 'ti ti-messages',
- action: async () => {
- misskeyApi('users/lists/update-membership', {
- listId: list.value.id,
- userId: item.userId,
- withReplies: !item.withReplies,
- }).then(() => {
- paginationEl.value.updateItem(item.id, (old) => ({
- ...old,
- withReplies: !item.withReplies,
- }));
- });
- },
+ type: 'switch',
+ text: i18n.ts.showRepliesToOthersInTimeline,
+ icon: 'ti ti-messages',
+ ref: withRepliesRef,
}], ev.currentTarget ?? ev.target);
+ watch(withRepliesRef, withReplies => {
+ misskeyApi('users/lists/update-membership', {
+ listId: list.value!.id,
+ userId: item.userId,
+ withReplies,
+ }).then(() => {
+ paginationEl.value!.updateItem(item.id, (old) => ({
+ ...old,
+ withReplies,
+ }));
+ });
+ });
}
async function deleteList() {
diff --git a/packages/frontend/src/pages/page.vue b/packages/frontend/src/pages/page.vue
index e73d032000..20b776aaa2 100644
--- a/packages/frontend/src/pages/page.vue
+++ b/packages/frontend/src/pages/page.vue
@@ -125,7 +125,7 @@ import { $i } from '@/account.js';
import { isSupportShare } from '@/scripts/navigator.js';
import { instance } from '@/instance.js';
import { getStaticImageUrl } from '@/scripts/media-proxy.js';
-import copyToClipboard from '@/scripts/copy-to-clipboard.js';
+import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
const props = defineProps<{
pageName: string;
@@ -286,6 +286,7 @@ definePageMetadata(() => ({
background-color: var(--accentedBg);
color: var(--accent);
text-decoration: none;
+ outline: none;
}
}
diff --git a/packages/frontend/src/pages/preview.vue b/packages/frontend/src/pages/preview.vue
new file mode 100644
index 0000000000..8e07b190aa
--- /dev/null
+++ b/packages/frontend/src/pages/preview.vue
@@ -0,0 +1,26 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div>
+ <MkSample/>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import MkSample from '@/components/MkPreview.vue';
+import { i18n } from '@/i18n.js';
+import { definePageMetadata } from '@/scripts/page-metadata.js';
+
+const headerActions = computed(() => []);
+
+const headerTabs = computed(() => []);
+
+definePageMetadata(computed(() => ({
+ title: i18n.ts.preview,
+ icon: 'ti ti-eye',
+})));
+</script>
diff --git a/packages/frontend/src/pages/reset-password.vue b/packages/frontend/src/pages/reset-password.vue
index 6b67a9cc87..6d24029535 100644
--- a/packages/frontend/src/pages/reset-password.vue
+++ b/packages/frontend/src/pages/reset-password.vue
@@ -44,7 +44,9 @@ async function save() {
onMounted(() => {
if (props.token == null) {
- os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, {}, 'closed');
+ const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, {
+ closed: () => dispose(),
+ });
mainRouter.push('/');
}
});
diff --git a/packages/frontend/src/pages/reversi/game.board.vue b/packages/frontend/src/pages/reversi/game.board.vue
index 175ea62411..7d9cefa5c9 100644
--- a/packages/frontend/src/pages/reversi/game.board.vue
+++ b/packages/frontend/src/pages/reversi/game.board.vue
@@ -169,7 +169,7 @@ const props = defineProps<{
const showBoardLabels = ref<boolean>(false);
const useAvatarAsStone = ref<boolean>(true);
const autoplaying = ref<boolean>(false);
-// eslint-disable-next-line vue/no-setup-props-destructure
+// eslint-disable-next-line vue/no-setup-props-reactivity-loss
const game = ref<Misskey.entities.ReversiGameDetailed & { logs: Reversi.Serializer.SerializedLog[] }>(deepClone(props.game));
const logPos = ref<number>(game.value.logs.length);
const engine = shallowRef<Reversi.Game>(Reversi.Serializer.restoreGame({
diff --git a/packages/frontend/src/pages/reversi/game.vue b/packages/frontend/src/pages/reversi/game.vue
index eadc51881c..97a793753d 100644
--- a/packages/frontend/src/pages/reversi/game.vue
+++ b/packages/frontend/src/pages/reversi/game.vue
@@ -20,6 +20,7 @@ import { useStream } from '@/stream.js';
import { signinRequired } from '@/account.js';
import { useRouter } from '@/router/supplier.js';
import * as os from '@/os.js';
+import { url } from '@/config.js';
import { i18n } from '@/i18n.js';
import { useInterval } from '@/scripts/use-interval.js';
@@ -44,7 +45,7 @@ function start(_game: Misskey.entities.ReversiGameDetailed) {
if (shareWhenStart.value) {
misskeyApi('notes/create', {
- text: i18n.ts._reversi.iStartedAGame + '\n' + location.href,
+ text: `${i18n.ts._reversi.iStartedAGame}\n${url}/reversi/g/${props.gameId}`,
visibility: 'home',
});
}
diff --git a/packages/frontend/src/pages/search.note.vue b/packages/frontend/src/pages/search.note.vue
index d68bbaeeca..9cf7fbe8d8 100644
--- a/packages/frontend/src/pages/search.note.vue
+++ b/packages/frontend/src/pages/search.note.vue
@@ -6,29 +6,38 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div class="_gaps">
<div class="_gaps">
- <MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search" @enter="search">
+ <MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search" @enter.prevent="search">
<template #prefix><i class="ti ti-search"></i></template>
</MkInput>
- <MkFolder>
- <template #label>{{ i18n.ts.options }}</template>
+ <MkFoldableSection :expanded="true">
+ <template #header>{{ i18n.ts.options }}</template>
<div class="_gaps_m">
- <MkSwitch v-model="isLocalOnly">{{ i18n.ts.localOnly }}</MkSwitch>
+ <MkRadios v-model="hostSelect">
+ <template #label>{{ i18n.ts.host }}</template>
+ <option value="all" default>{{ i18n.ts.all }}</option>
+ <option value="local">{{ i18n.ts.local }}</option>
+ <option v-if="noteSearchableScope === 'global'" value="specified">{{ i18n.ts.specifyHost }}</option>
+ </MkRadios>
+ <MkInput v-if="noteSearchableScope === 'global'" v-model="hostInput" :disabled="hostSelect !== 'specified'" :large="true" type="search">
+ <template #prefix><i class="ti ti-server"></i></template>
+ </MkInput>
<MkFolder :defaultOpen="true">
<template #label>{{ i18n.ts.specifyUser }}</template>
- <template v-if="user" #suffix>@{{ user.username }}</template>
+ <template v-if="user" #suffix>@{{ user.username }}{{ user.host ? `@${user.host}` : "" }}</template>
- <div style="text-align: center;" class="_gaps">
- <div v-if="user">@{{ user.username }}</div>
- <div>
- <MkButton v-if="user == null" primary rounded inline @click="selectUser">{{ i18n.ts.selectUser }}</MkButton>
- <MkButton v-else danger rounded inline @click="user = null">{{ i18n.ts.remove }}</MkButton>
+ <div class="_gaps">
+ <div :class="$style.userItem">
+ <MkUserCardMini v-if="user" :class="$style.userCard" :user="user" :withChart="false"/>
+ <MkButton v-if="user == null && $i != null" transparent :class="$style.addMeButton" @click="selectSelf"><div :class="$style.addUserButtonInner"><span><i class="ti ti-plus"></i><i class="ti ti-user"></i></span><span>{{ i18n.ts.selectSelf }}</span></div></MkButton>
+ <MkButton v-if="user == null" transparent :class="$style.addUserButton" @click="selectUser"><div :class="$style.addUserButtonInner"><i class="ti ti-plus"></i><span>{{ i18n.ts.selectUser }}</span></div></MkButton>
+ <button class="_button" :class="$style.remove" :disabled="user == null" @click="removeUser"><i class="ti ti-x"></i></button>
</div>
</div>
</MkFolder>
</div>
- </MkFolder>
+ </MkFoldableSection>
<div>
<MkButton large primary gradate rounded style="margin: 0 auto;" @click="search">{{ i18n.ts.search }}</MkButton>
</div>
@@ -42,54 +51,145 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
-import { ref } from 'vue';
+import { computed, ref, toRef, watch } from 'vue';
+import type { UserDetailed } from 'misskey-js/entities.js';
+import type { Paging } from '@/components/MkPagination.vue';
import MkNotes from '@/components/MkNotes.vue';
import MkInput from '@/components/MkInput.vue';
import MkButton from '@/components/MkButton.vue';
-import MkSwitch from '@/components/MkSwitch.vue';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
import MkFolder from '@/components/MkFolder.vue';
import { useRouter } from '@/router/supplier.js';
+import MkUserCardMini from '@/components/MkUserCardMini.vue';
+import MkRadios from '@/components/MkRadios.vue';
+import { $i } from '@/account.js';
+import { instance } from '@/instance.js';
-const router = useRouter();
+const props = withDefaults(defineProps<{
+ query?: string;
+ userId?: string;
+ username?: string;
+ host?: string | null;
+}>(), {
+ query: '',
+ userId: undefined,
+ username: undefined,
+ host: '',
+});
+const router = useRouter();
const key = ref(0);
-const searchQuery = ref('');
-const searchOrigin = ref('combined');
-const notePagination = ref();
-const user = ref<any>(null);
-const isLocalOnly = ref(false);
+const searchQuery = ref(toRef(props, 'query').value);
+const notePagination = ref<Paging>();
+const user = ref<UserDetailed | null>(null);
+const hostInput = ref(toRef(props, 'host').value);
+
+const noteSearchableScope = instance.noteSearchableScope ?? 'local';
+
+const hostSelect = ref<'all' | 'local' | 'specified'>('all');
+
+const setHostSelectWithInput = (after:string|undefined|null, before:string|undefined|null) => {
+ if (before === after) return;
+ if (after === '') hostSelect.value = 'all';
+ else hostSelect.value = 'specified';
+};
+
+setHostSelectWithInput(hostInput.value, undefined);
+
+watch(hostInput, setHostSelectWithInput);
+
+const searchHost = computed(() => {
+ if (hostSelect.value === 'local') return '.';
+ if (hostSelect.value === 'specified') return hostInput.value;
+ return null;
+});
+
+if (props.userId != null) {
+ misskeyApi('users/show', { userId: props.userId }).then(_user => {
+ user.value = _user;
+ });
+} else if (props.username != null) {
+ misskeyApi('users/show', {
+ username: props.username,
+ ...(props.host != null && props.host !== '') ? { host: props.host } : {},
+ }).then(_user => {
+ user.value = _user;
+ });
+}
function selectUser() {
- os.selectUser({ includeSelf: true }).then(_user => {
+ os.selectUser({ includeSelf: true, localOnly: instance.noteSearchableScope === 'local' }).then(_user => {
user.value = _user;
+ hostInput.value = _user.host ?? '';
});
}
+function selectSelf() {
+ user.value = $i as UserDetailed | null;
+ hostInput.value = null;
+}
+
+function removeUser() {
+ user.value = null;
+ hostInput.value = '';
+}
+
async function search() {
const query = searchQuery.value.toString().trim();
if (query == null || query === '') return;
- if (query.startsWith('https://')) {
- const promise = misskeyApi('ap/show', {
- uri: query,
+ //#region AP lookup
+ if (query.startsWith('https://') && !query.includes(' ')) {
+ const confirm = await os.confirm({
+ type: 'info',
+ text: i18n.ts.lookupConfirm,
});
+ if (!confirm.canceled) {
+ const promise = misskeyApi('ap/show', {
+ uri: query,
+ });
+
+ os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject);
+
+ const res = await promise;
- os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject);
+ if (res.type === 'User') {
+ router.push(`/@${res.object.username}@${res.object.host}`);
+ } else if (res.type === 'Note') {
+ router.push(`/notes/${res.object.id}`);
+ }
- const res = await promise;
+ return;
+ }
+ }
+ //#endregion
- if (res.type === 'User') {
- router.push(`/@${res.object.username}@${res.object.host}`);
- } else if (res.type === 'Note') {
- router.push(`/notes/${res.object.id}`);
+ if (query.length > 1 && !query.includes(' ')) {
+ if (query.startsWith('@')) {
+ const confirm = await os.confirm({
+ type: 'info',
+ text: i18n.ts.lookupConfirm,
+ });
+ if (!confirm.canceled) {
+ router.push(`/${query}`);
+ return;
+ }
}
- return;
+ if (query.startsWith('#')) {
+ const confirm = await os.confirm({
+ type: 'info',
+ text: i18n.ts.openTagPageConfirm,
+ });
+ if (!confirm.canceled) {
+ router.push(`/tags/${encodeURIComponent(query.substring(1))}`);
+ return;
+ }
+ }
}
notePagination.value = {
@@ -98,11 +198,49 @@ async function search() {
params: {
query: searchQuery.value,
userId: user.value ? user.value.id : null,
+ ...(searchHost.value ? { host: searchHost.value } : {}),
},
};
- if (isLocalOnly.value) notePagination.value.params.host = '.';
-
key.value++;
}
</script>
+<style lang="scss" module>
+.userItem {
+ display: flex;
+ justify-content: center;
+}
+.addMeButton {
+ border: 2px dashed var(--fgTransparent);
+ padding: 12px;
+ margin-right: 16px;
+}
+.addUserButton {
+ border: 2px dashed var(--fgTransparent);
+ padding: 12px;
+ flex-grow: 1;
+}
+.addUserButtonInner {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: space-between;
+ min-height: 38px;
+}
+.userCard {
+ flex-grow: 1;
+}
+.remove {
+ width: 32px;
+ height: 32px;
+ align-self: center;
+
+ & > i:before {
+ color: #ff2a2a;
+ }
+
+ &:disabled {
+ opacity: 0;
+ }
+}
+</style>
diff --git a/packages/frontend/src/pages/search.stories.impl.ts b/packages/frontend/src/pages/search.stories.impl.ts
new file mode 100644
index 0000000000..0110a7ab8e
--- /dev/null
+++ b/packages/frontend/src/pages/search.stories.impl.ts
@@ -0,0 +1,88 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { StoryObj } from '@storybook/vue3';
+import { HttpResponse, http } from 'msw';
+import search_ from './search.vue';
+import { userDetailed } from '@/../.storybook/fakes.js';
+import { commonHandlers } from '@/../.storybook/mocks.js';
+
+const localUser = userDetailed('someuserid', 'miskist', null, 'Local Misskey User');
+
+export const Default = {
+ render(args) {
+ return {
+ components: {
+ search_,
+ },
+ setup() {
+ return {
+ args,
+ };
+ },
+ computed: {
+ props() {
+ return {
+ ...this.args,
+ };
+ },
+ },
+ template: '<search_ v-bind="props" />',
+ };
+ },
+ args: {
+ ignoreNotesSearchAvailable: true,
+ },
+ parameters: {
+ layout: 'fullscreen',
+ msw: {
+ handlers: [
+ ...commonHandlers,
+ http.post('/api/users/show', () => {
+ return HttpResponse.json(userDetailed());
+ }),
+ http.post('/api/users/search', () => {
+ return HttpResponse.json([userDetailed(), localUser]);
+ }),
+ ],
+ },
+ },
+} satisfies StoryObj<typeof search_>;
+
+export const NoteSearchDisabled = {
+ ...Default,
+ args: {},
+} satisfies StoryObj<typeof search_>;
+
+export const WithUsernameLocal = {
+ ...Default,
+
+ args: {
+ ...Default.args,
+ username: localUser.username,
+ host: localUser.host,
+ },
+ parameters: {
+ layout: 'fullscreen',
+ msw: {
+ handlers: [
+ ...commonHandlers,
+ http.post('/api/users/show', () => {
+ return HttpResponse.json(localUser);
+ }),
+ http.post('/api/users/search', () => {
+ return HttpResponse.json([userDetailed(), localUser]);
+ }),
+ ],
+ },
+ },
+} satisfies StoryObj<typeof search_>;
+
+export const WithUserType = {
+ ...Default,
+ args: {
+ type: 'user',
+ },
+} satisfies StoryObj<typeof search_>;
diff --git a/packages/frontend/src/pages/search.user.vue b/packages/frontend/src/pages/search.user.vue
index b9c2704bc7..724fbfdfbd 100644
--- a/packages/frontend/src/pages/search.user.vue
+++ b/packages/frontend/src/pages/search.user.vue
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div class="_gaps">
<div class="_gaps">
- <MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search" @enter="search">
+ <MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search" @enter.prevent="search">
<template #prefix><i class="ti ti-search"></i></template>
</MkInput>
<MkRadios v-model="searchOrigin" @update:modelValue="search()">
@@ -25,7 +25,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
-import { ref } from 'vue';
+import { ref, toRef } from 'vue';
+import type { Endpoints } from 'misskey-js';
+import type { Paging } from '@/components/MkPagination.vue';
import MkUserList from '@/components/MkUserList.vue';
import MkInput from '@/components/MkInput.vue';
import MkRadios from '@/components/MkRadios.vue';
@@ -36,34 +38,74 @@ import MkFoldableSection from '@/components/MkFoldableSection.vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { useRouter } from '@/router/supplier.js';
+const props = withDefaults(defineProps<{
+ query?: string,
+ origin?: Endpoints['users/search']['req']['origin'],
+}>(), {
+ query: '',
+ origin: 'combined',
+});
+
const router = useRouter();
const key = ref('');
-const searchQuery = ref('');
-const searchOrigin = ref('combined');
-const userPagination = ref();
+const searchQuery = ref(toRef(props, 'query').value);
+const searchOrigin = ref(toRef(props, 'origin').value);
+const userPagination = ref<Paging>();
async function search() {
const query = searchQuery.value.toString().trim();
if (query == null || query === '') return;
- if (query.startsWith('https://')) {
- const promise = misskeyApi('ap/show', {
- uri: query,
+ //#region AP lookup
+ if (query.startsWith('https://') && !query.includes(' ')) {
+ const confirm = await os.confirm({
+ type: 'info',
+ text: i18n.ts.lookupConfirm,
});
+ if (!confirm.canceled) {
+ const promise = misskeyApi('ap/show', {
+ uri: query,
+ });
+
+ os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject);
+
+ const res = await promise;
- os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject);
+ if (res.type === 'User') {
+ router.push(`/@${res.object.username}@${res.object.host}`);
+ } else if (res.type === 'Note') {
+ router.push(`/notes/${res.object.id}`);
+ }
- const res = await promise;
+ return;
+ }
+ }
+ //#endregion
- if (res.type === 'User') {
- router.push(`/@${res.object.username}@${res.object.host}`);
- } else if (res.type === 'Note') {
- router.push(`/notes/${res.object.id}`);
+ if (query.length > 1 && !query.includes(' ')) {
+ if (query.startsWith('@')) {
+ const confirm = await os.confirm({
+ type: 'info',
+ text: i18n.ts.lookupConfirm,
+ });
+ if (!confirm.canceled) {
+ router.push(`/${query}`);
+ return;
+ }
}
- return;
+ if (query.startsWith('#')) {
+ const confirm = await os.confirm({
+ type: 'info',
+ text: i18n.ts.openTagPageConfirm,
+ });
+ if (!confirm.canceled) {
+ router.push(`/user-tags/${encodeURIComponent(query.substring(1))}`);
+ return;
+ }
+ }
}
userPagination.value = {
diff --git a/packages/frontend/src/pages/search.vue b/packages/frontend/src/pages/search.vue
index a3dcda77be..38d7548fa8 100644
--- a/packages/frontend/src/pages/search.vue
+++ b/packages/frontend/src/pages/search.vue
@@ -9,8 +9,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
<MkSpacer v-if="tab === 'note'" key="note" :contentMax="800">
- <div v-if="notesSearchAvailable">
- <XNote/>
+ <div v-if="notesSearchAvailable || ignoreNotesSearchAvailable">
+ <XNote v-bind="props"/>
</div>
<div v-else>
<MkInfo warn>{{ i18n.ts.notesSearchNotAvailable }}</MkInfo>
@@ -18,27 +18,43 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSpacer>
<MkSpacer v-else-if="tab === 'user'" key="user" :contentMax="800">
- <XUser/>
+ <XUser v-bind="props"/>
</MkSpacer>
</MkHorizontalSwipe>
</MkStickyContainer>
</template>
<script lang="ts" setup>
-import { computed, defineAsyncComponent, ref } from 'vue';
+import { computed, defineAsyncComponent, ref, toRef } from 'vue';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
-import { $i } from '@/account.js';
-import { instance } from '@/instance.js';
+import { notesSearchAvailable } from '@/scripts/check-permissions.js';
import MkInfo from '@/components/MkInfo.vue';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
+const props = withDefaults(defineProps<{
+ query?: string,
+ userId?: string,
+ username?: string,
+ host?: string | null,
+ type?: 'note' | 'user',
+ origin?: 'combined' | 'local' | 'remote',
+ // For storybook only
+ ignoreNotesSearchAvailable?: boolean,
+}>(), {
+ query: '',
+ userId: undefined,
+ username: undefined,
+ host: undefined,
+ type: 'note',
+ origin: 'combined',
+ ignoreNotesSearchAvailable: false,
+});
+
const XNote = defineAsyncComponent(() => import('./search.note.vue'));
const XUser = defineAsyncComponent(() => import('./search.user.vue'));
-const tab = ref('note');
-
-const notesSearchAvailable = (($i == null && instance.policies.canSearchNotes) || ($i != null && $i.policies.canSearchNotes));
+const tab = ref(toRef(props, 'type').value);
const headerActions = computed(() => []);
diff --git a/packages/frontend/src/pages/settings/2fa.vue b/packages/frontend/src/pages/settings/2fa.vue
index b7d648c1a4..6a9a1e16e2 100644
--- a/packages/frontend/src/pages/settings/2fa.vue
+++ b/packages/frontend/src/pages/settings/2fa.vue
@@ -108,9 +108,11 @@ async function registerTOTP(): Promise<void> {
token: auth.result.token,
});
- os.popup(defineAsyncComponent(() => import('./2fa.qrdialog.vue')), {
+ const { dispose } = os.popup(defineAsyncComponent(() => import('./2fa.qrdialog.vue')), {
twoFactorData,
- }, {}, 'closed');
+ }, {
+ closed: () => dispose(),
+ });
}
async function unregisterTOTP(): Promise<void> {
diff --git a/packages/frontend/src/pages/settings/accounts.vue b/packages/frontend/src/pages/settings/accounts.vue
index 1182346de9..08c9261dcf 100644
--- a/packages/frontend/src/pages/settings/accounts.vue
+++ b/packages/frontend/src/pages/settings/accounts.vue
@@ -74,22 +74,24 @@ async function removeAccount(account) {
}
function addExistingAccount() {
- os.popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, {
+ const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, {
done: async res => {
await addAccounts(res.id, res.i);
os.success();
init();
},
- }, 'closed');
+ closed: () => dispose(),
+ });
}
function createAccount() {
- os.popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, {
+ const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, {
done: async res => {
await addAccounts(res.id, res.i);
switchAccountWithToken(res.i);
},
- }, 'closed');
+ closed: () => dispose(),
+ });
}
async function switchAccount(account: any) {
diff --git a/packages/frontend/src/pages/settings/api.vue b/packages/frontend/src/pages/settings/api.vue
index d9596b4e45..b35d406a98 100644
--- a/packages/frontend/src/pages/settings/api.vue
+++ b/packages/frontend/src/pages/settings/api.vue
@@ -23,7 +23,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
const isDesktop = ref(window.innerWidth >= 1100);
function generateToken() {
- os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {}, {
+ const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {}, {
done: async result => {
const { name, permissions } = result;
const { token } = await misskeyApi('miauth/gen-token', {
@@ -38,7 +38,8 @@ function generateToken() {
text: token,
});
},
- }, 'closed');
+ closed: () => dispose(),
+ });
}
const headerActions = computed(() => []);
diff --git a/packages/frontend/src/pages/settings/avatar-decoration.vue b/packages/frontend/src/pages/settings/avatar-decoration.vue
index 3cc911c014..77229d3349 100644
--- a/packages/frontend/src/pages/settings/avatar-decoration.vue
+++ b/packages/frontend/src/pages/settings/avatar-decoration.vue
@@ -67,7 +67,7 @@ misskeyApi('get-avatar-decorations').then(_avatarDecorations => {
});
function openDecoration(avatarDecoration, index?: number) {
- os.popup(defineAsyncComponent(() => import('./avatar-decoration.dialog.vue')), {
+ const { dispose } = os.popup(defineAsyncComponent(() => import('./avatar-decoration.dialog.vue')), {
decoration: avatarDecoration,
usingIndex: index,
}, {
@@ -108,7 +108,8 @@ function openDecoration(avatarDecoration, index?: number) {
});
$i.avatarDecorations = update;
},
- }, 'closed');
+ closed: () => dispose(),
+ });
}
function detachAllDecorations() {
diff --git a/packages/frontend/src/pages/settings/drive-cleaner.vue b/packages/frontend/src/pages/settings/drive-cleaner.vue
index b20774c4ec..8d2946db63 100644
--- a/packages/frontend/src/pages/settings/drive-cleaner.vue
+++ b/packages/frontend/src/pages/settings/drive-cleaner.vue
@@ -48,7 +48,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script setup lang="ts">
-import { computed, ref, watch } from 'vue';
+import { computed, ref, watch, type StyleValue } from 'vue';
import tinycolor from 'tinycolor2';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
@@ -102,10 +102,10 @@ function fetchDriveInfo(): void {
});
}
-function genUsageBar(fsize: number): object {
+function genUsageBar(fsize: number): StyleValue {
return {
width: `${fsize / usage.value * 100}%`,
- background: tinycolor({ h: 180 - (fsize / usage.value * 180), s: 0.7, l: 0.5 }),
+ background: tinycolor({ h: 180 - (fsize / usage.value * 180), s: 0.7, l: 0.5 }).toHslString(),
};
}
diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue
index 81a8d474d2..0e66b93f1c 100644
--- a/packages/frontend/src/pages/settings/drive.vue
+++ b/packages/frontend/src/pages/settings/drive.vue
@@ -95,7 +95,7 @@ const meterStyle = computed(() => {
h: 180 - (usage.value / capacity.value * 180),
s: 0.7,
l: 0.5,
- }),
+ }).toHslString(),
};
});
diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue
index cfc63f2a08..94ef3b8485 100644
--- a/packages/frontend/src/pages/settings/general.vue
+++ b/packages/frontend/src/pages/settings/general.vue
@@ -169,6 +169,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-model="disableStreamingTimeline">{{ i18n.ts.disableStreamingTimeline }}</MkSwitch>
<MkSwitch v-model="enableHorizontalSwipe">{{ i18n.ts.enableHorizontalSwipe }}</MkSwitch>
<MkSwitch v-model="alwaysConfirmFollow">{{ i18n.ts.alwaysConfirmFollow }}</MkSwitch>
+ <MkSwitch v-model="confirmWhenRevealingSensitiveMedia">{{ i18n.ts.confirmWhenRevealingSensitiveMedia }}</MkSwitch>
</div>
<MkSelect v-model="serverDisconnectedBehavior">
<template #label>{{ i18n.ts.whenServerDisconnected }}</template>
@@ -176,6 +177,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<option value="dialog">{{ i18n.ts._serverDisconnectedBehavior.dialog }}</option>
<option value="quiet">{{ i18n.ts._serverDisconnectedBehavior.quiet }}</option>
</MkSelect>
+ <MkSelect v-model="contextMenu">
+ <template #label>{{ i18n.ts._contextMenu.title }}</template>
+ <option value="app">{{ i18n.ts._contextMenu.app }}</option>
+ <option value="appWithShift">{{ i18n.ts._contextMenu.appWithShift }}</option>
+ <option value="native">{{ i18n.ts._contextMenu.native }}</option>
+ </MkSelect>
<MkRange v-model="numberOfPageCache" :min="1" :max="10" :step="1" easing>
<template #label>{{ i18n.ts.numberOfPageCache }}</template>
<template #caption>{{ i18n.ts.numberOfPageCacheDescription }}</template>
@@ -315,6 +322,8 @@ const enableSeasonalScreenEffect = computed(defaultStore.makeGetterSetter('enabl
const enableHorizontalSwipe = computed(defaultStore.makeGetterSetter('enableHorizontalSwipe'));
const useNativeUIForVideoAudioPlayer = computed(defaultStore.makeGetterSetter('useNativeUIForVideoAudioPlayer'));
const alwaysConfirmFollow = computed(defaultStore.makeGetterSetter('alwaysConfirmFollow'));
+const confirmWhenRevealingSensitiveMedia = computed(defaultStore.makeGetterSetter('confirmWhenRevealingSensitiveMedia'));
+const contextMenu = computed(defaultStore.makeGetterSetter('contextMenu'));
watch(lang, () => {
miLocalStorage.setItem('lang', lang.value as string);
@@ -357,6 +366,8 @@ watch([
disableStreamingTimeline,
enableSeasonalScreenEffect,
alwaysConfirmFollow,
+ confirmWhenRevealingSensitiveMedia,
+ contextMenu,
], async () => {
await reloadAsk();
});
diff --git a/packages/frontend/src/pages/settings/plugin.vue b/packages/frontend/src/pages/settings/plugin.vue
index 9804454e66..3c3dcfe41e 100644
--- a/packages/frontend/src/pages/settings/plugin.vue
+++ b/packages/frontend/src/pages/settings/plugin.vue
@@ -82,7 +82,7 @@ import MkCode from '@/components/MkCode.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkKeyValue from '@/components/MkKeyValue.vue';
import * as os from '@/os.js';
-import copyToClipboard from '@/scripts/copy-to-clipboard.js';
+import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { ColdDeviceStorage } from '@/store.js';
import { unisonReload } from '@/scripts/unison-reload.js';
import { i18n } from '@/i18n.js';
diff --git a/packages/frontend/src/pages/settings/preferences-backups.vue b/packages/frontend/src/pages/settings/preferences-backups.vue
index b6f1043154..dace2cd847 100644
--- a/packages/frontend/src/pages/settings/preferences-backups.vue
+++ b/packages/frontend/src/pages/settings/preferences-backups.vue
@@ -113,8 +113,6 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [
'sound_note',
'sound_noteMy',
'sound_notification',
- 'sound_antenna',
- 'sound_channel',
];
const coldDeviceStorageSaveKeys: (keyof typeof ColdDeviceStorage.default)[] = [
'lightTheme',
diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue
index 60bf9b4d3d..a328933686 100644
--- a/packages/frontend/src/pages/settings/profile.vue
+++ b/packages/frontend/src/pages/settings/profile.vue
@@ -342,6 +342,7 @@ definePageMetadata(() => ({
&:hover, &:focus {
opacity: .7;
}
+
&:active {
cursor: pointer;
}
diff --git a/packages/frontend/src/pages/settings/sounds.sound.vue b/packages/frontend/src/pages/settings/sounds.sound.vue
index 113abd708b..81478fede5 100644
--- a/packages/frontend/src/pages/settings/sounds.sound.vue
+++ b/packages/frontend/src/pages/settings/sounds.sound.vue
@@ -9,7 +9,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.sound }}</template>
<option v-for="x in soundsTypes" :key="x ?? 'null'" :value="x">{{ getSoundTypeName(x) }}</option>
</MkSelect>
- <div v-if="type === '_driveFile_'" :class="$style.fileSelectorRoot">
+ <div v-if="type === '_driveFile_' && driveFileError === true" :class="$style.fileSelectorRoot">
+ <MkButton :class="$style.fileSelectorButton" inline rounded primary @click="selectSound">{{ i18n.ts.selectFile }}</MkButton>
+ <div :class="$style.fileErrorRoot">
+ <MkCondensedLine>{{ i18n.ts._soundSettings.driveFileError }}</MkCondensedLine>
+ </div>
+ </div>
+ <div v-else-if="type === '_driveFile_'" :class="$style.fileSelectorRoot">
<MkButton :class="$style.fileSelectorButton" inline rounded primary @click="selectSound">{{ i18n.ts.selectFile }}</MkButton>
<div :class="['_nowrap', !fileUrl && $style.fileNotSelected]">{{ friendlyFileName }}</div>
</div>
@@ -19,13 +25,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_buttons">
<MkButton inline @click="listen"><i class="ti ti-player-play"></i> {{ i18n.ts.listen }}</MkButton>
- <MkButton inline primary @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
+ <MkButton inline primary :disabled="!hasChanged || driveFileError" @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
</div>
</div>
</template>
<script lang="ts" setup>
-import { ref, computed } from 'vue';
+import { ref, computed, watch } from 'vue';
import type { SoundType } from '@/scripts/sound.js';
import MkSelect from '@/components/MkSelect.vue';
import MkButton from '@/components/MkButton.vue';
@@ -51,13 +57,18 @@ const type = ref<SoundType>(props.type);
const fileId = ref(props.fileId);
const fileUrl = ref(props.fileUrl);
const fileName = ref<string>('');
+const driveFileError = ref(false);
+const hasChanged = ref(false);
const volume = ref(props.volume);
if (type.value === '_driveFile_' && fileId.value) {
- const apiRes = await misskeyApi('drive/files/show', {
+ await misskeyApi('drive/files/show', {
fileId: fileId.value,
+ }).then((res) => {
+ fileName.value = res.name;
+ }).catch((res) => {
+ driveFileError.value = true;
});
- fileName.value = apiRes.name;
}
function getSoundTypeName(f: SoundType): string {
@@ -107,9 +118,21 @@ function selectSound(ev) {
fileUrl.value = file.url;
fileName.value = file.name;
fileId.value = file.id;
+ driveFileError.value = false;
+ hasChanged.value = true;
});
}
+watch([type, volume], ([typeTo, volumeTo], [typeFrom, volumeFrom]) => {
+ if (typeFrom !== typeTo && typeTo !== '_driveFile_') {
+ fileUrl.value = undefined;
+ fileName.value = '';
+ fileId.value = undefined;
+ driveFileError.value = false;
+ }
+ hasChanged.value = true;
+});
+
function listen() {
if (type.value === '_driveFile_' && (!fileUrl.value || !fileId.value)) {
os.alert({
@@ -131,6 +154,10 @@ function listen() {
}
function save() {
+ if (hasChanged.value === false || driveFileError.value === true) {
+ return;
+ }
+
if (type.value === '_driveFile_' && !fileUrl.value) {
os.alert({
type: 'warning',
@@ -163,6 +190,13 @@ function save() {
gap: 8px;
}
+.fileErrorRoot {
+ flex-grow: 1;
+ min-width: 0;
+ font-weight: 700;
+ color: var(--error);
+}
+
.fileSelectorButton {
flex-shrink: 0;
}
diff --git a/packages/frontend/src/pages/settings/sounds.vue b/packages/frontend/src/pages/settings/sounds.vue
index 090f0cf14c..9fcf564e55 100644
--- a/packages/frontend/src/pages/settings/sounds.vue
+++ b/packages/frontend/src/pages/settings/sounds.vue
@@ -21,8 +21,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkFolder v-for="type in operationTypes" :key="type">
<template #label>{{ i18n.ts._sfx[type] }}</template>
<template #suffix>{{ getSoundTypeName(sounds[type].type) }}</template>
-
- <XSound :type="sounds[type].type" :volume="sounds[type].volume" :fileId="sounds[type].fileId" :fileUrl="sounds[type].fileUrl" @update="(res) => updated(type, res)"/>
+ <Suspense>
+ <template #default>
+ <XSound :type="sounds[type].type" :volume="sounds[type].volume" :fileId="sounds[type].fileId" :fileUrl="sounds[type].fileUrl" @update="(res) => updated(type, res)"/>
+ </template>
+ <template #fallback>
+ <MkLoading/>
+ </template>
+ </Suspense>
</MkFolder>
</div>
</FormSection>
@@ -54,8 +60,6 @@ const sounds = ref<Record<OperationType, Ref<SoundStore>>>({
note: defaultStore.reactiveState.sound_note,
noteMy: defaultStore.reactiveState.sound_noteMy,
notification: defaultStore.reactiveState.sound_notification,
- antenna: defaultStore.reactiveState.sound_antenna,
- channel: defaultStore.reactiveState.sound_channel,
reaction: defaultStore.reactiveState.sound_reaction,
});
diff --git a/packages/frontend/src/pages/settings/statusbar.statusbar.vue b/packages/frontend/src/pages/settings/statusbar.statusbar.vue
index 92e389a288..67943524ef 100644
--- a/packages/frontend/src/pages/settings/statusbar.statusbar.vue
+++ b/packages/frontend/src/pages/settings/statusbar.statusbar.vue
@@ -36,7 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-model="statusbar.props.shuffle">
<template #label>{{ i18n.ts.shuffle }}</template>
</MkSwitch>
- <MkInput v-model="statusbar.props.refreshIntervalSec" manualSave type="number">
+ <MkInput v-model="statusbar.props.refreshIntervalSec" manualSave type="number" min="1">
<template #label>{{ i18n.ts.refreshInterval }}</template>
</MkInput>
<MkRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1">
@@ -48,7 +48,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
</template>
<template v-else-if="statusbar.type === 'federation'">
- <MkInput v-model="statusbar.props.refreshIntervalSec" manualSave type="number">
+ <MkInput v-model="statusbar.props.refreshIntervalSec" manualSave type="number" min="1">
<template #label>{{ i18n.ts.refreshInterval }}</template>
</MkInput>
<MkRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1">
diff --git a/packages/frontend/src/pages/settings/theme.manage.vue b/packages/frontend/src/pages/settings/theme.manage.vue
index 8a94d7388b..579ca6b20b 100644
--- a/packages/frontend/src/pages/settings/theme.manage.vue
+++ b/packages/frontend/src/pages/settings/theme.manage.vue
@@ -38,7 +38,7 @@ import MkSelect from '@/components/MkSelect.vue';
import MkInput from '@/components/MkInput.vue';
import MkButton from '@/components/MkButton.vue';
import { Theme, getBuiltinThemesRef } from '@/scripts/theme.js';
-import copyToClipboard from '@/scripts/copy-to-clipboard.js';
+import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import * as os from '@/os.js';
import { getThemes, removeTheme } from '@/theme-store.js';
import { i18n } from '@/i18n.js';
diff --git a/packages/frontend/src/pages/settings/theme.vue b/packages/frontend/src/pages/settings/theme.vue
index 0a4bd4b826..7d192bcbea 100644
--- a/packages/frontend/src/pages/settings/theme.vue
+++ b/packages/frontend/src/pages/settings/theme.vue
@@ -213,12 +213,18 @@ definePageMetadata(() => ({
}
}
+ .dn:focus-visible ~ .toggle {
+ outline: 2px solid var(--focus);
+ outline-offset: 2px;
+ }
+
.toggle {
cursor: pointer;
display: inline-block;
position: relative;
width: 90px;
height: 50px;
+ margin: 4px; // focus用のアウトライン
background-color: #83D8FF;
border-radius: 90px - 6;
transition: background-color 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
diff --git a/packages/frontend/src/pages/settings/webhook.edit.vue b/packages/frontend/src/pages/settings/webhook.edit.vue
index e9fb1e471e..058ef69c35 100644
--- a/packages/frontend/src/pages/settings/webhook.edit.vue
+++ b/packages/frontend/src/pages/settings/webhook.edit.vue
@@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkInput>
<FormSection>
- <template #label>{{ i18n.ts._webhookSettings.events }}</template>
+ <template #label>{{ i18n.ts._webhookSettings.trigger }}</template>
<div class="_gaps_s">
<MkSwitch v-model="event_follow">{{ i18n.ts._webhookSettings._events.follow }}</MkSwitch>
diff --git a/packages/frontend/src/pages/settings/webhook.new.vue b/packages/frontend/src/pages/settings/webhook.new.vue
index 5bf85e48f4..d62357caaf 100644
--- a/packages/frontend/src/pages/settings/webhook.new.vue
+++ b/packages/frontend/src/pages/settings/webhook.new.vue
@@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkInput>
<FormSection>
- <template #label>{{ i18n.ts._webhookSettings.events }}</template>
+ <template #label>{{ i18n.ts._webhookSettings.trigger }}</template>
<div class="_gaps_s">
<MkSwitch v-model="event_follow">{{ i18n.ts._webhookSettings._events.follow }}</MkSwitch>
diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue
index 98744c6318..32f6dd0e5a 100644
--- a/packages/frontend/src/pages/timeline.vue
+++ b/packages/frontend/src/pages/timeline.vue
@@ -8,8 +8,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header><MkPageHeader v-model:tab="src" :actions="headerActions" :tabs="$i ? headerTabs : headerTabsWhenNotLogin" :displayMyAvatar="true"/></template>
<MkSpacer :contentMax="800">
<MkHorizontalSwipe v-model:tab="src" :tabs="$i ? headerTabs : headerTabsWhenNotLogin">
- <div :key="src" ref="rootEl" v-hotkey.global="keymap">
- <MkInfo v-if="['home', 'local', 'social', 'global'].includes(src) && !defaultStore.reactiveState.timelineTutorials.value[src]" style="margin-bottom: var(--margin);" closable @close="closeTutorial()">
+ <div :key="src" ref="rootEl">
+ <MkInfo v-if="isBasicTimeline(src) && !defaultStore.reactiveState.timelineTutorials.value[src]" style="margin-bottom: var(--margin);" closable @close="closeTutorial()">
{{ i18n.ts._timelineDescription[src] }}
</MkInfo>
<MkPostForm v-if="defaultStore.reactiveState.showFixedPostForm.value" :class="$style.postForm" class="post-form _panel" fixed style="margin-bottom: var(--margin);"/>
@@ -45,7 +45,6 @@ import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
-import { instance } from '@/instance.js';
import { $i } from '@/account.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { antennasCache, userListsCache, favoritedChannelsCache } from '@/cache.js';
@@ -53,20 +52,15 @@ import { deviceKind } from '@/scripts/device-kind.js';
import { deepMerge } from '@/scripts/merge.js';
import { MenuItem } from '@/types/menu.js';
import { miLocalStorage } from '@/local-storage.js';
+import { availableBasicTimelines, hasWithReplies, isAvailableBasicTimeline, isBasicTimeline, basicTimelineIconClass } from '@/timelines.js';
provide('shouldOmitHeaderTitle', true);
-const isLocalTimelineAvailable = ($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable);
-const isGlobalTimelineAvailable = ($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable);
-const keymap = {
- 't': focus,
-};
-
const tlComponent = shallowRef<InstanceType<typeof MkTimeline>>();
const rootEl = shallowRef<HTMLElement>();
const queue = ref(0);
-const srcWhenNotSignin = ref<'local' | 'global'>(isLocalTimelineAvailable ? 'local' : 'global');
+const srcWhenNotSignin = ref<'local' | 'global'>(isAvailableBasicTimeline('local') ? 'local' : 'global');
const src = computed<'home' | 'local' | 'social' | 'global' | `list:${string}`>({
get: () => ($i ? defaultStore.reactiveState.tl.value.src : srcWhenNotSignin.value),
set: (x) => saveSrc(x),
@@ -77,7 +71,11 @@ const withRenotes = computed<boolean>({
});
// computed内での無限ループを防ぐためのフラグ
-const localSocialTLFilterSwitchStore = ref<'withReplies' | 'onlyFiles' | false>('withReplies');
+const localSocialTLFilterSwitchStore = ref<'withReplies' | 'onlyFiles' | false>(
+ defaultStore.reactiveState.tl.value.filter.withReplies ? 'withReplies' :
+ defaultStore.reactiveState.tl.value.filter.onlyFiles ? 'onlyFiles' :
+ false,
+);
const withReplies = computed<boolean>({
get: () => {
@@ -232,7 +230,7 @@ function focus(): void {
}
function closeTutorial(): void {
- if (!['home', 'local', 'social', 'global'].includes(src.value)) return;
+ if (!isBasicTimeline(src.value)) return;
const before = defaultStore.state.timelineTutorials;
before[src.value] = true;
defaultStore.set('timelineTutorials', before);
@@ -248,7 +246,7 @@ const headerActions = computed(() => {
type: 'switch',
text: i18n.ts.showRenotes,
ref: withRenotes,
- }, src.value === 'local' || src.value === 'social' ? {
+ }, isBasicTimeline(src.value) && hasWithReplies(src.value) ? {
type: 'switch',
text: i18n.ts.showRepliesToOthersInTimeline,
ref: withReplies,
@@ -261,7 +259,7 @@ const headerActions = computed(() => {
type: 'switch',
text: i18n.ts.fileAttachedOnly,
ref: onlyFiles,
- disabled: src.value === 'local' || src.value === 'social' ? withReplies : false,
+ disabled: isBasicTimeline(src.value) && hasWithReplies(src.value) ? withReplies : false,
}], ev.currentTarget ?? ev.target);
},
},
@@ -283,27 +281,12 @@ const headerTabs = computed(() => [...(defaultStore.reactiveState.pinnedUserList
title: l.name,
icon: 'ti ti-star',
iconOnly: true,
-}))), {
- key: 'home',
- title: i18n.ts._timelines.home,
- icon: 'ti ti-home',
- iconOnly: true,
-}, ...(isLocalTimelineAvailable ? [{
- key: 'local',
- title: i18n.ts._timelines.local,
- icon: 'ti ti-planet',
- iconOnly: true,
-}, {
- key: 'social',
- title: i18n.ts._timelines.social,
- icon: 'ti ti-universe',
- iconOnly: true,
-}] : []), ...(isGlobalTimelineAvailable ? [{
- key: 'global',
- title: i18n.ts._timelines.global,
- icon: 'ti ti-whirl',
+}))), ...availableBasicTimelines().map(tl => ({
+ key: tl,
+ title: i18n.ts._timelines[tl],
+ icon: basicTimelineIconClass(tl),
iconOnly: true,
-}] : []), {
+})), {
icon: 'ti ti-list',
title: i18n.ts.lists,
iconOnly: true,
@@ -320,24 +303,16 @@ const headerTabs = computed(() => [...(defaultStore.reactiveState.pinnedUserList
onClick: chooseChannel,
}] as Tab[]);
-const headerTabsWhenNotLogin = computed(() => [
- ...(isLocalTimelineAvailable ? [{
- key: 'local',
- title: i18n.ts._timelines.local,
- icon: 'ti ti-planet',
- iconOnly: true,
- }] : []),
- ...(isGlobalTimelineAvailable ? [{
- key: 'global',
- title: i18n.ts._timelines.global,
- icon: 'ti ti-whirl',
- iconOnly: true,
- }] : []),
-] as Tab[]);
+const headerTabsWhenNotLogin = computed(() => [...availableBasicTimelines().map(tl => ({
+ key: tl,
+ title: i18n.ts._timelines[tl],
+ icon: basicTimelineIconClass(tl),
+ iconOnly: true,
+}))] as Tab[]);
definePageMetadata(() => ({
title: i18n.ts.timeline,
- icon: src.value === 'local' ? 'ti ti-planet' : src.value === 'social' ? 'ti ti-universe' : src.value === 'global' ? 'ti ti-whirl' : 'ti ti-home',
+ icon: isBasicTimeline(src.value) ? basicTimelineIconClass(src.value) : 'ti ti-home',
}));
</script>
diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue
index 4e3e383e33..3039ec7499 100644
--- a/packages/frontend/src/pages/user/home.vue
+++ b/packages/frontend/src/pages/user/home.vue
@@ -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/>
@@ -97,7 +97,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="user.fields.length > 0" class="fields">
<dl v-for="(field, i) in user.fields" :key="i" class="field">
<dt class="name">
- <Mfm :text="field.name" :plain="true" :colored="false"/>
+ <Mfm :text="field.name" :author="user" :plain="true" :colored="false"/>
</dt>
<dd class="value">
<Mfm :text="field.value" :author="user" :colored="false"/>
@@ -167,12 +167,14 @@ import number from '@/filters/number.js';
import { userPage } from '@/filters/user.js';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
+import { defaultStore } from '@/store.js';
import { $i, iAmModerator } from '@/account.js';
import { dateString } from '@/filters/date.js';
import { confetti } from '@/scripts/confetti.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js';
import { useRouter } from '@/router/supplier.js';
+import { getStaticImageUrl } from '@/scripts/media-proxy.js';
function calcAge(birthdate: string): number {
const date = new Date(birthdate);
@@ -220,8 +222,14 @@ watch(moderationNote, async () => {
const style = computed(() => {
if (props.user.bannerUrl == null) return {};
- return {
- backgroundImage: `url(${ props.user.bannerUrl })`,
+ if (defaultStore.state.disableShowingAnimatedImages) {
+ return {
+ backgroundImage: `url(${ getStaticImageUrl(props.user.bannerUrl) })`,
+ };
+ } else {
+ return {
+ backgroundImage: `url(${ props.user.bannerUrl })`,
+ };
};
});
@@ -392,11 +400,12 @@ onUnmounted(() => {
> .name {
display: block;
- margin: 0;
+ margin: -10px;
+ padding: 10px;
line-height: 32px;
font-weight: bold;
font-size: 1.8em;
- text-shadow: 0 0 8px #000;
+ filter: drop-shadow(0 0 4px #000);
}
> .bottom {
diff --git a/packages/frontend/src/pages/welcome.timeline.note.vue b/packages/frontend/src/pages/welcome.timeline.note.vue
new file mode 100644
index 0000000000..f385938343
--- /dev/null
+++ b/packages/frontend/src/pages/welcome.timeline.note.vue
@@ -0,0 +1,109 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :key="note.id" :class="$style.note">
+ <div class="_panel _gaps_s" :class="$style.content">
+ <div v-if="note.cw != null" :class="$style.richcontent">
+ <div><Mfm :text="note.cw" :author="note.user"/></div>
+ <MkCwButton v-model="showContent" :text="note.text" :renote="note.renote" :files="note.files" :poll="note.poll" style="margin: 4px 0;"/>
+ <div v-if="showContent">
+ <MkA v-if="note.replyId" class="reply" :to="`/notes/${note.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
+ <Mfm v-if="note.text" :text="note.text" :author="note.user"/>
+ <MkA v-if="note.renoteId" class="rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA>
+ </div>
+ </div>
+ <div v-else ref="noteTextEl" :class="[$style.text, { [$style.collapsed]: shouldCollapse }]">
+ <MkA v-if="note.replyId" class="reply" :to="`/notes/${note.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
+ <Mfm v-if="note.text" :text="note.text" :author="note.user"/>
+ <MkA v-if="note.renoteId" class="rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA>
+ </div>
+ <div v-if="note.files && note.files.length > 0" :class="$style.richcontent">
+ <MkMediaList :mediaList="note.files.slice(0, 4)"/>
+ </div>
+ <div v-if="note.poll">
+ <MkPoll :noteId="note.id" :poll="note.poll" :readOnly="true"/>
+ </div>
+ <div v-if="note.reactionCount > 0" :class="$style.reactions">
+ <MkReactionsViewer :note="note" :maxNumber="16"/>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { ref, shallowRef, onUpdated, onMounted } from 'vue';
+import * as Misskey from 'misskey-js';
+import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
+import MkMediaList from '@/components/MkMediaList.vue';
+import MkPoll from '@/components/MkPoll.vue';
+import MkCwButton from '@/components/MkCwButton.vue';
+
+defineProps<{
+ note: Misskey.entities.Note;
+}>();
+
+const noteTextEl = shallowRef<HTMLDivElement>();
+const shouldCollapse = ref(false);
+const showContent = ref(false);
+
+function calcCollapse() {
+ if (noteTextEl.value) {
+ const height = noteTextEl.value.scrollHeight;
+ if (height > 200) {
+ shouldCollapse.value = true;
+ }
+ }
+}
+
+onMounted(() => {
+ calcCollapse();
+});
+
+onUpdated(() => {
+ calcCollapse();
+});
+</script>
+
+<style lang="scss" module>
+.note {
+ margin-left: auto;
+}
+
+.text {
+ position: relative;
+ max-height: 200px;
+ overflow: hidden;
+
+ &.collapsed::after {
+ content: '';
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ height: 64px;
+ background: linear-gradient(0deg, var(--panel), var(--X15));
+ }
+}
+
+.content {
+ padding: 16px;
+ margin: 0 0 0 auto;
+ max-width: max-content;
+ border-radius: 16px;
+}
+
+.reactions {
+ box-sizing: border-box;
+ margin: 8px -16px -8px;
+ padding: 8px 16px 0;
+ width: calc(100% + 32px);
+ border-top: 1px solid var(--divider);
+}
+
+.richcontent {
+ min-width: 250px;
+}
+</style>
diff --git a/packages/frontend/src/pages/welcome.timeline.vue b/packages/frontend/src/pages/welcome.timeline.vue
index 139b2e0a07..db326f9e6c 100644
--- a/packages/frontend/src/pages/welcome.timeline.vue
+++ b/packages/frontend/src/pages/welcome.timeline.vue
@@ -4,24 +4,17 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
-<div :class="$style.root">
- <div ref="scrollEl" :class="[$style.scrollbox, { [$style.scroll]: isScrolling }]">
- <div v-for="note in notes" :key="note.id" :class="$style.note">
- <div class="_panel" :class="$style.content">
- <div>
- <MkA v-if="note.replyId" class="reply" :to="`/notes/${note.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
- <Mfm v-if="note.text" :text="note.text" :author="note.user"/>
- <MkA v-if="note.renoteId" class="rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA>
- </div>
- <div v-if="note.files.length > 0" :class="$style.richcontent">
- <MkMediaList :mediaList="note.files"/>
- </div>
- <div v-if="note.poll">
- <MkPoll :noteId="note.id" :poll="note.poll" :readOnly="true"/>
- </div>
- </div>
- <MkReactionsViewer ref="reactionsViewer" :note="note"/>
- </div>
+<div :class="$style.root" class="_gaps">
+ <div
+ ref="notesMainContainerEl"
+ class="_gaps"
+ :class="[$style.scrollBoxMain, { [$style.scrollIntro]: (scrollState === 'intro'), [$style.scrollLoop]: (scrollState === 'loop') }]"
+ @animationend="changeScrollState"
+ >
+ <XNote v-for="note in notes" :key="`${note.id}_1`" :class="$style.note" :note="note"/>
+ </div>
+ <div v-if="isScrolling" class="_gaps" :class="[$style.scrollBoxSub, { [$style.scrollIntro]: (scrollState === 'intro'), [$style.scrollLoop]: (scrollState === 'loop') }]">
+ <XNote v-for="note in notes" :key="`${note.id}_2`" :class="$style.note" :note="note"/>
</div>
</div>
</template>
@@ -29,43 +22,54 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import * as Misskey from 'misskey-js';
import { onUpdated, ref, shallowRef } from 'vue';
-import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
-import MkMediaList from '@/components/MkMediaList.vue';
-import MkPoll from '@/components/MkPoll.vue';
+import XNote from '@/pages/welcome.timeline.note.vue';
import { misskeyApiGet } from '@/scripts/misskey-api.js';
import { getScrollContainer } from '@/scripts/scroll.js';
const notes = ref<Misskey.entities.Note[]>([]);
const isScrolling = ref(false);
-const scrollEl = shallowRef<HTMLElement>();
+const scrollState = ref<null | 'intro' | 'loop'>(null);
+const notesMainContainerEl = shallowRef<HTMLElement>();
misskeyApiGet('notes/featured').then(_notes => {
notes.value = _notes;
});
+function changeScrollState() {
+ if (scrollState.value !== 'loop') {
+ scrollState.value = 'loop';
+ }
+}
+
onUpdated(() => {
- if (!scrollEl.value) return;
- const container = getScrollContainer(scrollEl.value);
+ if (!notesMainContainerEl.value) return;
+ const container = getScrollContainer(notesMainContainerEl.value);
const containerHeight = container ? container.clientHeight : window.innerHeight;
- if (scrollEl.value.offsetHeight > containerHeight) {
+ if (notesMainContainerEl.value.offsetHeight > containerHeight) {
+ if (scrollState.value === null) {
+ scrollState.value = 'intro';
+ }
isScrolling.value = true;
}
});
</script>
<style lang="scss" module>
-@keyframes scroll {
+@keyframes scrollIntro {
0% {
transform: translate3d(0, 0, 0);
}
- 5% {
- transform: translate3d(0, 0, 0);
+ 100% {
+ transform: translate3d(0, calc(calc(-100% - 128px) - var(--margin)), 0);
}
- 75% {
- transform: translate3d(0, calc(-100% + 90vh), 0);
+}
+
+@keyframes scrollConstant {
+ 0% {
+ transform: translate3d(0, -128px, 0);
}
- 90% {
- transform: translate3d(0, calc(-100% + 90vh), 0);
+ 100% {
+ transform: translate3d(0, calc(calc(-100% - 128px) - var(--margin)), 0);
}
}
@@ -73,24 +77,26 @@ onUpdated(() => {
text-align: right;
}
-.scrollbox {
- &.scroll {
- animation: scroll 45s linear infinite;
+.scrollBoxMain {
+ &.scrollIntro {
+ animation: scrollIntro 30s linear forwards;
+ }
+ &.scrollLoop {
+ animation: scrollConstant 30s linear infinite;
}
}
-.note {
- margin: 16px 0 16px auto;
-}
-
-.content {
- padding: 16px;
- margin: 0 0 0 auto;
- max-width: max-content;
- border-radius: 16px;
+.scrollBoxSub {
+ &.scrollIntro {
+ animation: scrollIntro 30s linear forwards;
+ }
+ &.scrollLoop {
+ animation: scrollConstant 30s linear infinite;
+ }
}
-.richcontent {
- min-width: 250px;
+.root:has(.note:hover) .scrollBoxMain,
+.root:has(.note:hover) .scrollBoxSub {
+ animation-play-state: paused;
}
</style>