Compare commits

...

11 Commits

Author SHA1 Message Date
github-actions[bot]
6378dfbffc Bump version to 2024.9.0-alpha.7 2024-09-23 13:00:04 +00:00
かっこかり
cd247b99ee fix(frontend): MkRangeのタッチ操作時にtooltipが複数重なって表示されないように (#14548)
* fix: directiveでのtooltip表示との競合を解消 (#265)

(cherry picked from commit 6d15d379a76b1b153ec2996e22bf0fc29ced5fda)

* code style

* Update Changelog

* record origin

* fix: ホバー時にもツールチップが出るように

---------

Co-authored-by: CaffeinePower <86540016+cffnpwr@users.noreply.github.com>
Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2024-09-23 21:53:51 +09:00
かっこかり
0c6d1ec524 refactor(frontend): popupMenuの項目作成時に三項演算子をなるべく使わないように (#14554)
* refactor(frontend): popupMenuの項目作成時に三項演算子をなるべく使わないように

* type import

* fix

* lint
2024-09-23 21:50:30 +09:00
かっこかり
e673c143a9 fix(backend): happy-domを使用後にcloseするように (#14615)
* Add `DetachedWindowAPI.close` calls to `MfmService`

(cherry picked from commit ceaec3324925e53ca3f467b0438a98f1108eed0f)

* fix

* update changelog

* fix

---------

Co-authored-by: Julia Johannesen <julia@insertdomain.name>
2024-09-23 21:43:48 +09:00
かっこかり
7f7445ad7a refactor(misskey-games): Misskey Games系パッケージのlint修正+Lint CI整備 (#14612)
* chore(lint): Fix linting in misskey-reversi

(cherry picked from commit 894934a1a7743472b2d051e2690007ae373efd76)

* chore(lint): Fix linting in misskey-bubble-game

(cherry picked from commit 1ba9c37a8d5e4ae6a98494026b87f6f6439790c7)

* enhance(gh): add lint ci for misskey games packages

* enhance(gh): fix lint ci

* fix

* revert some changes that nothing to do with lint rules

* fix

* lint fixes

* refactor: strict type def

* lint fixes

* 🎨

* 🎨

---------

Co-authored-by: 4censord <mail@4censord.de>
2024-09-23 21:25:23 +09:00
github-actions[bot]
733fd56058 Bump version to 2024.9.0-alpha.6 2024-09-23 10:53:19 +00:00
syuilo
3f0aaaa41e perf(embed): improve embed performance (#14613)
* wip

* wip

* wip

* refactor

* refactor

---------

Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
2024-09-23 19:49:52 +09:00
syuilo
2aebdb8cc5 enhance(frontend): tweak control panel 2024-09-23 17:18:37 +09:00
syuilo
cd52dc73bb 🎨 2024-09-23 14:51:34 +09:00
syuilo
1ba09e1eee enhance(frontend): improve forms usability 2024-09-23 14:42:38 +09:00
zyoshoka
2c615357f2 fix(misskey-js): wrong hashtag channel param type (#14611) 2024-09-23 09:53:50 +09:00
74 changed files with 1982 additions and 1502 deletions

View File

@@ -12,6 +12,8 @@ on:
- packages/frontend-embed/**
- packages/sw/**
- packages/misskey-js/**
- packages/misskey-bubble-game/**
- packages/misskey-reversi/**
- packages/shared/eslint.config.js
- .github/workflows/lint.yml
pull_request:
@@ -22,6 +24,8 @@ on:
- packages/frontend-embed/**
- packages/sw/**
- packages/misskey-js/**
- packages/misskey-bubble-game/**
- packages/misskey-reversi/**
- packages/shared/eslint.config.js
- .github/workflows/lint.yml
jobs:
@@ -53,6 +57,8 @@ jobs:
- frontend-embed
- sw
- misskey-js
- misskey-bubble-game
- misskey-reversi
env:
eslint-cache-version: v1
eslint-cache-path: ${{ github.workspace }}/node_modules/.cache/eslint-${{ matrix.workspace }}

View File

@@ -13,6 +13,8 @@
- Enhance: ScratchpadにUIインスペクターを追加
- Fix: サーバーメトリクスが2つ以上あるとリロード直後の表示がおかしくなる問題を修正
- Fix: 月の違う同じ日はセパレータが表示されないのを修正
- Fix: タッチ画面でレンジスライダーを操作するとツールチップが複数表示される問題を修正
(Cherry-picked from https://github.com/taiyme/misskey/pull/265)
- Fix: 縦横比が極端なカスタム絵文字を表示する際にレイアウトが崩れる箇所があるのを修正
(Cherry-picked from https://github.com/MisskeyIO/misskey/pull/725)
- Fix: 設定変更時のリロード確認ダイアログが複数個表示されることがある問題を修正
@@ -28,6 +30,8 @@
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/commit/26e0412fbb91447c37e8fb06ffb0487346063bb8)
- Fix: `Retry-After`ヘッダーが送信されなかった問題を修正
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/commit/8a982c61c01909e7540ff1be9f019df07c3f0624)
- Fix: サーバーサイドのDOM解析完了時にリソースを開放するように
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/634)
## 2024.8.0

12
locales/index.d.ts vendored
View File

@@ -5096,6 +5096,18 @@ export interface Locale extends ILocale {
* パフォーマンス
*/
"performance": string;
/**
* 変更あり
*/
"modified": string;
/**
* 破棄
*/
"discard": string;
/**
* {n}件の変更があります
*/
"thereAreNChanges": ParameterizedString<"n">;
"_delivery": {
/**
* 配信状態

View File

@@ -1270,6 +1270,9 @@ genEmbedCode: "埋め込みコードを生成"
noteOfThisUser: "このユーザーのノート一覧"
clipNoteLimitExceeded: "これ以上このクリップにノートを追加できません。"
performance: "パフォーマンス"
modified: "変更あり"
discard: "破棄"
thereAreNChanges: "{n}件の変更があります"
_delivery:
status: "配信状態"

View File

@@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "2024.9.0-alpha.5",
"version": "2024.9.0-alpha.7",
"codename": "nasubi",
"repository": {
"type": "git",

View File

@@ -239,7 +239,7 @@ export class MfmService {
return null;
}
const { window } = new Window();
const { happyDOM, window } = new Window();
const doc = window.document;
@@ -457,6 +457,10 @@ export class MfmService {
appendChildren(nodes, body);
return new XMLSerializer().serializeToString(body);
const serialized = new XMLSerializer().serializeToString(body);
happyDOM.close().catch(err => {});
return serialized;
}
}

View File

@@ -207,7 +207,7 @@ export class ApRequestService {
if ((contentType ?? '').split(';')[0].trimEnd().toLowerCase() === 'text/html' && _followAlternate === true) {
const html = await res.text();
const window = new Window({
const { window, happyDOM } = new Window({
settings: {
disableJavaScriptEvaluation: true,
disableJavaScriptFileLoading: true,
@@ -241,7 +241,7 @@ export class ApRequestService {
} catch (e) {
// something went wrong parsing the HTML, ignore the whole thing
} finally {
window.close();
happyDOM.close().catch(err => {});
}
}
//#endregion

View File

@@ -785,6 +785,72 @@ export class ClientServerService {
//#endregion
//#region embed pages
fastify.get<{ Params: { user: string; } }>('/embed/user-timeline/:user', async (request, reply) => {
reply.removeHeader('X-Frame-Options');
const user = await this.usersRepository.findOneBy({
id: request.params.user,
});
if (user == null) return;
if (user.host != null) return;
const _user = await this.userEntityService.pack(user);
reply.header('Cache-Control', 'public, max-age=3600');
return await reply.view('base-embed', {
title: this.meta.name ?? 'Misskey',
...await this.generateCommonPugData(this.meta),
embedCtx: htmlSafeJsonStringify({
user: _user,
}),
});
});
fastify.get<{ Params: { note: string; } }>('/embed/notes/:note', async (request, reply) => {
reply.removeHeader('X-Frame-Options');
const note = await this.notesRepository.findOneBy({
id: request.params.note,
});
if (note == null) return;
if (note.visibility !== 'public') return;
if (note.userHost != null) return;
const _note = await this.noteEntityService.pack(note, null, { detail: true });
reply.header('Cache-Control', 'public, max-age=3600');
return await reply.view('base-embed', {
title: this.meta.name ?? 'Misskey',
...await this.generateCommonPugData(this.meta),
embedCtx: htmlSafeJsonStringify({
note: _note,
}),
});
});
fastify.get<{ Params: { clip: string; } }>('/embed/clips/:clip', async (request, reply) => {
reply.removeHeader('X-Frame-Options');
const clip = await this.clipsRepository.findOneBy({
id: request.params.clip,
});
if (clip == null) return;
const _clip = await this.clipEntityService.pack(clip);
reply.header('Cache-Control', 'public, max-age=3600');
return await reply.view('base-embed', {
title: this.meta.name ?? 'Misskey',
...await this.generateCommonPugData(this.meta),
embedCtx: htmlSafeJsonStringify({
clip: _clip,
}),
});
});
fastify.get('/embed/*', async (request, reply) => {
reply.removeHeader('X-Frame-Options');

View File

@@ -43,6 +43,9 @@ html(class='embed')
script(type='application/json' id='misskey_meta' data-generated-at=now)
!= metaJson
script(type='application/json' id='misskey_embedCtx' data-generated-at=now)
!= embedCtx
script
include ../boot.embed.js

View File

@@ -20,16 +20,19 @@ import { serverMetadata } from '@/server-metadata.js';
import { url } from '@@/js/config.js';
import { parseEmbedParams } from '@@/js/embed-page.js';
import { postMessageToParentWindow, setIframeId } from '@/post-message.js';
import { serverContext } from '@/server-context.js';
import type { Theme } from '@/theme.js';
console.log('Misskey Embed');
//#region Embedパラメータの取得・パース
const params = new URLSearchParams(location.search);
const embedParams = parseEmbedParams(params);
if (_DEV_) console.log(embedParams);
//#endregion
//#region テーマ
function parseThemeOrNull(theme: string | null): Theme | null {
if (theme == null) return null;
try {
@@ -65,6 +68,7 @@ if (embedParams.colorMode === 'dark') {
}
});
}
//#endregion
// サイズの制限
document.documentElement.style.maxWidth = '500px';
@@ -89,6 +93,10 @@ const app = createApp(
app.provide(DI.mediaProxy, new MediaProxy(serverMetadata, url));
app.provide(DI.serverMetadata, serverMetadata);
app.provide(DI.serverContext, serverContext);
app.provide(DI.embedParams, embedParams);
// https://github.com/misskey-dev/misskey/pull/8575#issuecomment-1114239210

View File

@@ -142,8 +142,8 @@ import EmAcct from '@/components/EmAcct.vue';
import { userPage } from '@/utils.js';
import { notePage } from '@/utils.js';
import { i18n } from '@/i18n.js';
import { DI } from '@/di.js';
import { shouldCollapsed } from '@@/js/collapsed.js';
import { serverMetadata } from '@/server-metadata.js';
import { url } from '@@/js/config.js';
import EmMfm from '@/components/EmMfm.js';
@@ -151,6 +151,8 @@ const props = defineProps<{
note: Misskey.entities.Note;
}>();
const serverMetadata = inject(DI.serverMetadata)!;
const inChannel = inject('inChannel', null);
const note = ref(props.note);

View File

@@ -20,12 +20,12 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { shallowRef } from 'vue';
import { useTemplateRef } from 'vue';
import EmNote from '@/components/EmNote.vue';
import EmPagination, { Paging } from '@/components/EmPagination.vue';
import { i18n } from '@/i18n.js';
const props = withDefaults(defineProps<{
withDefaults(defineProps<{
pagination: Paging;
noGap?: boolean;
disableAutoLoad?: boolean;
@@ -34,7 +34,7 @@ const props = withDefaults(defineProps<{
ad: true,
});
const pagingComponent = shallowRef<InstanceType<typeof EmPagination>>();
const pagingComponent = useTemplateRef('pagingComponent');
defineExpose({
pagingComponent,

View File

@@ -7,9 +7,11 @@ import type { InjectionKey } from 'vue';
import * as Misskey from 'misskey-js';
import { MediaProxy } from '@@/js/media-proxy.js';
import type { ParsedEmbedParams } from '@@/js/embed-page.js';
import type { ServerContext } from '@/server-context.js';
export const DI = {
serverMetadata: Symbol() as InjectionKey<Misskey.entities.MetaDetailed>,
embedParams: Symbol() as InjectionKey<ParsedEmbedParams>,
serverContext: Symbol() as InjectionKey<ServerContext>,
mediaProxy: Symbol() as InjectionKey<MediaProxy>,
};

View File

@@ -5,8 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div>
<EmLoading v-if="loading"/>
<EmTimelineContainer v-else-if="clip" :showHeader="embedParams.header">
<EmTimelineContainer v-if="clip" :showHeader="embedParams.header">
<template #header>
<div :class="$style.clipHeader">
<div :class="$style.headerClipIconRoot">
@@ -39,20 +38,19 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script setup lang="ts">
import { ref, computed, shallowRef, inject } from 'vue';
import { ref, computed, inject, useTemplateRef } from 'vue';
import * as Misskey from 'misskey-js';
import { scrollToTop } from '@@/js/scroll.js';
import { url, instanceName } from '@@/js/config.js';
import { isLink } from '@@/js/is-link.js';
import { defaultEmbedParams } from '@@/js/embed-page.js';
import type { Paging } from '@/components/EmPagination.vue';
import EmLoading from '@/components/EmLoading.vue';
import EmNotes from '@/components/EmNotes.vue';
import XNotFound from '@/pages/not-found.vue';
import EmTimelineContainer from '@/components/EmTimelineContainer.vue';
import { misskeyApi } from '@/misskey-api.js';
import { i18n } from '@/i18n.js';
import { serverMetadata } from '@/server-metadata.js';
import { url, instanceName } from '@@/js/config.js';
import { isLink } from '@@/js/is-link.js';
import { defaultEmbedParams } from '@@/js/embed-page.js';
import { assertServerContext } from '@/server-context.js';
import { DI } from '@/di.js';
const props = defineProps<{
@@ -61,16 +59,30 @@ const props = defineProps<{
const embedParams = inject(DI.embedParams, defaultEmbedParams);
const clip = ref<Misskey.entities.Clip | null>(null);
const serverMetadata = inject(DI.serverMetadata)!;
const serverContext = inject(DI.serverContext)!;
const clip = ref<Misskey.entities.Clip | null>();
if (assertServerContext(serverContext, 'clip')) {
clip.value = serverContext.clip;
} else {
clip.value = await misskeyApi('clips/show', {
clipId: props.clipId,
}).catch(() => {
return null;
});
}
const pagination = computed(() => ({
endpoint: 'clips/notes',
params: {
clipId: props.clipId,
},
} as Paging));
const loading = ref(true);
const notesEl = shallowRef<InstanceType<typeof EmNotes> | null>(null);
const notesEl = useTemplateRef('notesEl');
function top(ev: MouseEvent) {
const target = ev.target as HTMLElement | null;
@@ -80,16 +92,6 @@ function top(ev: MouseEvent) {
scrollToTop(notesEl.value.$el as HTMLElement, { behavior: 'smooth' });
}
}
misskeyApi('clips/show', {
clipId: props.clipId,
}).then(res => {
clip.value = res;
loading.value = false;
}).catch(err => {
console.error(err);
loading.value = false;
});
</script>
<style lang="scss" module>

View File

@@ -5,40 +5,37 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div :class="$style.noteEmbedRoot">
<EmLoading v-if="loading"/>
<EmNoteDetailed v-else-if="note" :note="note"/>
<EmNoteDetailed v-if="note" :note="note"/>
<XNotFound v-else/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { inject, ref } from 'vue';
import * as Misskey from 'misskey-js';
import EmNoteDetailed from '@/components/EmNoteDetailed.vue';
import EmLoading from '@/components/EmLoading.vue';
import XNotFound from '@/pages/not-found.vue';
import { DI } from '@/di.js';
import { misskeyApi } from '@/misskey-api.js';
import { assertServerContext } from '@/server-context';
const props = defineProps<{
noteId: string;
}>();
const note = ref<Misskey.entities.Note | null>(null);
const loading = ref(true);
const serverContext = inject(DI.serverContext)!;
// TODO: クライアント側でAPIを叩くのは二度手間なので予めHTMLに埋め込んでおく
misskeyApi('notes/show', {
noteId: props.noteId,
}).then(res => {
// リモートのノートは埋め込ませない
if (res.url == null && res.uri == null) {
note.value = res;
}
loading.value = false;
}).catch(err => {
console.error(err);
loading.value = false;
});
const note = ref<Misskey.entities.Note | null>(null);
if (assertServerContext(serverContext, 'note')) {
note.value = serverContext.note;
} else {
note.value = await misskeyApi('notes/show', {
noteId: props.noteId,
}).catch(() => {
return null;
});
}
</script>
<style lang="scss" module>

View File

@@ -38,14 +38,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script setup lang="ts">
import { computed, shallowRef, inject } from 'vue';
import { computed, inject, useTemplateRef } from 'vue';
import { scrollToTop } from '@@/js/scroll.js';
import type { Paging } from '@/components/EmPagination.vue';
import EmNotes from '@/components/EmNotes.vue';
import XNotFound from '@/pages/not-found.vue';
import EmTimelineContainer from '@/components/EmTimelineContainer.vue';
import { i18n } from '@/i18n.js';
import { serverMetadata } from '@/server-metadata.js';
import { url, instanceName } from '@@/js/config.js';
import { isLink } from '@@/js/is-link.js';
import { DI } from '@/di.js';
@@ -55,6 +54,8 @@ const props = defineProps<{
tag: string;
}>();
const serverMetadata = inject(DI.serverMetadata)!;
const embedParams = inject(DI.embedParams, defaultEmbedParams);
const pagination = computed(() => ({
@@ -64,7 +65,7 @@ const pagination = computed(() => ({
},
} as Paging));
const notesEl = shallowRef<InstanceType<typeof EmNotes> | null>(null);
const notesEl = useTemplateRef('notesEl');
function top(ev: MouseEvent) {
const target = ev.target as HTMLElement | null;

View File

@@ -5,8 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div>
<EmLoading v-if="loading"/>
<EmTimelineContainer v-else-if="user" :showHeader="embedParams.header">
<EmTimelineContainer v-if="user && !prohibited" :showHeader="embedParams.header">
<template #header>
<div :class="$style.userHeader">
<a :href="`/@${user.username}`" target="_blank" rel="noopener noreferrer" :class="$style.avatarLink">
@@ -46,21 +45,20 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script setup lang="ts">
import { ref, computed, shallowRef, inject } from 'vue';
import { ref, computed, inject, useTemplateRef } from 'vue';
import * as Misskey from 'misskey-js';
import { url, instanceName } from '@@/js/config.js';
import { defaultEmbedParams } from '@@/js/embed-page.js';
import type { Paging } from '@/components/EmPagination.vue';
import EmNotes from '@/components/EmNotes.vue';
import EmAvatar from '@/components/EmAvatar.vue';
import EmLoading from '@/components/EmLoading.vue';
import EmUserName from '@/components/EmUserName.vue';
import I18n from '@/components/I18n.vue';
import XNotFound from '@/pages/not-found.vue';
import EmTimelineContainer from '@/components/EmTimelineContainer.vue';
import { misskeyApi } from '@/misskey-api.js';
import { i18n } from '@/i18n.js';
import { serverMetadata } from '@/server-metadata.js';
import { url, instanceName } from '@@/js/config.js';
import { defaultEmbedParams } from '@@/js/embed-page.js';
import { assertServerContext } from '@/server-context.js';
import { DI } from '@/di.js';
const props = defineProps<{
@@ -69,26 +67,37 @@ const props = defineProps<{
const embedParams = inject(DI.embedParams, defaultEmbedParams);
const user = ref<Misskey.entities.UserLite | null>(null);
const serverMetadata = inject(DI.serverMetadata)!;
const serverContext = inject(DI.serverContext)!;
const user = ref<Misskey.entities.UserLite | null>();
const prohibited = ref(false);
if (assertServerContext(serverContext, 'user')) {
user.value = serverContext.user;
} else {
user.value = await misskeyApi('users/show', {
userId: props.userId,
}).catch(() => {
return null;
});
}
if (user.value?.host != null) {
// リモートサーバーのユーザーは弾く
prohibited.value = true;
}
const pagination = computed(() => ({
endpoint: 'users/notes',
params: {
userId: user.value?.id,
},
} as Paging));
const loading = ref(true);
const notesEl = shallowRef<InstanceType<typeof EmNotes> | null>(null);
misskeyApi('users/show', {
userId: props.userId,
}).then(res => {
user.value = res;
loading.value = false;
}).catch(err => {
console.error(err);
loading.value = false;
});
const notesEl = useTemplateRef('notesEl');
</script>
<style lang="scss" module>

View File

@@ -0,0 +1,21 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as Misskey from 'misskey-js';
const providedContextEl = document.getElementById('misskey_embedCtx');
export type ServerContext = {
clip?: Misskey.entities.Clip;
note?: Misskey.entities.Note;
user?: Misskey.entities.UserLite;
} | null;
// NOTE: devモードのときしか embedCtx が null になることは無い
export const serverContext: ServerContext = (providedContextEl && providedContextEl.textContent) ? JSON.parse(providedContextEl.textContent) : null;
export function assertServerContext<K extends keyof NonNullable<ServerContext>>(ctx: ServerContext, entity: K): ctx is Required<Pick<NonNullable<ServerContext>, K>> {
if (ctx == null) return false;
return entity in ctx;
}

View File

@@ -18,11 +18,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<div
:class="$style.routerViewContainer"
>
<EmNotePage v-if="page === 'notes'" :noteId="contentId"/>
<EmUserTimelinePage v-else-if="page === 'user-timeline'" :userId="contentId"/>
<EmClipPage v-else-if="page === 'clips'" :clipId="contentId"/>
<EmTagPage v-else-if="page === 'tags'" :tag="contentId"/>
<XNotFound v-else/>
<Suspense :timeout="0">
<EmNotePage v-if="page === 'notes'" :noteId="contentId"/>
<EmUserTimelinePage v-else-if="page === 'user-timeline'" :userId="contentId"/>
<EmClipPage v-else-if="page === 'clips'" :clipId="contentId"/>
<EmTagPage v-else-if="page === 'tags'" :tag="contentId"/>
<XNotFound v-else/>
<template #fallback>
<EmLoading/>
</template>
</Suspense>
</div>
</div>
</template>
@@ -37,6 +42,7 @@ import EmUserTimelinePage from '@/pages/user-timeline.vue';
import EmClipPage from '@/pages/clip.vue';
import EmTagPage from '@/pages/tag.vue';
import XNotFound from '@/pages/not-found.vue';
import EmLoading from '@/components/EmLoading.vue';
const page = location.pathname.split('/')[2];
const contentId = location.pathname.split('/')[3];

View File

@@ -8,7 +8,7 @@ import * as Misskey from 'misskey-js';
import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js';
import { i18n } from '@/i18n.js';
import { miLocalStorage } from '@/local-storage.js';
import { MenuButton } from '@/types/menu.js';
import type { MenuItem, MenuButton } from '@/types/menu.js';
import { del, get, set } from '@/scripts/idb-proxy.js';
import { apiUrl } from '@@/js/config.js';
import { waiting, popup, popupMenu, success, alert } from '@/os.js';
@@ -288,14 +288,26 @@ export async function openAccountMenu(opts: {
});
}));
const menuItems: MenuItem[] = [];
if (opts.withExtraOperation) {
popupMenu([...[{
type: 'link' as const,
menuItems.push({
type: 'link',
text: i18n.ts.profile,
to: `/@${ $i.username }`,
to: `/@${$i.username}`,
avatar: $i,
}, { type: 'divider' as const }, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, {
type: 'parent' as const,
}, {
type: 'divider',
});
if (opts.includeCurrentAccount) {
menuItems.push(createItem($i));
}
menuItems.push(...accountItemPromises);
menuItems.push({
type: 'parent',
icon: 'ti ti-plus',
text: i18n.ts.addAccount,
children: [{
@@ -306,18 +318,22 @@ export async function openAccountMenu(opts: {
action: () => { createAccount(); },
}],
}, {
type: 'link' as const,
type: 'link',
icon: 'ti ti-users',
text: i18n.ts.manageAccounts,
to: '/settings/accounts',
}]], ev.currentTarget ?? ev.target, {
align: 'left',
});
} else {
popupMenu([...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises], ev.currentTarget ?? ev.target, {
align: 'left',
});
if (opts.includeCurrentAccount) {
menuItems.push(createItem($i));
}
menuItems.push(...accountItemPromises);
}
popupMenu(menuItems, ev.currentTarget ?? ev.target, {
align: 'left',
});
}
if (_DEV_) {

View File

@@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { onMounted, onBeforeUnmount, shallowRef, ref } from 'vue';
import MkMenu from './MkMenu.vue';
import { MenuItem } from '@/types/menu.js';
import type { MenuItem } from '@/types/menu.js';
import contains from '@/scripts/contains.js';
import { defaultStore } from '@/store.js';
import * as os from '@/os.js';

View File

@@ -42,7 +42,7 @@ import { i18n } from '@/i18n.js';
import { defaultStore } from '@/store.js';
import { claimAchievement } from '@/scripts/achievements.js';
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { MenuItem } from '@/types/menu.js';
import type { MenuItem } from '@/types/menu.js';
const props = withDefaults(defineProps<{
folder: Misskey.entities.DriveFolder;

View File

@@ -620,7 +620,9 @@ function fetchMoreFiles() {
}
function getMenu() {
const menu: MenuItem[] = [{
const menu: MenuItem[] = [];
menu.push({
type: 'switch',
text: i18n.ts.keepOriginalUploading,
ref: keepOriginal,
@@ -638,19 +640,25 @@ function getMenu() {
}, { type: 'divider' }, {
text: folder.value ? folder.value.name : i18n.ts.drive,
type: 'label',
}, folder.value ? {
text: i18n.ts.renameFolder,
icon: 'ti ti-forms',
action: () => { if (folder.value) renameFolder(folder.value); },
} : undefined, folder.value ? {
text: i18n.ts.deleteFolder,
icon: 'ti ti-trash',
action: () => { deleteFolder(folder.value as Misskey.entities.DriveFolder); },
} : undefined, {
});
if (folder.value) {
menu.push({
text: i18n.ts.renameFolder,
icon: 'ti ti-forms',
action: () => { if (folder.value) renameFolder(folder.value); },
}, {
text: i18n.ts.deleteFolder,
icon: 'ti ti-trash',
action: () => { deleteFolder(folder.value as Misskey.entities.DriveFolder); },
});
}
menu.push({
text: i18n.ts.createFolder,
icon: 'ti ti-folder-plus',
action: () => { createFolder(); },
}];
});
return menu;
}

View File

@@ -90,6 +90,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<script setup lang="ts">
import { shallowRef, ref, computed, nextTick, onMounted, onDeactivated, onUnmounted } from 'vue';
import { url } from '@@/js/config.js';
import { embedRouteWithScrollbar } from '@@/js/embed-page.js';
import type { EmbeddableEntity, EmbedParams } from '@@/js/embed-page.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
@@ -103,10 +105,8 @@ import MkInfo from '@/components/MkInfo.vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { url } from '@@/js/config.js';
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { normalizeEmbedParams, getEmbedCode } from '@/scripts/get-embed-code.js';
import { embedRouteWithScrollbar } from '@@/js/embed-page.js';
const emit = defineEmits<{
(ev: 'ok'): void;
@@ -307,6 +307,8 @@ onUnmounted(() => {
.embedCodeGenPreviewRoot {
position: relative;
background-color: var(--bg);
background-size: auto auto;
background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--panel) 6px, var(--panel) 12px);
cursor: not-allowed;
}

View File

@@ -237,6 +237,8 @@ onMounted(() => {
background: var(--acrylicBg);
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));
background-size: auto auto;
background-image: repeating-linear-gradient(135deg, transparent, transparent 5px, var(--panel) 5px, var(--panel) 10px);
border-radius: 0 0 6px 6px;
}
</style>

View File

@@ -0,0 +1,49 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.root">
<div :class="$style.text">{{ i18n.tsx.thereAreNChanges({ n: form.modifiedCount.value }) }}</div>
<div style="margin-left: auto;" class="_buttons">
<MkButton danger rounded @click="form.discard"><i class="ti ti-x"></i> {{ i18n.ts.discard }}</MkButton>
<MkButton primary rounded @click="form.save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
</div>
</div>
</template>
<script lang="ts" setup>
import { } from 'vue';
import MkButton from './MkButton.vue';
import { i18n } from '@/i18n.js';
const props = defineProps<{
form: {
modifiedCount: {
value: number;
};
discard: () => void;
save: () => void;
};
}>();
</script>
<style lang="scss" module>
.root {
display: flex;
align-items: center;
}
.text {
color: var(--warn);
font-size: 90%;
animation: modified-blink 2s infinite;
}
@keyframes modified-blink {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
</style>

View File

@@ -172,9 +172,7 @@ async function show() {
const menuShowing = ref(false);
function showMenu(ev: MouseEvent) {
let menu: MenuItem[] = [];
menu = [
const menu: MenuItem[] = [
// TODO: 再生キューに追加
{
type: 'switch',
@@ -222,7 +220,7 @@ function showMenu(ev: MouseEvent) {
menu.push({
type: 'divider',
}, {
type: 'link' as const,
type: 'link',
text: i18n.ts._fileViewer.title,
icon: 'ti ti-info-circle',
to: `/my/drive/file/${props.audio.id}`,

View File

@@ -60,6 +60,7 @@ import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { $i, iAmModerator } from '@/account.js';
import type { MenuItem } from '@/types/menu.js';
const props = withDefaults(defineProps<{
image: Misskey.entities.DriveFile;
@@ -111,27 +112,39 @@ watch(() => props.image, () => {
});
function showMenu(ev: MouseEvent) {
os.popupMenu([{
const menuItems: MenuItem[] = [];
menuItems.push({
text: i18n.ts.hide,
icon: 'ti ti-eye-off',
action: () => {
hide.value = true;
},
}, ...(iAmModerator ? [{
text: i18n.ts.markAsSensitive,
icon: 'ti ti-eye-exclamation',
danger: true,
action: () => {
os.apiWithDialog('drive/files/update', { fileId: props.image.id, isSensitive: true });
},
}] : []), ...($i?.id === props.image.userId ? [{
type: 'divider' as const,
}, {
type: 'link' as const,
text: i18n.ts._fileViewer.title,
icon: 'ti ti-info-circle',
to: `/my/drive/file/${props.image.id}`,
}] : [])], ev.currentTarget ?? ev.target);
});
if (iAmModerator) {
menuItems.push({
text: i18n.ts.markAsSensitive,
icon: 'ti ti-eye-exclamation',
danger: true,
action: () => {
os.apiWithDialog('drive/files/update', { fileId: props.image.id, isSensitive: true });
},
});
}
if ($i?.id === props.image.userId) {
menuItems.push({
type: 'divider',
}, {
type: 'link',
text: i18n.ts._fileViewer.title,
icon: 'ti ti-info-circle',
to: `/my/drive/file/${props.image.id}`,
});
}
os.popupMenu(menuItems, ev.currentTarget ?? ev.target);
}
</script>

View File

@@ -192,9 +192,7 @@ async function show() {
const menuShowing = ref(false);
function showMenu(ev: MouseEvent) {
let menu: MenuItem[] = [];
menu = [
const menu: MenuItem[] = [
// TODO: 再生キューに追加
{
type: 'switch',
@@ -247,7 +245,7 @@ function showMenu(ev: MouseEvent) {
menu.push({
type: 'divider',
}, {
type: 'link' as const,
type: 'link',
text: i18n.ts._fileViewer.title,
icon: 'ti ti-info-circle',
to: `/my/drive/file/${props.video.id}`,

View File

@@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { nextTick, onMounted, onUnmounted, provide, shallowRef, watch } from 'vue';
import MkMenu from './MkMenu.vue';
import { MenuItem } from '@/types/menu.js';
import type { MenuItem } from '@/types/menu.js';
const props = defineProps<{
items: MenuItem[];

View File

@@ -193,7 +193,7 @@ import { deepClone } from '@/scripts/clone.js';
import { useTooltip } from '@/scripts/use-tooltip.js';
import { claimAchievement } from '@/scripts/achievements.js';
import { getNoteSummary } from '@/scripts/get-note-summary.js';
import { MenuItem } from '@/types/menu.js';
import type { MenuItem } from '@/types/menu.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
import { shouldCollapsed } from '@@/js/collapsed.js';

View File

@@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { ref, shallowRef } from 'vue';
import MkModal from './MkModal.vue';
import MkMenu from './MkMenu.vue';
import { MenuItem } from '@/types/menu.js';
import type { MenuItem } from '@/types/menu.js';
defineProps<{
items: MenuItem[];

View File

@@ -26,6 +26,7 @@ import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
import type { MenuItem } from '@/types/menu.js';
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
@@ -136,7 +137,10 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent): void {
if (menuShowing) return;
const isImage = file.type.startsWith('image/');
os.popupMenu([{
const menuItems: MenuItem[] = [];
menuItems.push({
text: i18n.ts.renameFile,
icon: 'ti ti-forms',
action: () => { rename(file); },
@@ -148,11 +152,17 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent): void {
text: i18n.ts.describeFile,
icon: 'ti ti-text-caption',
action: () => { describe(file); },
}, ...isImage ? [{
text: i18n.ts.cropImage,
icon: 'ti ti-crop',
action: () : void => { crop(file); },
}] : [], {
});
if (isImage) {
menuItems.push({
text: i18n.ts.cropImage,
icon: 'ti ti-crop',
action: () : void => { crop(file); },
});
}
menuItems.push({
type: 'divider',
}, {
text: i18n.ts.attachCancel,
@@ -163,7 +173,9 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent): void {
icon: 'ti ti-trash',
danger: true,
action: () => { detachAndDeleteMedia(file); },
}], ev.currentTarget ?? ev.target).then(() => menuShowing = false);
});
os.popupMenu(menuItems, ev.currentTarget ?? ev.target).then(() => menuShowing = false);
menuShowing = true;
}
</script>

View File

@@ -5,7 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div class="timctyfi" :class="{ disabled, easing }">
<div class="label"><slot name="label"></slot></div>
<div class="label">
<slot name="label"></slot>
</div>
<div v-adaptive-border class="body">
<div ref="containerEl" class="container">
<div class="track">
@@ -14,15 +16,25 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="steps && showTicks" class="ticks">
<div v-for="i in (steps + 1)" class="tick" :style="{ left: (((i - 1) / steps) * 100) + '%' }"></div>
</div>
<div ref="thumbEl" v-tooltip="textConverter(finalValue)" class="thumb" :style="{ left: thumbPosition + 'px' }" @mousedown="onMousedown" @touchstart="onMousedown"></div>
<div
ref="thumbEl"
class="thumb"
:style="{ left: thumbPosition + 'px' }"
@mouseenter.passive="onMouseenter"
@mousedown="onMousedown"
@touchstart="onMousedown"
></div>
</div>
</div>
<div class="caption"><slot name="caption"></slot></div>
<div class="caption">
<slot name="caption"></slot>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, watch, shallowRef } from 'vue';
import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue';
import { isTouchUsing } from '@/scripts/touch.js';
import * as os from '@/os.js';
const props = withDefaults(defineProps<{
@@ -101,12 +113,36 @@ const steps = computed(() => {
}
});
const tooltipForDragShowing = ref(false);
const tooltipForHoverShowing = ref(false);
function onMouseenter() {
if (isTouchUsing) return;
tooltipForHoverShowing.value = true;
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTooltip.vue')), {
showing: computed(() => tooltipForHoverShowing.value && !tooltipForDragShowing.value),
text: computed(() => {
return props.textConverter(finalValue.value);
}),
targetElement: thumbEl,
}, {
closed: () => dispose(),
});
thumbEl.value!.addEventListener('mouseleave', () => {
tooltipForHoverShowing.value = false;
}, { once: true, passive: true });
}
function onMousedown(ev: MouseEvent | TouchEvent) {
ev.preventDefault();
const tooltipShowing = ref(true);
tooltipForDragShowing.value = true;
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTooltip.vue')), {
showing: tooltipShowing,
showing: tooltipForDragShowing,
text: computed(() => {
return props.textConverter(finalValue.value);
}),
@@ -137,7 +173,7 @@ function onMousedown(ev: MouseEvent | TouchEvent) {
const onMouseup = () => {
document.head.removeChild(style);
tooltipShowing.value = false;
tooltipForDragShowing.value = false;
window.removeEventListener('mousemove', onDrag);
window.removeEventListener('touchmove', onDrag);
window.removeEventListener('mouseup', onMouseup);
@@ -261,12 +297,12 @@ function onMousedown(ev: MouseEvent | TouchEvent) {
> .container {
> .track {
> .highlight {
transition: width 0.2s cubic-bezier(0,0,0,1);
transition: width 0.2s cubic-bezier(0, 0, 0, 1);
}
}
> .thumb {
transition: left 0.2s cubic-bezier(0,0,0,1);
transition: left 0.2s cubic-bezier(0, 0, 0, 1);
}
}
}

View File

@@ -46,7 +46,7 @@ import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js';
import { useInterval } from '@@/js/use-interval.js';
import { i18n } from '@/i18n.js';
import { MenuItem } from '@/types/menu.js';
import type { MenuItem } from '@/types/menu.js';
const props = defineProps<{
modelValue: string | null;

View File

@@ -56,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { onBeforeUnmount, onMounted, provide, shallowRef, ref } from 'vue';
import contains from '@/scripts/contains.js';
import * as os from '@/os.js';
import { MenuItem } from '@/types/menu.js';
import type { MenuItem } from '@/types/menu.js';
import { i18n } from '@/i18n.js';
import { defaultStore } from '@/store.js';

View File

@@ -35,6 +35,7 @@ import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import * as sound from '@/scripts/sound.js';
import { i18n } from '@/i18n.js';
import MkCustomEmojiDetailedDialog from '@/components/MkCustomEmojiDetailedDialog.vue';
import type { MenuItem } from '@/types/menu.js';
const props = defineProps<{
name: string;
@@ -85,7 +86,9 @@ const errored = ref(url.value == null);
function onClick(ev: MouseEvent) {
if (props.menu) {
os.popupMenu([{
const menuItems: MenuItem[] = [];
menuItems.push({
type: 'label',
text: `:${props.name}:`,
}, {
@@ -95,14 +98,20 @@ function onClick(ev: MouseEvent) {
copyToClipboard(`:${props.name}:`);
os.success();
},
}, ...(props.menuReaction && react ? [{
text: i18n.ts.doReaction,
icon: 'ti ti-plus',
action: () => {
react(`:${props.name}:`);
sound.playMisskeySfx('reaction');
},
}] : []), {
});
if (props.menuReaction && react) {
menuItems.push({
text: i18n.ts.doReaction,
icon: 'ti ti-plus',
action: () => {
react(`:${props.name}:`);
sound.playMisskeySfx('reaction');
},
});
}
menuItems.push({
text: i18n.ts.info,
icon: 'ti ti-info-circle',
action: async () => {
@@ -114,7 +123,9 @@ function onClick(ev: MouseEvent) {
closed: () => dispose(),
});
},
}], ev.currentTarget ?? ev.target);
});
os.popupMenu(menuItems, ev.currentTarget ?? ev.target);
}
}
</script>

View File

@@ -17,6 +17,7 @@ import * as os from '@/os.js';
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import * as sound from '@/scripts/sound.js';
import { i18n } from '@/i18n.js';
import type { MenuItem } from '@/types/menu.js';
const props = defineProps<{
emoji: string;
@@ -39,7 +40,9 @@ function computeTitle(event: PointerEvent): void {
function onClick(ev: MouseEvent) {
if (props.menu) {
os.popupMenu([{
const menuItems: MenuItem[] = [];
menuItems.push({
type: 'label',
text: props.emoji,
}, {
@@ -49,14 +52,20 @@ function onClick(ev: MouseEvent) {
copyToClipboard(props.emoji);
os.success();
},
}, ...(props.menuReaction && react ? [{
text: i18n.ts.doReaction,
icon: 'ti ti-plus',
action: () => {
react(props.emoji);
sound.playMisskeySfx('reaction');
},
}] : [])], ev.currentTarget ?? ev.target);
});
if (props.menuReaction && react) {
menuItems.push({
text: i18n.ts.doReaction,
icon: 'ti ti-plus',
action: () => {
react(props.emoji);
sound.playMisskeySfx('reaction');
},
});
}
os.popupMenu(menuItems, ev.currentTarget ?? ev.target);
}
}
</script>

View File

@@ -125,7 +125,7 @@ export const navbarItemDef = reactive({
ui: {
title: i18n.ts.switchUi,
icon: 'ti ti-devices',
action: (ev) => {
action: (ev: MouseEvent) => {
os.popupMenu([{
text: i18n.ts.default,
active: ui === 'default' || ui === null,

View File

@@ -22,7 +22,7 @@ import MkPasswordDialog from '@/components/MkPasswordDialog.vue';
import MkEmojiPickerDialog from '@/components/MkEmojiPickerDialog.vue';
import MkPopupMenu from '@/components/MkPopupMenu.vue';
import MkContextMenu from '@/components/MkContextMenu.vue';
import { MenuItem } from '@/types/menu.js';
import type { MenuItem } from '@/types/menu.js';
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { pleaseLogin } from '@/scripts/please-login.js';
import { showMovedDialog } from '@/scripts/show-moved-dialog.js';

View File

@@ -4,145 +4,143 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div>
<FormSuspense :p="init">
<div class="_gaps_m">
<MkRadios v-model="provider">
<option :value="null">{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</option>
<option value="hcaptcha">hCaptcha</option>
<option value="mcaptcha">mCaptcha</option>
<option value="recaptcha">reCAPTCHA</option>
<option value="turnstile">Turnstile</option>
</MkRadios>
<MkFolder>
<template #icon><i class="ti ti-shield"></i></template>
<template #label>{{ i18n.ts.botProtection }}</template>
<template v-if="botProtectionForm.savedState.provider === 'hcaptcha'" #suffix>hCaptcha</template>
<template v-else-if="botProtectionForm.savedState.provider === 'mcaptcha'" #suffix>mCaptcha</template>
<template v-else-if="botProtectionForm.savedState.provider === 'recaptcha'" #suffix>reCAPTCHA</template>
<template v-else-if="botProtectionForm.savedState.provider === 'turnstile'" #suffix>Turnstile</template>
<template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template>
<template v-if="botProtectionForm.modified.value" #footer>
<MkFormFooter :form="botProtectionForm"/>
</template>
<template v-if="provider === 'hcaptcha'">
<MkInput v-model="hcaptchaSiteKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.hcaptchaSiteKey }}</template>
</MkInput>
<MkInput v-model="hcaptchaSecretKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.hcaptchaSecretKey }}</template>
</MkInput>
<FormSlot>
<template #label>{{ i18n.ts.preview }}</template>
<MkCaptcha provider="hcaptcha" :sitekey="hcaptchaSiteKey || '10000000-ffff-ffff-ffff-000000000001'"/>
</FormSlot>
</template>
<template v-else-if="provider === 'mcaptcha'">
<MkInput v-model="mcaptchaSiteKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.mcaptchaSiteKey }}</template>
</MkInput>
<MkInput v-model="mcaptchaSecretKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.mcaptchaSecretKey }}</template>
</MkInput>
<MkInput v-model="mcaptchaInstanceUrl">
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts.mcaptchaInstanceUrl }}</template>
</MkInput>
<FormSlot v-if="mcaptchaSiteKey && mcaptchaInstanceUrl">
<template #label>{{ i18n.ts.preview }}</template>
<MkCaptcha provider="mcaptcha" :sitekey="mcaptchaSiteKey" :instanceUrl="mcaptchaInstanceUrl"/>
</FormSlot>
</template>
<template v-else-if="provider === 'recaptcha'">
<MkInput v-model="recaptchaSiteKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.recaptchaSiteKey }}</template>
</MkInput>
<MkInput v-model="recaptchaSecretKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.recaptchaSecretKey }}</template>
</MkInput>
<FormSlot v-if="recaptchaSiteKey">
<template #label>{{ i18n.ts.preview }}</template>
<MkCaptcha provider="recaptcha" :sitekey="recaptchaSiteKey"/>
</FormSlot>
</template>
<template v-else-if="provider === 'turnstile'">
<MkInput v-model="turnstileSiteKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.turnstileSiteKey }}</template>
</MkInput>
<MkInput v-model="turnstileSecretKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.turnstileSecretKey }}</template>
</MkInput>
<FormSlot>
<template #label>{{ i18n.ts.preview }}</template>
<MkCaptcha provider="turnstile" :sitekey="turnstileSiteKey || '1x00000000000000000000AA'"/>
</FormSlot>
</template>
<div class="_gaps_m">
<MkRadios v-model="botProtectionForm.state.provider">
<option :value="null">{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</option>
<option value="hcaptcha">hCaptcha</option>
<option value="mcaptcha">mCaptcha</option>
<option value="recaptcha">reCAPTCHA</option>
<option value="turnstile">Turnstile</option>
</MkRadios>
<MkButton primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
</div>
</FormSuspense>
</div>
<template v-if="botProtectionForm.state.provider === 'hcaptcha'">
<MkInput v-model="botProtectionForm.state.hcaptchaSiteKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.hcaptchaSiteKey }}</template>
</MkInput>
<MkInput v-model="botProtectionForm.state.hcaptchaSecretKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.hcaptchaSecretKey }}</template>
</MkInput>
<FormSlot>
<template #label>{{ i18n.ts.preview }}</template>
<MkCaptcha provider="hcaptcha" :sitekey="botProtectionForm.state.hcaptchaSiteKey || '10000000-ffff-ffff-ffff-000000000001'"/>
</FormSlot>
</template>
<template v-else-if="botProtectionForm.state.provider === 'mcaptcha'">
<MkInput v-model="botProtectionForm.state.mcaptchaSiteKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.mcaptchaSiteKey }}</template>
</MkInput>
<MkInput v-model="botProtectionForm.state.mcaptchaSecretKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.mcaptchaSecretKey }}</template>
</MkInput>
<MkInput v-model="botProtectionForm.state.mcaptchaInstanceUrl">
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts.mcaptchaInstanceUrl }}</template>
</MkInput>
<FormSlot v-if="botProtectionForm.state.mcaptchaSiteKey && botProtectionForm.state.mcaptchaInstanceUrl">
<template #label>{{ i18n.ts.preview }}</template>
<MkCaptcha provider="mcaptcha" :sitekey="botProtectionForm.state.mcaptchaSiteKey" :instanceUrl="botProtectionForm.state.mcaptchaInstanceUrl"/>
</FormSlot>
</template>
<template v-else-if="botProtectionForm.state.provider === 'recaptcha'">
<MkInput v-model="botProtectionForm.state.recaptchaSiteKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.recaptchaSiteKey }}</template>
</MkInput>
<MkInput v-model="botProtectionForm.state.recaptchaSecretKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.recaptchaSecretKey }}</template>
</MkInput>
<FormSlot v-if="botProtectionForm.state.recaptchaSiteKey">
<template #label>{{ i18n.ts.preview }}</template>
<MkCaptcha provider="recaptcha" :sitekey="botProtectionForm.state.recaptchaSiteKey"/>
</FormSlot>
</template>
<template v-else-if="botProtectionForm.state.provider === 'turnstile'">
<MkInput v-model="botProtectionForm.state.turnstileSiteKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.turnstileSiteKey }}</template>
</MkInput>
<MkInput v-model="botProtectionForm.state.turnstileSecretKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.turnstileSecretKey }}</template>
</MkInput>
<FormSlot>
<template #label>{{ i18n.ts.preview }}</template>
<MkCaptcha provider="turnstile" :sitekey="botProtectionForm.state.turnstileSiteKey || '1x00000000000000000000AA'"/>
</FormSlot>
</template>
</div>
</MkFolder>
</template>
<script lang="ts" setup>
import { defineAsyncComponent, ref } from 'vue';
import type { CaptchaProvider } from '@/components/MkCaptcha.vue';
import MkRadios from '@/components/MkRadios.vue';
import MkInput from '@/components/MkInput.vue';
import MkButton from '@/components/MkButton.vue';
import FormSuspense from '@/components/form/suspense.vue';
import FormSlot from '@/components/form/slot.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { fetchInstance } from '@/instance.js';
import { i18n } from '@/i18n.js';
import { useForm } from '@/scripts/use-form.js';
import MkFormFooter from '@/components/MkFormFooter.vue';
import MkFolder from '@/components/MkFolder.vue';
const MkCaptcha = defineAsyncComponent(() => import('@/components/MkCaptcha.vue'));
const provider = ref<CaptchaProvider | null>(null);
const hcaptchaSiteKey = ref<string | null>(null);
const hcaptchaSecretKey = ref<string | null>(null);
const mcaptchaSiteKey = ref<string | null>(null);
const mcaptchaSecretKey = ref<string | null>(null);
const mcaptchaInstanceUrl = ref<string | null>(null);
const recaptchaSiteKey = ref<string | null>(null);
const recaptchaSecretKey = ref<string | null>(null);
const turnstileSiteKey = ref<string | null>(null);
const turnstileSecretKey = ref<string | null>(null);
const meta = await misskeyApi('admin/meta');
async function init() {
const meta = await misskeyApi('admin/meta');
hcaptchaSiteKey.value = meta.hcaptchaSiteKey;
hcaptchaSecretKey.value = meta.hcaptchaSecretKey;
mcaptchaSiteKey.value = meta.mcaptchaSiteKey;
mcaptchaSecretKey.value = meta.mcaptchaSecretKey;
mcaptchaInstanceUrl.value = meta.mcaptchaInstanceUrl;
recaptchaSiteKey.value = meta.recaptchaSiteKey;
recaptchaSecretKey.value = meta.recaptchaSecretKey;
turnstileSiteKey.value = meta.turnstileSiteKey;
turnstileSecretKey.value = meta.turnstileSecretKey;
provider.value = meta.enableHcaptcha ? 'hcaptcha' :
meta.enableRecaptcha ? 'recaptcha' :
meta.enableTurnstile ? 'turnstile' :
meta.enableMcaptcha ? 'mcaptcha' : null;
}
function save() {
os.apiWithDialog('admin/update-meta', {
enableHcaptcha: provider.value === 'hcaptcha',
hcaptchaSiteKey: hcaptchaSiteKey.value,
hcaptchaSecretKey: hcaptchaSecretKey.value,
enableMcaptcha: provider.value === 'mcaptcha',
mcaptchaSiteKey: mcaptchaSiteKey.value,
mcaptchaSecretKey: mcaptchaSecretKey.value,
mcaptchaInstanceUrl: mcaptchaInstanceUrl.value,
enableRecaptcha: provider.value === 'recaptcha',
recaptchaSiteKey: recaptchaSiteKey.value,
recaptchaSecretKey: recaptchaSecretKey.value,
enableTurnstile: provider.value === 'turnstile',
turnstileSiteKey: turnstileSiteKey.value,
turnstileSecretKey: turnstileSecretKey.value,
}).then(() => {
fetchInstance(true);
const botProtectionForm = useForm({
provider: meta.enableHcaptcha
? 'hcaptcha'
: meta.enableRecaptcha
? 'recaptcha'
: meta.enableTurnstile
? 'turnstile'
: meta.enableMcaptcha
? 'mcaptcha'
: null,
hcaptchaSiteKey: meta.hcaptchaSiteKey,
hcaptchaSecretKey: meta.hcaptchaSecretKey,
mcaptchaSiteKey: meta.mcaptchaSiteKey,
mcaptchaSecretKey: meta.mcaptchaSecretKey,
mcaptchaInstanceUrl: meta.mcaptchaInstanceUrl,
recaptchaSiteKey: meta.recaptchaSiteKey,
recaptchaSecretKey: meta.recaptchaSecretKey,
turnstileSiteKey: meta.turnstileSiteKey,
turnstileSecretKey: meta.turnstileSecretKey,
}, async (state) => {
await os.apiWithDialog('admin/update-meta', {
enableHcaptcha: state.provider === 'hcaptcha',
hcaptchaSiteKey: state.hcaptchaSiteKey,
hcaptchaSecretKey: state.hcaptchaSecretKey,
enableMcaptcha: state.provider === 'mcaptcha',
mcaptchaSiteKey: state.mcaptchaSiteKey,
mcaptchaSecretKey: state.mcaptchaSecretKey,
mcaptchaInstanceUrl: state.mcaptchaInstanceUrl,
enableRecaptcha: state.provider === 'recaptcha',
recaptchaSiteKey: state.recaptchaSiteKey,
recaptchaSecretKey: state.recaptchaSecretKey,
enableTurnstile: state.provider === 'turnstile',
turnstileSiteKey: state.turnstileSiteKey,
turnstileSecretKey: state.turnstileSecretKey,
});
}
fetchInstance(true);
});
</script>

View File

@@ -7,103 +7,100 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkStickyContainer>
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="700" :marginMin="16" :marginMax="32">
<FormSuspense :p="init">
<div class="_gaps">
<div class="_panel" style="padding: 16px;">
<MkSwitch v-model="enableServerMachineStats" @change="onChange_enableServerMachineStats">
<template #label>{{ i18n.ts.enableServerMachineStats }}</template>
<template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template>
</MkSwitch>
</div>
<div class="_panel" style="padding: 16px;">
<MkSwitch v-model="enableIdenticonGeneration" @change="onChange_enableIdenticonGeneration">
<template #label>{{ i18n.ts.enableIdenticonGeneration }}</template>
<template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template>
</MkSwitch>
</div>
<div class="_panel" style="padding: 16px;">
<MkSwitch v-model="enableChartsForRemoteUser" @change="onChange_enableChartsForRemoteUser">
<template #label>{{ i18n.ts.enableChartsForRemoteUser }}</template>
<template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template>
</MkSwitch>
</div>
<div class="_panel" style="padding: 16px;">
<MkSwitch v-model="enableChartsForFederatedInstances" @change="onChange_enableChartsForFederatedInstances">
<template #label>{{ i18n.ts.enableChartsForFederatedInstances }}</template>
<template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template>
</MkSwitch>
</div>
<MkFolder :defaultOpen="true">
<template #icon><i class="ti ti-bolt"></i></template>
<template #label>Misskey® Fan-out Timeline Technology (FTT)</template>
<template v-if="enableFanoutTimeline" #suffix>Enabled</template>
<template v-else #suffix>Disabled</template>
<template v-if="isFttModified" #footer>
<MkButton primary rounded @click="saveFtt">{{ i18n.ts.save }}</MkButton>
</template>
<div class="_gaps_m">
<MkSwitch v-model="enableFanoutTimeline">
<template #label>{{ i18n.ts.enable }}</template>
<template #caption>
<div>{{ i18n.ts._serverSettings.fanoutTimelineDescription }}</div>
<div><MkLink target="_blank" url="https://misskey-hub.net/docs/for-admin/features/ftt/">{{ i18n.ts.details }}</MkLink></div>
</template>
</MkSwitch>
<MkSwitch v-model="enableFanoutTimelineDbFallback">
<template #label>{{ i18n.ts._serverSettings.fanoutTimelineDbFallback }}</template>
<template #caption>{{ i18n.ts._serverSettings.fanoutTimelineDbFallbackDescription }}</template>
</MkSwitch>
<MkInput v-model="perLocalUserUserTimelineCacheMax" type="number">
<template #label>perLocalUserUserTimelineCacheMax</template>
</MkInput>
<MkInput v-model="perRemoteUserUserTimelineCacheMax" type="number">
<template #label>perRemoteUserUserTimelineCacheMax</template>
</MkInput>
<MkInput v-model="perUserHomeTimelineCacheMax" type="number">
<template #label>perUserHomeTimelineCacheMax</template>
</MkInput>
<MkInput v-model="perUserListTimelineCacheMax" type="number">
<template #label>perUserListTimelineCacheMax</template>
</MkInput>
</div>
</MkFolder>
<MkFolder :defaultOpen="true">
<template #icon><i class="ti ti-bolt"></i></template>
<template #label>Misskey® Reactions Boost Technology (RBT)<span class="_beta">{{ i18n.ts.beta }}</span></template>
<template v-if="enableReactionsBuffering" #suffix>Enabled</template>
<template v-else #suffix>Disabled</template>
<template v-if="isRbtModified" #footer>
<MkButton primary rounded @click="saveRbt">{{ i18n.ts.save }}</MkButton>
</template>
<div class="_gaps_m">
<MkSwitch v-model="enableReactionsBuffering">
<template #label>{{ i18n.ts.enable }}</template>
<template #caption>{{ i18n.ts._serverSettings.reactionsBufferingDescription }}</template>
</MkSwitch>
</div>
</MkFolder>
<div class="_gaps">
<div class="_panel" style="padding: 16px;">
<MkSwitch v-model="enableServerMachineStats" @change="onChange_enableServerMachineStats">
<template #label>{{ i18n.ts.enableServerMachineStats }}</template>
<template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template>
</MkSwitch>
</div>
</FormSuspense>
<div class="_panel" style="padding: 16px;">
<MkSwitch v-model="enableIdenticonGeneration" @change="onChange_enableIdenticonGeneration">
<template #label>{{ i18n.ts.enableIdenticonGeneration }}</template>
<template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template>
</MkSwitch>
</div>
<div class="_panel" style="padding: 16px;">
<MkSwitch v-model="enableChartsForRemoteUser" @change="onChange_enableChartsForRemoteUser">
<template #label>{{ i18n.ts.enableChartsForRemoteUser }}</template>
<template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template>
</MkSwitch>
</div>
<div class="_panel" style="padding: 16px;">
<MkSwitch v-model="enableChartsForFederatedInstances" @change="onChange_enableChartsForFederatedInstances">
<template #label>{{ i18n.ts.enableChartsForFederatedInstances }}</template>
<template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template>
</MkSwitch>
</div>
<MkFolder :defaultOpen="true">
<template #icon><i class="ti ti-bolt"></i></template>
<template #label>Misskey® Fan-out Timeline Technology (FTT)</template>
<template v-if="fttForm.savedState.enableFanoutTimeline" #suffix>Enabled</template>
<template v-else #suffix>Disabled</template>
<template v-if="fttForm.modified.value" #footer>
<MkFormFooter :form="fttForm"/>
</template>
<div class="_gaps_m">
<MkSwitch v-model="fttForm.state.enableFanoutTimeline">
<template #label>{{ i18n.ts.enable }}<span v-if="fttForm.modifiedStates.enableFanoutTimeline" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>
<div>{{ i18n.ts._serverSettings.fanoutTimelineDescription }}</div>
<div><MkLink target="_blank" url="https://misskey-hub.net/docs/for-admin/features/ftt/">{{ i18n.ts.details }}</MkLink></div>
</template>
</MkSwitch>
<MkSwitch v-model="fttForm.state.enableFanoutTimelineDbFallback">
<template #label>{{ i18n.ts._serverSettings.fanoutTimelineDbFallback }}<span v-if="fttForm.modifiedStates.enableFanoutTimelineDbFallback" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts._serverSettings.fanoutTimelineDbFallbackDescription }}</template>
</MkSwitch>
<MkInput v-model="fttForm.state.perLocalUserUserTimelineCacheMax" type="number">
<template #label>perLocalUserUserTimelineCacheMax<span v-if="fttForm.modifiedStates.perLocalUserUserTimelineCacheMax" class="_modified">{{ i18n.ts.modified }}</span></template>
</MkInput>
<MkInput v-model="fttForm.state.perRemoteUserUserTimelineCacheMax" type="number">
<template #label>perRemoteUserUserTimelineCacheMax<span v-if="fttForm.modifiedStates.perRemoteUserUserTimelineCacheMax" class="_modified">{{ i18n.ts.modified }}</span></template>
</MkInput>
<MkInput v-model="fttForm.state.perUserHomeTimelineCacheMax" type="number">
<template #label>perUserHomeTimelineCacheMax<span v-if="fttForm.modifiedStates.perUserHomeTimelineCacheMax" class="_modified">{{ i18n.ts.modified }}</span></template>
</MkInput>
<MkInput v-model="fttForm.state.perUserListTimelineCacheMax" type="number">
<template #label>perUserListTimelineCacheMax<span v-if="fttForm.modifiedStates.perUserListTimelineCacheMax" class="_modified">{{ i18n.ts.modified }}</span></template>
</MkInput>
</div>
</MkFolder>
<MkFolder :defaultOpen="true">
<template #icon><i class="ti ti-bolt"></i></template>
<template #label>Misskey® Reactions Boost Technology (RBT)<span class="_beta">{{ i18n.ts.beta }}</span></template>
<template v-if="rbtForm.savedState.enableReactionsBuffering" #suffix>Enabled</template>
<template v-else #suffix>Disabled</template>
<template v-if="rbtForm.modified.value" #footer>
<MkFormFooter :form="rbtForm"/>
</template>
<div class="_gaps_m">
<MkSwitch v-model="rbtForm.state.enableReactionsBuffering">
<template #label>{{ i18n.ts.enable }}<span v-if="rbtForm.modifiedStates.enableReactionsBuffering" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts._serverSettings.reactionsBufferingDescription }}</template>
</MkSwitch>
</div>
</MkFolder>
</div>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { ref, computed, watch } from 'vue';
import { ref, computed } from 'vue';
import XHeader from './_header_.vue';
import FormSuspense from '@/components/form/suspense.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { fetchInstance } from '@/instance.js';
@@ -114,45 +111,15 @@ import MkFolder from '@/components/MkFolder.vue';
import MkInput from '@/components/MkInput.vue';
import MkLink from '@/components/MkLink.vue';
import MkButton from '@/components/MkButton.vue';
import { useForm } from '@/scripts/use-form.js';
import MkFormFooter from '@/components/MkFormFooter.vue';
const enableServerMachineStats = ref<boolean>(false);
const enableIdenticonGeneration = ref<boolean>(false);
const enableChartsForRemoteUser = ref<boolean>(false);
const enableChartsForFederatedInstances = ref<boolean>(false);
const enableFanoutTimeline = ref<boolean>(false);
const enableFanoutTimelineDbFallback = ref<boolean>(false);
const perLocalUserUserTimelineCacheMax = ref<number>(0);
const perRemoteUserUserTimelineCacheMax = ref<number>(0);
const perUserHomeTimelineCacheMax = ref<number>(0);
const perUserListTimelineCacheMax = ref<number>(0);
const enableReactionsBuffering = ref<boolean>(false);
const meta = await misskeyApi('admin/meta');
const isFttModified = ref<boolean>(false);
const isRbtModified = ref<boolean>(false);
async function init() {
const meta = await misskeyApi('admin/meta');
enableServerMachineStats.value = meta.enableServerMachineStats;
enableIdenticonGeneration.value = meta.enableIdenticonGeneration;
enableChartsForRemoteUser.value = meta.enableChartsForRemoteUser;
enableChartsForFederatedInstances.value = meta.enableChartsForFederatedInstances;
enableFanoutTimeline.value = meta.enableFanoutTimeline;
enableFanoutTimelineDbFallback.value = meta.enableFanoutTimelineDbFallback;
perLocalUserUserTimelineCacheMax.value = meta.perLocalUserUserTimelineCacheMax;
perRemoteUserUserTimelineCacheMax.value = meta.perRemoteUserUserTimelineCacheMax;
perUserHomeTimelineCacheMax.value = meta.perUserHomeTimelineCacheMax;
perUserListTimelineCacheMax.value = meta.perUserListTimelineCacheMax;
enableReactionsBuffering.value = meta.enableReactionsBuffering;
watch([enableFanoutTimeline, enableFanoutTimelineDbFallback, perLocalUserUserTimelineCacheMax, perRemoteUserUserTimelineCacheMax, perUserHomeTimelineCacheMax, perUserListTimelineCacheMax], () => {
isFttModified.value = true;
});
watch(enableReactionsBuffering, () => {
isRbtModified.value = true;
});
}
const enableServerMachineStats = ref(meta.enableServerMachineStats);
const enableIdenticonGeneration = ref(meta.enableIdenticonGeneration);
const enableChartsForRemoteUser = ref(meta.enableChartsForRemoteUser);
const enableChartsForFederatedInstances = ref(meta.enableChartsForFederatedInstances);
function onChange_enableServerMachineStats(value: boolean) {
os.apiWithDialog('admin/update-meta', {
@@ -186,28 +153,33 @@ function onChange_enableChartsForFederatedInstances(value: boolean) {
});
}
function saveFtt() {
os.apiWithDialog('admin/update-meta', {
enableFanoutTimeline: enableFanoutTimeline.value,
enableFanoutTimelineDbFallback: enableFanoutTimelineDbFallback.value,
perLocalUserUserTimelineCacheMax: perLocalUserUserTimelineCacheMax.value,
perRemoteUserUserTimelineCacheMax: perRemoteUserUserTimelineCacheMax.value,
perUserHomeTimelineCacheMax: perUserHomeTimelineCacheMax.value,
perUserListTimelineCacheMax: perUserListTimelineCacheMax.value,
}).then(() => {
isFttModified.value = false;
fetchInstance(true);
const fttForm = useForm({
enableFanoutTimeline: meta.enableFanoutTimeline,
enableFanoutTimelineDbFallback: meta.enableFanoutTimelineDbFallback,
perLocalUserUserTimelineCacheMax: meta.perLocalUserUserTimelineCacheMax,
perRemoteUserUserTimelineCacheMax: meta.perRemoteUserUserTimelineCacheMax,
perUserHomeTimelineCacheMax: meta.perUserHomeTimelineCacheMax,
perUserListTimelineCacheMax: meta.perUserListTimelineCacheMax,
}, async (state) => {
await os.apiWithDialog('admin/update-meta', {
enableFanoutTimeline: state.enableFanoutTimeline,
enableFanoutTimelineDbFallback: state.enableFanoutTimelineDbFallback,
perLocalUserUserTimelineCacheMax: state.perLocalUserUserTimelineCacheMax,
perRemoteUserUserTimelineCacheMax: state.perRemoteUserUserTimelineCacheMax,
perUserHomeTimelineCacheMax: state.perUserHomeTimelineCacheMax,
perUserListTimelineCacheMax: state.perUserListTimelineCacheMax,
});
}
fetchInstance(true);
});
function saveRbt() {
os.apiWithDialog('admin/update-meta', {
enableReactionsBuffering: enableReactionsBuffering.value,
}).then(() => {
isRbtModified.value = false;
fetchInstance(true);
const rbtForm = useForm({
enableReactionsBuffering: meta.enableReactionsBuffering,
}, async (state) => {
await os.apiWithDialog('admin/update-meta', {
enableReactionsBuffering: state.enableReactionsBuffering,
});
}
fetchInstance(true);
});
const headerActions = computed(() => []);

View File

@@ -7,119 +7,115 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkStickyContainer>
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="700" :marginMin="16" :marginMax="32">
<FormSuspense :p="init">
<div class="_gaps_m">
<MkFolder>
<template #icon><i class="ti ti-shield"></i></template>
<template #label>{{ i18n.ts.botProtection }}</template>
<template v-if="enableHcaptcha" #suffix>hCaptcha</template>
<template v-else-if="enableMcaptcha" #suffix>mCaptcha</template>
<template v-else-if="enableRecaptcha" #suffix>reCAPTCHA</template>
<template v-else-if="enableTurnstile" #suffix>Turnstile</template>
<template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template>
<div class="_gaps_m">
<XBotProtection/>
<XBotProtection/>
</MkFolder>
<MkFolder>
<template #icon><i class="ti ti-eye-off"></i></template>
<template #label>{{ i18n.ts.sensitiveMediaDetection }}</template>
<template v-if="sensitiveMediaDetectionForm.savedState.sensitiveMediaDetection === 'all'" #suffix>{{ i18n.ts.all }}</template>
<template v-else-if="sensitiveMediaDetectionForm.savedState.sensitiveMediaDetection === 'local'" #suffix>{{ i18n.ts.localOnly }}</template>
<template v-else-if="sensitiveMediaDetectionForm.savedState.sensitiveMediaDetection === 'remote'" #suffix>{{ i18n.ts.remoteOnly }}</template>
<template v-else #suffix>{{ i18n.ts.none }}</template>
<template v-if="sensitiveMediaDetectionForm.modified.value" #footer>
<MkFormFooter :form="sensitiveMediaDetectionForm"/>
</template>
<MkFolder>
<template #icon><i class="ti ti-eye-off"></i></template>
<template #label>{{ i18n.ts.sensitiveMediaDetection }}</template>
<template v-if="sensitiveMediaDetection === 'all'" #suffix>{{ i18n.ts.all }}</template>
<template v-else-if="sensitiveMediaDetection === 'local'" #suffix>{{ i18n.ts.localOnly }}</template>
<template v-else-if="sensitiveMediaDetection === 'remote'" #suffix>{{ i18n.ts.remoteOnly }}</template>
<template v-else #suffix>{{ i18n.ts.none }}</template>
<div class="_gaps_m">
<span>{{ i18n.ts._sensitiveMediaDetection.description }}</span>
<div class="_gaps_m">
<span>{{ i18n.ts._sensitiveMediaDetection.description }}</span>
<MkRadios v-model="sensitiveMediaDetectionForm.state.sensitiveMediaDetection">
<option value="none">{{ i18n.ts.none }}</option>
<option value="all">{{ i18n.ts.all }}</option>
<option value="local">{{ i18n.ts.localOnly }}</option>
<option value="remote">{{ i18n.ts.remoteOnly }}</option>
</MkRadios>
<MkRadios v-model="sensitiveMediaDetection">
<option value="none">{{ i18n.ts.none }}</option>
<option value="all">{{ i18n.ts.all }}</option>
<option value="local">{{ i18n.ts.localOnly }}</option>
<option value="remote">{{ i18n.ts.remoteOnly }}</option>
</MkRadios>
<MkRange v-model="sensitiveMediaDetectionForm.state.sensitiveMediaDetectionSensitivity" :min="0" :max="4" :step="1" :textConverter="(v) => `${v + 1}`">
<template #label>{{ i18n.ts._sensitiveMediaDetection.sensitivity }}</template>
<template #caption>{{ i18n.ts._sensitiveMediaDetection.sensitivityDescription }}</template>
</MkRange>
<MkRange v-model="sensitiveMediaDetectionSensitivity" :min="0" :max="4" :step="1" :textConverter="(v) => `${v + 1}`">
<template #label>{{ i18n.ts._sensitiveMediaDetection.sensitivity }}</template>
<template #caption>{{ i18n.ts._sensitiveMediaDetection.sensitivityDescription }}</template>
</MkRange>
<MkSwitch v-model="sensitiveMediaDetectionForm.state.enableSensitiveMediaDetectionForVideos">
<template #label>{{ i18n.ts._sensitiveMediaDetection.analyzeVideos }}<span class="_beta">{{ i18n.ts.beta }}</span></template>
<template #caption>{{ i18n.ts._sensitiveMediaDetection.analyzeVideosDescription }}</template>
</MkSwitch>
<MkSwitch v-model="enableSensitiveMediaDetectionForVideos">
<template #label>{{ i18n.ts._sensitiveMediaDetection.analyzeVideos }}<span class="_beta">{{ i18n.ts.beta }}</span></template>
<template #caption>{{ i18n.ts._sensitiveMediaDetection.analyzeVideosDescription }}</template>
</MkSwitch>
<MkSwitch v-model="sensitiveMediaDetectionForm.state.setSensitiveFlagAutomatically">
<template #label>{{ i18n.ts._sensitiveMediaDetection.setSensitiveFlagAutomatically }} ({{ i18n.ts.notRecommended }})</template>
<template #caption>{{ i18n.ts._sensitiveMediaDetection.setSensitiveFlagAutomaticallyDescription }}</template>
</MkSwitch>
<MkSwitch v-model="setSensitiveFlagAutomatically">
<template #label>{{ i18n.ts._sensitiveMediaDetection.setSensitiveFlagAutomatically }} ({{ i18n.ts.notRecommended }})</template>
<template #caption>{{ i18n.ts._sensitiveMediaDetection.setSensitiveFlagAutomaticallyDescription }}</template>
</MkSwitch>
<!-- 現状 false positive が多すぎて実用に耐えない
<MkSwitch v-model="disallowUploadWhenPredictedAsPorn">
<template #label>{{ i18n.ts._sensitiveMediaDetection.disallowUploadWhenPredictedAsPorn }}</template>
</MkSwitch>
-->
</div>
</MkFolder>
<!-- 現状 false positive が多すぎて実用に耐えない
<MkSwitch v-model="disallowUploadWhenPredictedAsPorn">
<template #label>{{ i18n.ts._sensitiveMediaDetection.disallowUploadWhenPredictedAsPorn }}</template>
</MkSwitch>
-->
<MkFolder>
<template #label>Active Email Validation</template>
<template v-if="emailValidationForm.savedState.enableActiveEmailValidation" #suffix>Enabled</template>
<template v-else #suffix>Disabled</template>
<template v-if="emailValidationForm.modified.value" #footer>
<MkFormFooter :form="emailValidationForm"/>
</template>
<MkButton primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
<div class="_gaps_m">
<span>{{ i18n.ts.activeEmailValidationDescription }}</span>
<MkSwitch v-model="emailValidationForm.state.enableActiveEmailValidation">
<template #label>Enable</template>
</MkSwitch>
<MkSwitch v-model="emailValidationForm.state.enableVerifymailApi">
<template #label>Use Verifymail.io API</template>
</MkSwitch>
<MkInput v-model="emailValidationForm.state.verifymailAuthKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>Verifymail.io API Auth Key</template>
</MkInput>
<MkSwitch v-model="emailValidationForm.state.enableTruemailApi">
<template #label>Use TrueMail API</template>
</MkSwitch>
<MkInput v-model="emailValidationForm.state.truemailInstance">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>TrueMail API Instance</template>
</MkInput>
<MkInput v-model="emailValidationForm.state.truemailAuthKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>TrueMail API Auth Key</template>
</MkInput>
</div>
</MkFolder>
<MkFolder>
<template #label>Active Email Validation</template>
<template v-if="enableActiveEmailValidation" #suffix>Enabled</template>
<template v-else #suffix>Disabled</template>
<MkFolder>
<template #label>Banned Email Domains</template>
<template v-if="bannedEmailDomainsForm.modified.value" #footer>
<MkFormFooter :form="bannedEmailDomainsForm"/>
</template>
<div class="_gaps_m">
<span>{{ i18n.ts.activeEmailValidationDescription }}</span>
<MkSwitch v-model="enableActiveEmailValidation">
<template #label>Enable</template>
</MkSwitch>
<MkSwitch v-model="enableVerifymailApi">
<template #label>Use Verifymail.io API</template>
</MkSwitch>
<MkInput v-model="verifymailAuthKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>Verifymail.io API Auth Key</template>
</MkInput>
<MkSwitch v-model="enableTruemailApi">
<template #label>Use TrueMail API</template>
</MkSwitch>
<MkInput v-model="truemailInstance">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>TrueMail API Instance</template>
</MkInput>
<MkInput v-model="truemailAuthKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>TrueMail API Auth Key</template>
</MkInput>
<MkButton primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
<div class="_gaps_m">
<MkTextarea v-model="bannedEmailDomainsForm.state.bannedEmailDomains">
<template #label>Banned Email Domains List</template>
</MkTextarea>
</div>
</MkFolder>
<MkFolder>
<template #label>Banned Email Domains</template>
<MkFolder>
<template #label>Log IP address</template>
<template v-if="ipLoggingForm.savedState.enableIpLogging" #suffix>Enabled</template>
<template v-else #suffix>Disabled</template>
<template v-if="ipLoggingForm.modified.value" #footer>
<MkFormFooter :form="ipLoggingForm"/>
</template>
<div class="_gaps_m">
<MkTextarea v-model="bannedEmailDomains">
<template #label>Banned Email Domains List</template>
</MkTextarea>
<MkButton primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
<MkFolder>
<template #label>Log IP address</template>
<template v-if="enableIpLogging" #suffix>Enabled</template>
<template v-else #suffix>Disabled</template>
<div class="_gaps_m">
<MkSwitch v-model="enableIpLogging" @update:modelValue="save">
<template #label>Enable</template>
</MkSwitch>
</div>
</MkFolder>
</div>
</FormSuspense>
<div class="_gaps_m">
<MkSwitch v-model="ipLoggingForm.state.enableIpLogging">
<template #label>Enable</template>
</MkSwitch>
</div>
</MkFolder>
</div>
</MkSpacer>
</MkStickyContainer>
</template>
@@ -131,83 +127,80 @@ import XHeader from './_header_.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkRadios from '@/components/MkRadios.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import FormSuspense from '@/components/form/suspense.vue';
import MkRange from '@/components/MkRange.vue';
import MkInput from '@/components/MkInput.vue';
import MkButton from '@/components/MkButton.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { fetchInstance } from '@/instance.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { useForm } from '@/scripts/use-form.js';
import MkFormFooter from '@/components/MkFormFooter.vue';
const enableHcaptcha = ref<boolean>(false);
const enableMcaptcha = ref<boolean>(false);
const enableRecaptcha = ref<boolean>(false);
const enableTurnstile = ref<boolean>(false);
const sensitiveMediaDetection = ref<string>('none');
const sensitiveMediaDetectionSensitivity = ref<number>(0);
const setSensitiveFlagAutomatically = ref<boolean>(false);
const enableSensitiveMediaDetectionForVideos = ref<boolean>(false);
const enableIpLogging = ref<boolean>(false);
const enableActiveEmailValidation = ref<boolean>(false);
const enableVerifymailApi = ref<boolean>(false);
const verifymailAuthKey = ref<string | null>(null);
const enableTruemailApi = ref<boolean>(false);
const truemailInstance = ref<string | null>(null);
const truemailAuthKey = ref<string | null>(null);
const bannedEmailDomains = ref<string>('');
const meta = await misskeyApi('admin/meta');
async function init() {
const meta = await misskeyApi('admin/meta');
enableHcaptcha.value = meta.enableHcaptcha;
enableMcaptcha.value = meta.enableMcaptcha;
enableRecaptcha.value = meta.enableRecaptcha;
enableTurnstile.value = meta.enableTurnstile;
sensitiveMediaDetection.value = meta.sensitiveMediaDetection;
sensitiveMediaDetectionSensitivity.value =
meta.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0 :
meta.sensitiveMediaDetectionSensitivity === 'low' ? 1 :
meta.sensitiveMediaDetectionSensitivity === 'medium' ? 2 :
meta.sensitiveMediaDetectionSensitivity === 'high' ? 3 :
meta.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 4 : 0;
setSensitiveFlagAutomatically.value = meta.setSensitiveFlagAutomatically;
enableSensitiveMediaDetectionForVideos.value = meta.enableSensitiveMediaDetectionForVideos;
enableIpLogging.value = meta.enableIpLogging;
enableActiveEmailValidation.value = meta.enableActiveEmailValidation;
enableVerifymailApi.value = meta.enableVerifymailApi;
verifymailAuthKey.value = meta.verifymailAuthKey;
enableTruemailApi.value = meta.enableTruemailApi;
truemailInstance.value = meta.truemailInstance;
truemailAuthKey.value = meta.truemailAuthKey;
bannedEmailDomains.value = meta.bannedEmailDomains?.join('\n') || '';
}
function save() {
os.apiWithDialog('admin/update-meta', {
sensitiveMediaDetection: sensitiveMediaDetection.value,
const sensitiveMediaDetectionForm = useForm({
sensitiveMediaDetection: meta.sensitiveMediaDetection,
sensitiveMediaDetectionSensitivity: meta.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0 :
meta.sensitiveMediaDetectionSensitivity === 'low' ? 1 :
meta.sensitiveMediaDetectionSensitivity === 'medium' ? 2 :
meta.sensitiveMediaDetectionSensitivity === 'high' ? 3 :
meta.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 4 : 0,
setSensitiveFlagAutomatically: meta.setSensitiveFlagAutomatically,
enableSensitiveMediaDetectionForVideos: meta.enableSensitiveMediaDetectionForVideos,
}, async (state) => {
await os.apiWithDialog('admin/update-meta', {
sensitiveMediaDetection: state.sensitiveMediaDetection,
sensitiveMediaDetectionSensitivity:
sensitiveMediaDetectionSensitivity.value === 0 ? 'veryLow' :
sensitiveMediaDetectionSensitivity.value === 1 ? 'low' :
sensitiveMediaDetectionSensitivity.value === 2 ? 'medium' :
sensitiveMediaDetectionSensitivity.value === 3 ? 'high' :
sensitiveMediaDetectionSensitivity.value === 4 ? 'veryHigh' :
state.sensitiveMediaDetectionSensitivity === 0 ? 'veryLow' :
state.sensitiveMediaDetectionSensitivity === 1 ? 'low' :
state.sensitiveMediaDetectionSensitivity === 2 ? 'medium' :
state.sensitiveMediaDetectionSensitivity === 3 ? 'high' :
state.sensitiveMediaDetectionSensitivity === 4 ? 'veryHigh' :
0,
setSensitiveFlagAutomatically: setSensitiveFlagAutomatically.value,
enableSensitiveMediaDetectionForVideos: enableSensitiveMediaDetectionForVideos.value,
enableIpLogging: enableIpLogging.value,
enableActiveEmailValidation: enableActiveEmailValidation.value,
enableVerifymailApi: enableVerifymailApi.value,
verifymailAuthKey: verifymailAuthKey.value,
enableTruemailApi: enableTruemailApi.value,
truemailInstance: truemailInstance.value,
truemailAuthKey: truemailAuthKey.value,
bannedEmailDomains: bannedEmailDomains.value.split('\n'),
}).then(() => {
fetchInstance(true);
setSensitiveFlagAutomatically: state.setSensitiveFlagAutomatically,
enableSensitiveMediaDetectionForVideos: state.enableSensitiveMediaDetectionForVideos,
});
}
fetchInstance(true);
});
const ipLoggingForm = useForm({
enableIpLogging: meta.enableIpLogging,
}, async (state) => {
await os.apiWithDialog('admin/update-meta', {
enableIpLogging: state.enableIpLogging,
});
fetchInstance(true);
});
const emailValidationForm = useForm({
enableActiveEmailValidation: meta.enableActiveEmailValidation,
enableVerifymailApi: meta.enableVerifymailApi,
verifymailAuthKey: meta.verifymailAuthKey,
enableTruemailApi: meta.enableTruemailApi,
truemailInstance: meta.truemailInstance,
truemailAuthKey: meta.truemailAuthKey,
}, async (state) => {
await os.apiWithDialog('admin/update-meta', {
enableActiveEmailValidation: state.enableActiveEmailValidation,
enableVerifymailApi: state.enableVerifymailApi,
verifymailAuthKey: state.verifymailAuthKey,
enableTruemailApi: state.enableTruemailApi,
truemailInstance: state.truemailInstance,
truemailAuthKey: state.truemailAuthKey,
});
fetchInstance(true);
});
const bannedEmailDomainsForm = useForm({
bannedEmailDomains: meta.bannedEmailDomains?.join('\n') || '',
}, async (state) => {
await os.apiWithDialog('admin/update-meta', {
bannedEmailDomains: state.bannedEmailDomains.split('\n'),
});
fetchInstance(true);
});
const headerActions = computed(() => []);

View File

@@ -8,223 +8,221 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkStickyContainer>
<template #header><XHeader :tabs="headerTabs"/></template>
<MkSpacer :contentMax="700" :marginMin="16" :marginMax="32">
<FormSuspense :p="init">
<div class="_gaps_m">
<MkFolder :defaultOpen="true">
<template #icon><i class="ti ti-info-circle"></i></template>
<template #label>{{ i18n.ts.info }}</template>
<template #footer>
<MkButton primary rounded @click="saveInfo">{{ i18n.ts.save }}</MkButton>
</template>
<div class="_gaps_m">
<MkFolder :defaultOpen="true">
<template #icon><i class="ti ti-info-circle"></i></template>
<template #label>{{ i18n.ts.info }}</template>
<template v-if="infoForm.modified.value" #footer>
<MkFormFooter :form="infoForm"/>
</template>
<div class="_gaps">
<MkInput v-model="name">
<template #label>{{ i18n.ts.instanceName }}</template>
</MkInput>
<div class="_gaps">
<MkInput v-model="infoForm.state.name">
<template #label>{{ i18n.ts.instanceName }}<span v-if="infoForm.modifiedStates.name" class="_modified">{{ i18n.ts.modified }}</span></template>
</MkInput>
<MkInput v-model="shortName">
<template #label>{{ i18n.ts._serverSettings.shortName }} ({{ i18n.ts.optional }})</template>
<template #caption>{{ i18n.ts._serverSettings.shortNameDescription }}</template>
</MkInput>
<MkInput v-model="infoForm.state.shortName">
<template #label>{{ i18n.ts._serverSettings.shortName }} ({{ i18n.ts.optional }})<span v-if="infoForm.modifiedStates.shortName" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts._serverSettings.shortNameDescription }}</template>
</MkInput>
<MkTextarea v-model="description">
<template #label>{{ i18n.ts.instanceDescription }}</template>
</MkTextarea>
<FormSplit :minWidth="300">
<MkInput v-model="maintainerName">
<template #label>{{ i18n.ts.maintainerName }}</template>
</MkInput>
<MkInput v-model="maintainerEmail" type="email">
<template #prefix><i class="ti ti-mail"></i></template>
<template #label>{{ i18n.ts.maintainerEmail }}</template>
</MkInput>
</FormSplit>
<MkInput v-model="tosUrl" type="url">
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts.tosUrl }}</template>
</MkInput>
<MkInput v-model="privacyPolicyUrl" type="url">
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts.privacyPolicyUrl }}</template>
</MkInput>
<MkInput v-model="inquiryUrl" type="url">
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts._serverSettings.inquiryUrl }}</template>
<template #caption>{{ i18n.ts._serverSettings.inquiryUrlDescription }}</template>
</MkInput>
<MkInput v-model="repositoryUrl" type="url">
<template #label>{{ i18n.ts.repositoryUrl }}</template>
<template #prefix><i class="ti ti-link"></i></template>
<template #caption>{{ i18n.ts.repositoryUrlDescription }}</template>
</MkInput>
<MkInfo v-if="!instance.providesTarball && !repositoryUrl" warn>
{{ i18n.ts.repositoryUrlOrTarballRequired }}
</MkInfo>
<MkInput v-model="impressumUrl" type="url">
<template #label>{{ i18n.ts.impressumUrl }}</template>
<template #prefix><i class="ti ti-link"></i></template>
<template #caption>{{ i18n.ts.impressumDescription }}</template>
</MkInput>
</div>
</MkFolder>
<MkFolder>
<template #icon><i class="ti ti-user-star"></i></template>
<template #label>{{ i18n.ts.pinnedUsers }}</template>
<template #footer>
<MkButton primary rounded @click="save_pinnedUsers">{{ i18n.ts.save }}</MkButton>
</template>
<MkTextarea v-model="pinnedUsers">
<template #label>{{ i18n.ts.pinnedUsers }}</template>
<template #caption>{{ i18n.ts.pinnedUsersDescription }}</template>
<MkTextarea v-model="infoForm.state.description">
<template #label>{{ i18n.ts.instanceDescription }}<span v-if="infoForm.modifiedStates.description" class="_modified">{{ i18n.ts.modified }}</span></template>
</MkTextarea>
</MkFolder>
<MkFolder>
<template #icon><i class="ti ti-cloud"></i></template>
<template #label>{{ i18n.ts.files }}</template>
<template #footer>
<MkButton primary rounded @click="saveFiles">{{ i18n.ts.save }}</MkButton>
</template>
<FormSplit :minWidth="300">
<MkInput v-model="infoForm.state.maintainerName">
<template #label>{{ i18n.ts.maintainerName }}<span v-if="infoForm.modifiedStates.maintainerName" class="_modified">{{ i18n.ts.modified }}</span></template>
</MkInput>
<div class="_gaps">
<MkSwitch v-model="cacheRemoteFiles">
<template #label>{{ i18n.ts.cacheRemoteFiles }}</template>
<template #caption>{{ i18n.ts.cacheRemoteFilesDescription }}{{ i18n.ts.youCanCleanRemoteFilesCache }}</template>
<MkInput v-model="infoForm.state.maintainerEmail" type="email">
<template #label>{{ i18n.ts.maintainerEmail }}<span v-if="infoForm.modifiedStates.maintainerEmail" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #prefix><i class="ti ti-mail"></i></template>
</MkInput>
</FormSplit>
<MkInput v-model="infoForm.state.tosUrl" type="url">
<template #label>{{ i18n.ts.tosUrl }}<span v-if="infoForm.modifiedStates.tosUrl" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #prefix><i class="ti ti-link"></i></template>
</MkInput>
<MkInput v-model="infoForm.state.privacyPolicyUrl" type="url">
<template #label>{{ i18n.ts.privacyPolicyUrl }}<span v-if="infoForm.modifiedStates.privacyPolicyUrl" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #prefix><i class="ti ti-link"></i></template>
</MkInput>
<MkInput v-model="infoForm.state.inquiryUrl" type="url">
<template #label>{{ i18n.ts._serverSettings.inquiryUrl }}<span v-if="infoForm.modifiedStates.inquiryUrl" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts._serverSettings.inquiryUrlDescription }}</template>
<template #prefix><i class="ti ti-link"></i></template>
</MkInput>
<MkInput v-model="infoForm.state.repositoryUrl" type="url">
<template #label>{{ i18n.ts.repositoryUrl }}<span v-if="infoForm.modifiedStates.repositoryUrl" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts.repositoryUrlDescription }}</template>
<template #prefix><i class="ti ti-link"></i></template>
</MkInput>
<MkInfo v-if="!instance.providesTarball && !infoForm.state.repositoryUrl" warn>
{{ i18n.ts.repositoryUrlOrTarballRequired }}
</MkInfo>
<MkInput v-model="infoForm.state.impressumUrl" type="url">
<template #label>{{ i18n.ts.impressumUrl }}<span v-if="infoForm.modifiedStates.impressumUrl" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts.impressumDescription }}</template>
<template #prefix><i class="ti ti-link"></i></template>
</MkInput>
</div>
</MkFolder>
<MkFolder>
<template #icon><i class="ti ti-user-star"></i></template>
<template #label>{{ i18n.ts.pinnedUsers }}</template>
<template v-if="pinnedUsersForm.modified.value" #footer>
<MkFormFooter :form="pinnedUsersForm"/>
</template>
<MkTextarea v-model="pinnedUsersForm.state.pinnedUsers">
<template #label>{{ i18n.ts.pinnedUsers }}<span v-if="pinnedUsersForm.modifiedStates.pinnedUsers" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts.pinnedUsersDescription }}</template>
</MkTextarea>
</MkFolder>
<MkFolder>
<template #icon><i class="ti ti-cloud"></i></template>
<template #label>{{ i18n.ts.files }}</template>
<template v-if="filesForm.modified.value" #footer>
<MkFormFooter :form="filesForm"/>
</template>
<div class="_gaps">
<MkSwitch v-model="filesForm.state.cacheRemoteFiles">
<template #label>{{ i18n.ts.cacheRemoteFiles }}<span v-if="filesForm.modifiedStates.cacheRemoteFiles" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts.cacheRemoteFilesDescription }}{{ i18n.ts.youCanCleanRemoteFilesCache }}</template>
</MkSwitch>
<template v-if="filesForm.state.cacheRemoteFiles">
<MkSwitch v-model="filesForm.state.cacheRemoteSensitiveFiles">
<template #label>{{ i18n.ts.cacheRemoteSensitiveFiles }}<span v-if="filesForm.modifiedStates.cacheRemoteSensitiveFiles" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts.cacheRemoteSensitiveFilesDescription }}</template>
</MkSwitch>
<template v-if="cacheRemoteFiles">
<MkSwitch v-model="cacheRemoteSensitiveFiles">
<template #label>{{ i18n.ts.cacheRemoteSensitiveFiles }}</template>
<template #caption>{{ i18n.ts.cacheRemoteSensitiveFilesDescription }}</template>
</MkSwitch>
</template>
</div>
</MkFolder>
<MkFolder>
<template #icon><i class="ti ti-world-cog"></i></template>
<template #label>ServiceWorker</template>
<template #footer>
<MkButton primary rounded @click="saveServiceWorker">{{ i18n.ts.save }}</MkButton>
</template>
</div>
</MkFolder>
<div class="_gaps">
<MkSwitch v-model="enableServiceWorker">
<template #label>{{ i18n.ts.enableServiceworker }}</template>
<template #caption>{{ i18n.ts.serviceworkerInfo }}</template>
</MkSwitch>
<MkFolder>
<template #icon><i class="ti ti-world-cog"></i></template>
<template #label>ServiceWorker</template>
<template v-if="serviceWorkerForm.modified.value" #footer>
<MkFormFooter :form="serviceWorkerForm"/>
</template>
<template v-if="enableServiceWorker">
<MkInput v-model="swPublicKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>Public key</template>
</MkInput>
<div class="_gaps">
<MkSwitch v-model="serviceWorkerForm.state.enableServiceWorker">
<template #label>{{ i18n.ts.enableServiceworker }}<span v-if="serviceWorkerForm.modifiedStates.enableServiceWorker" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts.serviceworkerInfo }}</template>
</MkSwitch>
<MkInput v-model="swPrivateKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>Private key</template>
</MkInput>
</template>
</div>
</MkFolder>
<template v-if="serviceWorkerForm.state.enableServiceWorker">
<MkInput v-model="serviceWorkerForm.state.swPublicKey">
<template #label>Public key<span v-if="serviceWorkerForm.modifiedStates.swPublicKey" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #prefix><i class="ti ti-key"></i></template>
</MkInput>
<MkFolder>
<template #icon><i class="ti ti-ad"></i></template>
<template #label>{{ i18n.ts._ad.adsSettings }}</template>
<template #footer>
<MkButton primary rounded @click="saveAd">{{ i18n.ts.save }}</MkButton>
<MkInput v-model="serviceWorkerForm.state.swPrivateKey">
<template #label>Private key<span v-if="serviceWorkerForm.modifiedStates.swPrivateKey" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #prefix><i class="ti ti-key"></i></template>
</MkInput>
</template>
</div>
</MkFolder>
<div class="_gaps">
<div class="_gaps_s">
<MkInput v-model="notesPerOneAd" :min="0" type="number">
<template #label>{{ i18n.ts._ad.notesPerOneAd }}</template>
<template #caption>{{ i18n.ts._ad.setZeroToDisable }}</template>
</MkInput>
<MkInfo v-if="notesPerOneAd > 0 && notesPerOneAd < 20" :warn="true">
{{ i18n.ts._ad.adsTooClose }}
</MkInfo>
<MkFolder>
<template #icon><i class="ti ti-ad"></i></template>
<template #label>{{ i18n.ts._ad.adsSettings }}</template>
<template v-if="adForm.modified.value" #footer>
<MkFormFooter :form="adForm"/>
</template>
<div class="_gaps">
<div class="_gaps_s">
<MkInput v-model="adForm.state.notesPerOneAd" :min="0" type="number">
<template #label>{{ i18n.ts._ad.notesPerOneAd }}<span v-if="adForm.modifiedStates.notesPerOneAd" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts._ad.setZeroToDisable }}</template>
</MkInput>
<MkInfo v-if="adForm.state.notesPerOneAd > 0 && adForm.state.notesPerOneAd < 20" :warn="true">
{{ i18n.ts._ad.adsTooClose }}
</MkInfo>
</div>
</div>
</MkFolder>
<MkFolder>
<template #icon><i class="ti ti-world-search"></i></template>
<template #label>{{ i18n.ts._urlPreviewSetting.title }}</template>
<template v-if="urlPreviewForm.modified.value" #footer>
<MkFormFooter :form="urlPreviewForm"/>
</template>
<div class="_gaps">
<MkSwitch v-model="urlPreviewForm.state.urlPreviewEnabled">
<template #label>{{ i18n.ts._urlPreviewSetting.enable }}<span v-if="urlPreviewForm.modifiedStates.urlPreviewEnabled" class="_modified">{{ i18n.ts.modified }}</span></template>
</MkSwitch>
<MkSwitch v-model="urlPreviewForm.state.urlPreviewRequireContentLength">
<template #label>{{ i18n.ts._urlPreviewSetting.requireContentLength }}<span v-if="urlPreviewForm.modifiedStates.urlPreviewRequireContentLength" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts._urlPreviewSetting.requireContentLengthDescription }}</template>
</MkSwitch>
<MkInput v-model="urlPreviewForm.state.urlPreviewMaximumContentLength" type="number">
<template #label>{{ i18n.ts._urlPreviewSetting.maximumContentLength }}<span v-if="urlPreviewForm.modifiedStates.urlPreviewMaximumContentLength" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts._urlPreviewSetting.maximumContentLengthDescription }}</template>
</MkInput>
<MkInput v-model="urlPreviewForm.state.urlPreviewTimeout" type="number">
<template #label>{{ i18n.ts._urlPreviewSetting.timeout }}<span v-if="urlPreviewForm.modifiedStates.urlPreviewTimeout" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts._urlPreviewSetting.timeoutDescription }}</template>
</MkInput>
<MkInput v-model="urlPreviewForm.state.urlPreviewUserAgent" type="text">
<template #label>{{ i18n.ts._urlPreviewSetting.userAgent }}<span v-if="urlPreviewForm.modifiedStates.urlPreviewUserAgent" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts._urlPreviewSetting.userAgentDescription }}</template>
</MkInput>
<div>
<MkInput v-model="urlPreviewForm.state.urlPreviewSummaryProxyUrl" type="text">
<template #label>{{ i18n.ts._urlPreviewSetting.summaryProxy }}<span v-if="urlPreviewForm.modifiedStates.urlPreviewSummaryProxyUrl" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>[{{ i18n.ts.notUsePleaseLeaveBlank }}] {{ i18n.ts._urlPreviewSetting.summaryProxyDescription }}</template>
</MkInput>
<div :class="$style.subCaption">
{{ i18n.ts._urlPreviewSetting.summaryProxyDescription2 }}
<ul style="padding-left: 20px; margin: 4px 0">
<li>{{ i18n.ts._urlPreviewSetting.timeout }} / key:timeout</li>
<li>{{ i18n.ts._urlPreviewSetting.maximumContentLength }} / key:contentLengthLimit</li>
<li>{{ i18n.ts._urlPreviewSetting.requireContentLength }} / key:contentLengthRequired</li>
<li>{{ i18n.ts._urlPreviewSetting.userAgent }} / key:userAgent</li>
</ul>
</div>
</div>
</MkFolder>
</div>
</MkFolder>
<MkFolder>
<template #icon><i class="ti ti-world-search"></i></template>
<template #label>{{ i18n.ts._urlPreviewSetting.title }}</template>
<template #footer>
<MkButton primary rounded @click="saveUrlPreview">{{ i18n.ts.save }}</MkButton>
</template>
<MkFolder>
<template #icon><i class="ti ti-ghost"></i></template>
<template #label>{{ i18n.ts.proxyAccount }}</template>
<div class="_gaps">
<MkSwitch v-model="urlPreviewEnabled">
<template #label>{{ i18n.ts._urlPreviewSetting.enable }}</template>
</MkSwitch>
<div class="_gaps">
<MkInfo>{{ i18n.ts.proxyAccountDescription }}</MkInfo>
<MkKeyValue>
<template #key>{{ i18n.ts.proxyAccount }}</template>
<template #value>{{ proxyAccount ? `@${proxyAccount.username}` : i18n.ts.none }}</template>
</MkKeyValue>
<MkSwitch v-model="urlPreviewRequireContentLength">
<template #label>{{ i18n.ts._urlPreviewSetting.requireContentLength }}</template>
<template #caption>{{ i18n.ts._urlPreviewSetting.requireContentLengthDescription }}</template>
</MkSwitch>
<MkInput v-model="urlPreviewMaximumContentLength" type="number">
<template #label>{{ i18n.ts._urlPreviewSetting.maximumContentLength }}</template>
<template #caption>{{ i18n.ts._urlPreviewSetting.maximumContentLengthDescription }}</template>
</MkInput>
<MkInput v-model="urlPreviewTimeout" type="number">
<template #label>{{ i18n.ts._urlPreviewSetting.timeout }}</template>
<template #caption>{{ i18n.ts._urlPreviewSetting.timeoutDescription }}</template>
</MkInput>
<MkInput v-model="urlPreviewUserAgent" type="text">
<template #label>{{ i18n.ts._urlPreviewSetting.userAgent }}</template>
<template #caption>{{ i18n.ts._urlPreviewSetting.userAgentDescription }}</template>
</MkInput>
<div>
<MkInput v-model="urlPreviewSummaryProxyUrl" type="text">
<template #label>{{ i18n.ts._urlPreviewSetting.summaryProxy }}</template>
<template #caption>[{{ i18n.ts.notUsePleaseLeaveBlank }}] {{ i18n.ts._urlPreviewSetting.summaryProxyDescription }}</template>
</MkInput>
<div :class="$style.subCaption">
{{ i18n.ts._urlPreviewSetting.summaryProxyDescription2 }}
<ul style="padding-left: 20px; margin: 4px 0">
<li>{{ i18n.ts._urlPreviewSetting.timeout }} / key:timeout</li>
<li>{{ i18n.ts._urlPreviewSetting.maximumContentLength }} / key:contentLengthLimit</li>
<li>{{ i18n.ts._urlPreviewSetting.requireContentLength }} / key:contentLengthRequired</li>
<li>{{ i18n.ts._urlPreviewSetting.userAgent }} / key:userAgent</li>
</ul>
</div>
</div>
</div>
</MkFolder>
<MkFolder>
<template #icon><i class="ti ti-ghost"></i></template>
<template #label>{{ i18n.ts.proxyAccount }}</template>
<div class="_gaps">
<MkInfo>{{ i18n.ts.proxyAccountDescription }}</MkInfo>
<MkKeyValue>
<template #key>{{ i18n.ts.proxyAccount }}</template>
<template #value>{{ proxyAccount ? `@${proxyAccount.username}` : i18n.ts.none }}</template>
</MkKeyValue>
<MkButton primary @click="chooseProxyAccount">{{ i18n.ts.selectAccount }}</MkButton>
</div>
</MkFolder>
</div>
</FormSuspense>
<MkButton primary @click="chooseProxyAccount">{{ i18n.ts.selectAccount }}</MkButton>
</div>
</MkFolder>
</div>
</MkSpacer>
</MkStickyContainer>
</div>
@@ -239,7 +237,6 @@ import MkInput from '@/components/MkInput.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkInfo from '@/components/MkInfo.vue';
import FormSplit from '@/components/form/split.vue';
import FormSuspense from '@/components/form/suspense.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { fetchInstance, instance } from '@/instance.js';
@@ -249,143 +246,109 @@ import MkButton from '@/components/MkButton.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkKeyValue from '@/components/MkKeyValue.vue';
import { useForm } from '@/scripts/use-form.js';
import MkFormFooter from '@/components/MkFormFooter.vue';
const proxyAccount = ref<Misskey.entities.UserDetailed | null>(null);
const meta = await misskeyApi('admin/meta');
const name = ref<string | null>(null);
const shortName = ref<string | null>(null);
const description = ref<string | null>(null);
const maintainerName = ref<string | null>(null);
const maintainerEmail = ref<string | null>(null);
const tosUrl = ref<string | null>(null);
const privacyPolicyUrl = ref<string | null>(null);
const inquiryUrl = ref<string | null>(null);
const repositoryUrl = ref<string | null>(null);
const impressumUrl = ref<string | null>(null);
const pinnedUsers = ref<string>('');
const cacheRemoteFiles = ref<boolean>(false);
const cacheRemoteSensitiveFiles = ref<boolean>(false);
const enableServiceWorker = ref<boolean>(false);
const swPublicKey = ref<string | null>(null);
const swPrivateKey = ref<string | null>(null);
const notesPerOneAd = ref<number>(0);
const urlPreviewEnabled = ref<boolean>(true);
const urlPreviewTimeout = ref<number>(10000);
const urlPreviewMaximumContentLength = ref<number>(1024 * 1024 * 10);
const urlPreviewRequireContentLength = ref<boolean>(true);
const urlPreviewUserAgent = ref<string | null>(null);
const urlPreviewSummaryProxyUrl = ref<string | null>(null);
const proxyAccountId = ref<string | null>(null);
const proxyAccount = ref(meta.proxyAccountId ? await misskeyApi('users/show', { userId: meta.proxyAccountId }) : null);
async function init(): Promise<void> {
const meta = await misskeyApi('admin/meta');
name.value = meta.name;
shortName.value = meta.shortName;
description.value = meta.description;
maintainerName.value = meta.maintainerName;
maintainerEmail.value = meta.maintainerEmail;
tosUrl.value = meta.tosUrl;
privacyPolicyUrl.value = meta.privacyPolicyUrl;
inquiryUrl.value = meta.inquiryUrl;
repositoryUrl.value = meta.repositoryUrl;
impressumUrl.value = meta.impressumUrl;
pinnedUsers.value = meta.pinnedUsers.join('\n');
cacheRemoteFiles.value = meta.cacheRemoteFiles;
cacheRemoteSensitiveFiles.value = meta.cacheRemoteSensitiveFiles;
enableServiceWorker.value = meta.enableServiceWorker;
swPublicKey.value = meta.swPublickey;
swPrivateKey.value = meta.swPrivateKey;
notesPerOneAd.value = meta.notesPerOneAd;
urlPreviewEnabled.value = meta.urlPreviewEnabled;
urlPreviewTimeout.value = meta.urlPreviewTimeout;
urlPreviewMaximumContentLength.value = meta.urlPreviewMaximumContentLength;
urlPreviewRequireContentLength.value = meta.urlPreviewRequireContentLength;
urlPreviewUserAgent.value = meta.urlPreviewUserAgent;
urlPreviewSummaryProxyUrl.value = meta.urlPreviewSummaryProxyUrl;
proxyAccountId.value = meta.proxyAccountId;
if (proxyAccountId.value) {
proxyAccount.value = await misskeyApi('users/show', { userId: proxyAccountId.value });
}
}
function saveInfo() {
os.apiWithDialog('admin/update-meta', {
name: name.value,
shortName: shortName.value === '' ? null : shortName.value,
description: description.value,
maintainerName: maintainerName.value,
maintainerEmail: maintainerEmail.value,
tosUrl: tosUrl.value,
privacyPolicyUrl: privacyPolicyUrl.value,
inquiryUrl: inquiryUrl.value,
repositoryUrl: repositoryUrl.value,
impressumUrl: impressumUrl.value,
}).then(() => {
fetchInstance(true);
const infoForm = useForm({
name: meta.name ?? '',
shortName: meta.shortName ?? '',
description: meta.description ?? '',
maintainerName: meta.maintainerName ?? '',
maintainerEmail: meta.maintainerEmail ?? '',
tosUrl: meta.tosUrl ?? '',
privacyPolicyUrl: meta.privacyPolicyUrl ?? '',
inquiryUrl: meta.inquiryUrl ?? '',
repositoryUrl: meta.repositoryUrl ?? '',
impressumUrl: meta.impressumUrl ?? '',
}, async (state) => {
await os.apiWithDialog('admin/update-meta', {
name: state.name,
shortName: state.shortName === '' ? null : state.shortName,
description: state.description,
maintainerName: state.maintainerName,
maintainerEmail: state.maintainerEmail,
tosUrl: state.tosUrl,
privacyPolicyUrl: state.privacyPolicyUrl,
inquiryUrl: state.inquiryUrl,
repositoryUrl: state.repositoryUrl,
impressumUrl: state.impressumUrl,
});
}
fetchInstance(true);
});
function save_pinnedUsers() {
os.apiWithDialog('admin/update-meta', {
pinnedUsers: pinnedUsers.value.split('\n'),
}).then(() => {
fetchInstance(true);
const pinnedUsersForm = useForm({
pinnedUsers: meta.pinnedUsers.join('\n'),
}, async (state) => {
await os.apiWithDialog('admin/update-meta', {
pinnedUsers: state.pinnedUsers.split('\n'),
});
}
fetchInstance(true);
});
function saveFiles() {
os.apiWithDialog('admin/update-meta', {
cacheRemoteFiles: cacheRemoteFiles.value,
cacheRemoteSensitiveFiles: cacheRemoteSensitiveFiles.value,
}).then(() => {
fetchInstance(true);
const filesForm = useForm({
cacheRemoteFiles: meta.cacheRemoteFiles,
cacheRemoteSensitiveFiles: meta.cacheRemoteSensitiveFiles,
}, async (state) => {
await os.apiWithDialog('admin/update-meta', {
cacheRemoteFiles: state.cacheRemoteFiles,
cacheRemoteSensitiveFiles: state.cacheRemoteSensitiveFiles,
});
}
fetchInstance(true);
});
function saveServiceWorker() {
os.apiWithDialog('admin/update-meta', {
enableServiceWorker: enableServiceWorker.value,
swPublicKey: swPublicKey.value,
swPrivateKey: swPrivateKey.value,
}).then(() => {
fetchInstance(true);
const serviceWorkerForm = useForm({
enableServiceWorker: meta.enableServiceWorker,
swPublicKey: meta.swPublickey ?? '',
swPrivateKey: meta.swPrivateKey ?? '',
}, async (state) => {
await os.apiWithDialog('admin/update-meta', {
enableServiceWorker: state.enableServiceWorker,
swPublicKey: state.swPublicKey,
swPrivateKey: state.swPrivateKey,
});
}
fetchInstance(true);
});
function saveAd() {
os.apiWithDialog('admin/update-meta', {
notesPerOneAd: notesPerOneAd.value,
}).then(() => {
fetchInstance(true);
const adForm = useForm({
notesPerOneAd: meta.notesPerOneAd,
}, async (state) => {
await os.apiWithDialog('admin/update-meta', {
notesPerOneAd: state.notesPerOneAd,
});
}
fetchInstance(true);
});
function saveUrlPreview() {
os.apiWithDialog('admin/update-meta', {
urlPreviewEnabled: urlPreviewEnabled.value,
urlPreviewTimeout: urlPreviewTimeout.value,
urlPreviewMaximumContentLength: urlPreviewMaximumContentLength.value,
urlPreviewRequireContentLength: urlPreviewRequireContentLength.value,
urlPreviewUserAgent: urlPreviewUserAgent.value,
urlPreviewSummaryProxyUrl: urlPreviewSummaryProxyUrl.value,
}).then(() => {
fetchInstance(true);
const urlPreviewForm = useForm({
urlPreviewEnabled: meta.urlPreviewEnabled,
urlPreviewTimeout: meta.urlPreviewTimeout,
urlPreviewMaximumContentLength: meta.urlPreviewMaximumContentLength,
urlPreviewRequireContentLength: meta.urlPreviewRequireContentLength,
urlPreviewUserAgent: meta.urlPreviewUserAgent ?? '',
urlPreviewSummaryProxyUrl: meta.urlPreviewSummaryProxyUrl ?? '',
}, async (state) => {
await os.apiWithDialog('admin/update-meta', {
urlPreviewEnabled: state.urlPreviewEnabled,
urlPreviewTimeout: state.urlPreviewTimeout,
urlPreviewMaximumContentLength: state.urlPreviewMaximumContentLength,
urlPreviewRequireContentLength: state.urlPreviewRequireContentLength,
urlPreviewUserAgent: state.urlPreviewUserAgent,
urlPreviewSummaryProxyUrl: state.urlPreviewSummaryProxyUrl,
});
}
fetchInstance(true);
});
function chooseProxyAccount() {
os.selectUser({ localOnly: true }).then(user => {
proxyAccount.value = user;
proxyAccountId.value = user.id;
saveProxyAccount();
});
}
function saveProxyAccount() {
os.apiWithDialog('admin/update-meta', {
proxyAccountId: proxyAccountId.value,
}).then(() => {
fetchInstance(true);
os.apiWithDialog('admin/update-meta', {
proxyAccountId: user.id,
}).then(() => {
fetchInstance(true);
});
});
}

View File

@@ -45,6 +45,7 @@ import { clipsCache } from '@/cache.js';
import { isSupportShare } from '@/scripts/navigator.js';
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { genEmbedCode } from '@/scripts/get-embed-code.js';
import type { MenuItem } from '@/types/menu.js';
const props = defineProps<{
clipId: string,
@@ -131,7 +132,9 @@ const headerActions = computed(() => clip.value && isOwned.value ? [{
icon: 'ti ti-share',
text: i18n.ts.share,
handler: (ev: MouseEvent): void => {
os.popupMenu([{
const menuItems: MenuItem[] = [];
menuItems.push({
icon: 'ti ti-link',
text: i18n.ts.copyUrl,
action: () => {
@@ -144,17 +147,23 @@ const headerActions = computed(() => clip.value && isOwned.value ? [{
action: () => {
genEmbedCode('clips', clip.value!.id);
},
}, ...(isSupportShare() ? [{
icon: 'ti ti-share',
text: i18n.ts.share,
action: async () => {
navigator.share({
title: clip.value!.name,
text: clip.value!.description ?? '',
url: `${url}/clips/${clip.value!.id}`,
});
},
}] : [])], ev.currentTarget ?? ev.target);
});
if (isSupportShare()) {
menuItems.push({
icon: 'ti ti-share',
text: i18n.ts.share,
action: async () => {
navigator.share({
title: clip.value!.name,
text: clip.value!.description ?? '',
url: `${url}/clips/${clip.value!.id}`,
});
},
});
}
os.popupMenu(menuItems, ev.currentTarget ?? ev.target);
},
}] : []), {
icon: 'ti ti-trash',

View File

@@ -80,7 +80,7 @@ import { defaultStore } from '@/store.js';
import { $i } from '@/account.js';
import { isSupportShare } from '@/scripts/navigator.js';
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { MenuItem } from '@/types/menu';
import type { MenuItem } from '@/types/menu.js';
import { pleaseLogin } from '@/scripts/please-login.js';
const props = defineProps<{
@@ -104,18 +104,23 @@ function fetchFlash() {
function share(ev: MouseEvent) {
if (!flash.value) return;
os.popupMenu([
{
text: i18n.ts.shareWithNote,
icon: 'ti ti-pencil',
action: shareWithNote,
},
...(isSupportShare() ? [{
const menuItems: MenuItem[] = [];
menuItems.push({
text: i18n.ts.shareWithNote,
icon: 'ti ti-pencil',
action: shareWithNote,
});
if (isSupportShare()) {
menuItems.push({
text: i18n.ts.share,
icon: 'ti ti-share',
action: shareWithNavigator,
}] : []),
], ev.currentTarget ?? ev.target);
});
}
os.popupMenu(menuItems, ev.currentTarget ?? ev.target);
}
function copyLink() {

View File

@@ -80,7 +80,7 @@ import { $i } from '@/account.js';
import { isSupportShare } from '@/scripts/navigator.js';
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { useRouter } from '@/router/supplier.js';
import { MenuItem } from '@/types/menu';
import type { MenuItem } from '@/types/menu.js';
const router = useRouter();
@@ -171,35 +171,35 @@ function reportAbuse() {
function showMenu(ev: MouseEvent) {
if (!post.value) return;
const menu: MenuItem[] = [
...($i && $i.id !== post.value.userId ? [
{
icon: 'ti ti-exclamation-circle',
text: i18n.ts.reportAbuse,
action: reportAbuse,
},
...($i.isModerator || $i.isAdmin ? [
{
type: 'divider' as const,
},
{
icon: 'ti ti-trash',
text: i18n.ts.delete,
danger: true,
action: () => os.confirm({
type: 'warning',
text: i18n.ts.deleteConfirm,
}).then(({ canceled }) => {
if (canceled || !post.value) return;
const menuItems: MenuItem[] = [];
os.apiWithDialog('gallery/posts/delete', { postId: post.value.id });
}),
},
] : []),
] : []),
];
if ($i && $i.id !== post.value.userId) {
menuItems.push({
icon: 'ti ti-exclamation-circle',
text: i18n.ts.reportAbuse,
action: reportAbuse,
});
os.popupMenu(menu, ev.currentTarget ?? ev.target);
if ($i.isModerator || $i.isAdmin) {
menuItems.push({
type: 'divider',
}, {
icon: 'ti ti-trash',
text: i18n.ts.delete,
danger: true,
action: () => os.confirm({
type: 'warning',
text: i18n.ts.deleteConfirm,
}).then(({ canceled }) => {
if (canceled || !post.value) return;
os.apiWithDialog('gallery/posts/delete', { postId: post.value.id });
}),
});
}
}
os.popupMenu(menuItems, ev.currentTarget ?? ev.target);
}
watch(() => props.postId, fetchPost, { immediate: true });

View File

@@ -134,12 +134,14 @@ async function removeUser(item, ev) {
async function showMembershipMenu(item, ev) {
const withRepliesRef = ref(item.withReplies);
os.popupMenu([{
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,

View File

@@ -121,7 +121,7 @@ import { instance } from '@/instance.js';
import { getStaticImageUrl } from '@/scripts/media-proxy.js';
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { useRouter } from '@/router/supplier.js';
import { MenuItem } from '@/types/menu';
import type { MenuItem } from '@/types/menu.js';
const router = useRouter();
@@ -165,18 +165,23 @@ function fetchPage() {
function share(ev: MouseEvent) {
if (!page.value) return;
os.popupMenu([
{
text: i18n.ts.shareWithNote,
icon: 'ti ti-pencil',
action: shareWithNote,
},
...(isSupportShare() ? [{
const menuItems: MenuItem[] = [];
menuItems.push({
text: i18n.ts.shareWithNote,
icon: 'ti ti-pencil',
action: shareWithNote,
});
if (isSupportShare()) {
menuItems.push({
text: i18n.ts.share,
icon: 'ti ti-share',
action: shareWithNavigator,
}] : []),
], ev.currentTarget ?? ev.target);
});
}
os.popupMenu(menuItems, ev.currentTarget ?? ev.target);
}
function copyLink() {
@@ -256,51 +261,59 @@ function reportAbuse() {
function showMenu(ev: MouseEvent) {
if (!page.value) return;
const menu: MenuItem[] = [
...($i && $i.id === page.value.userId ? [
{
icon: 'ti ti-code',
text: i18n.ts._pages.viewSource,
action: () => router.push(`/@${props.username}/pages/${props.pageName}/view-source`),
},
...($i.pinnedPageId === page.value.id ? [{
const menuItems: MenuItem[] = [];
if ($i && $i.id === page.value.userId) {
menuItems.push({
icon: 'ti ti-pencil',
text: i18n.ts.editThisPage,
action: () => router.push(`/pages/edit/${page.value.id}`),
});
if ($i.pinnedPageId === page.value.id) {
menuItems.push({
icon: 'ti ti-pinned-off',
text: i18n.ts.unpin,
action: () => pin(false),
}] : [{
});
} else {
menuItems.push({
icon: 'ti ti-pin',
text: i18n.ts.pin,
action: () => pin(true),
}]),
] : []),
...($i && $i.id !== page.value.userId ? [
{
icon: 'ti ti-exclamation-circle',
text: i18n.ts.reportAbuse,
action: reportAbuse,
},
...($i.isModerator || $i.isAdmin ? [
{
type: 'divider' as const,
},
{
icon: 'ti ti-trash',
text: i18n.ts.delete,
danger: true,
action: () => os.confirm({
type: 'warning',
text: i18n.ts.deleteConfirm,
}).then(({ canceled }) => {
if (canceled || !page.value) return;
});
}
} else if ($i && $i.id !== page.value.userId) {
menuItems.push({
icon: 'ti ti-code',
text: i18n.ts._pages.viewSource,
action: () => router.push(`/@${props.username}/pages/${props.pageName}/view-source`),
}, {
icon: 'ti ti-exclamation-circle',
text: i18n.ts.reportAbuse,
action: reportAbuse,
});
os.apiWithDialog('pages/delete', { pageId: page.value.id });
}),
},
] : []),
] : []),
];
if ($i.isModerator || $i.isAdmin) {
menuItems.push({
type: 'divider',
}, {
icon: 'ti ti-trash',
text: i18n.ts.delete,
danger: true,
action: () => os.confirm({
type: 'warning',
text: i18n.ts.deleteConfirm,
}).then(({ canceled }) => {
if (canceled || !page.value) return;
os.popupMenu(menu, ev.currentTarget ?? ev.target);
os.apiWithDialog('pages/delete', { pageId: page.value.id });
}),
});
}
}
os.popupMenu(menuItems, ev.currentTarget ?? ev.target);
}
watch(() => path.value, fetchPage, { immediate: true });

View File

@@ -121,7 +121,7 @@ import MkRadios from '@/components/MkRadios.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkFolder from '@/components/MkFolder.vue';
import * as os from '@/os.js';
import { MenuItem } from '@/types/menu.js';
import type { MenuItem } from '@/types/menu.js';
import { useRouter } from '@/router/supplier.js';
const $i = signinRequired();

View File

@@ -50,7 +50,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
import { antennasCache, userListsCache, favoritedChannelsCache } from '@/cache.js';
import { deviceKind } from '@/scripts/device-kind.js';
import { deepMerge } from '@/scripts/merge.js';
import { MenuItem } from '@/types/menu.js';
import type { MenuItem } from '@/types/menu.js';
import { miLocalStorage } from '@/local-storage.js';
import { availableBasicTimelines, hasWithReplies, isAvailableBasicTimeline, isBasicTimeline, basicTimelineIconClass } from '@/timelines.js';
import type { BasicTimelineType } from '@/timelines.js';
@@ -189,7 +189,7 @@ async function chooseChannel(ev: MouseEvent): Promise<void> {
}),
(channels.length === 0 ? undefined : { type: 'divider' }),
{
type: 'link' as const,
type: 'link',
icon: 'ti ti-plus',
text: i18n.ts.createNew,
to: '/channels',
@@ -258,16 +258,24 @@ const headerActions = computed(() => {
icon: 'ti ti-dots',
text: i18n.ts.options,
handler: (ev) => {
os.popupMenu([{
const menuItems: MenuItem[] = [];
menuItems.push({
type: 'switch',
text: i18n.ts.showRenotes,
ref: withRenotes,
}, isBasicTimeline(src.value) && hasWithReplies(src.value) ? {
type: 'switch',
text: i18n.ts.showRepliesToOthersInTimeline,
ref: withReplies,
disabled: onlyFiles,
} : undefined, {
});
if (isBasicTimeline(src.value) && hasWithReplies(src.value)) {
menuItems.push({
type: 'switch',
text: i18n.ts.showRepliesToOthersInTimeline,
ref: withReplies,
disabled: onlyFiles,
});
}
menuItems.push({
type: 'switch',
text: i18n.ts.withSensitive,
ref: withSensitive,
@@ -276,7 +284,9 @@ const headerActions = computed(() => {
text: i18n.ts.fileAttachedOnly,
ref: onlyFiles,
disabled: isBasicTimeline(src.value) && hasWithReplies(src.value) ? withReplies : false,
}], ev.currentTarget ?? ev.target);
});
os.popupMenu(menuItems, ev.currentTarget ?? ev.target);
},
},
];

View File

@@ -9,7 +9,7 @@ import { i18n } from '@/i18n.js';
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { MenuItem } from '@/types/menu.js';
import type { MenuItem } from '@/types/menu.js';
import { defaultStore } from '@/store.js';
function rename(file: Misskey.entities.DriveFile) {
@@ -87,8 +87,10 @@ async function deleteFile(file: Misskey.entities.DriveFile) {
export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Misskey.entities.DriveFolder | null): MenuItem[] {
const isImage = file.type.startsWith('image/');
let menu;
menu = [{
const menuItems: MenuItem[] = [];
menuItems.push({
type: 'link',
to: `/my/drive/file/${file.id}`,
text: i18n.ts._fileViewer.title,
@@ -109,14 +111,20 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Miss
text: i18n.ts.describeFile,
icon: 'ti ti-text-caption',
action: () => describe(file),
}, ...isImage ? [{
text: i18n.ts.cropImage,
icon: 'ti ti-crop',
action: () => os.cropImage(file, {
aspectRatio: NaN,
uploadFolder: folder ? folder.id : folder,
}),
}] : [], { type: 'divider' }, {
});
if (isImage) {
menuItems.push({
text: i18n.ts.cropImage,
icon: 'ti ti-crop',
action: () => os.cropImage(file, {
aspectRatio: NaN,
uploadFolder: folder ? folder.id : folder,
}),
});
}
menuItems.push({ type: 'divider' }, {
text: i18n.ts.createNoteFromTheFile,
icon: 'ti ti-pencil',
action: () => os.post({
@@ -138,17 +146,17 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Miss
icon: 'ti ti-trash',
danger: true,
action: () => deleteFile(file),
}];
});
if (defaultStore.state.devMode) {
menu = menu.concat([{ type: 'divider' }, {
menuItems.push({ type: 'divider' }, {
icon: 'ti ti-id',
text: i18n.ts.copyFileId,
action: () => {
copyToClipboard(file.id);
},
}]);
});
}
return menu;
return menuItems;
}

View File

@@ -17,7 +17,7 @@ import { defaultStore, noteActions } from '@/store.js';
import { miLocalStorage } from '@/local-storage.js';
import { getUserMenu } from '@/scripts/get-user-menu.js';
import { clipsCache, favoritedChannelsCache } from '@/cache.js';
import { MenuItem } from '@/types/menu.js';
import type { MenuItem } from '@/types/menu.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { isSupportShare } from '@/scripts/navigator.js';
import { getAppearNote } from '@/scripts/get-appear-note.js';
@@ -99,11 +99,13 @@ export async function getNoteClipMenu(props: {
const { canceled, result } = await os.form(i18n.ts.createNewClip, {
name: {
type: 'string',
default: null,
label: i18n.ts.name,
},
description: {
type: 'string',
required: false,
default: null,
multiline: true,
label: i18n.ts.description,
},
@@ -264,7 +266,7 @@ export function getNoteMenu(props: {
title: i18n.ts.numberOfDays,
});
if (canceled) return;
if (canceled || days == null) return;
os.apiWithDialog('admin/promo/create', {
noteId: appearNote.id,
@@ -295,161 +297,23 @@ export function getNoteMenu(props: {
props.translation.value = res;
}
let menu: MenuItem[];
const menuItems: MenuItem[] = [];
if ($i) {
const statePromise = misskeyApi('notes/state', {
noteId: appearNote.id,
});
menu = [
...(
props.currentClip?.userId === $i.id ? [{
icon: 'ti ti-backspace',
text: i18n.ts.unclip,
danger: true,
action: unclip,
}, { type: 'divider' }] : []
), {
icon: 'ti ti-info-circle',
text: i18n.ts.details,
action: openDetail,
}, {
icon: 'ti ti-copy',
text: i18n.ts.copyContent,
action: copyContent,
}, getCopyNoteLinkMenu(appearNote, i18n.ts.copyLink)
, (appearNote.url || appearNote.uri) ? {
icon: 'ti ti-external-link',
text: i18n.ts.showOnRemote,
action: () => {
window.open(appearNote.url ?? appearNote.uri, '_blank', 'noopener');
},
} : getNoteEmbedCodeMenu(appearNote, i18n.ts.genEmbedCode),
...(isSupportShare() ? [{
icon: 'ti ti-share',
text: i18n.ts.share,
action: share,
}] : []),
$i && $i.policies.canUseTranslator && instance.translatorAvailable ? {
icon: 'ti ti-language-hiragana',
text: i18n.ts.translate,
action: translate,
} : undefined,
{ type: 'divider' },
statePromise.then(state => state.isFavorited ? {
icon: 'ti ti-star-off',
text: i18n.ts.unfavorite,
action: () => toggleFavorite(false),
} : {
icon: 'ti ti-star',
text: i18n.ts.favorite,
action: () => toggleFavorite(true),
}),
{
type: 'parent' as const,
icon: 'ti ti-paperclip',
text: i18n.ts.clip,
children: () => getNoteClipMenu(props),
},
statePromise.then(state => state.isMutedThread ? {
icon: 'ti ti-message-off',
text: i18n.ts.unmuteThread,
action: () => toggleThreadMute(false),
} : {
icon: 'ti ti-message-off',
text: i18n.ts.muteThread,
action: () => toggleThreadMute(true),
}),
appearNote.userId === $i.id ? ($i.pinnedNoteIds ?? []).includes(appearNote.id) ? {
icon: 'ti ti-pinned-off',
text: i18n.ts.unpin,
action: () => togglePin(false),
} : {
icon: 'ti ti-pin',
text: i18n.ts.pin,
action: () => togglePin(true),
} : undefined,
{
type: 'parent' as const,
icon: 'ti ti-user',
text: i18n.ts.user,
children: async () => {
const user = appearNote.userId === $i?.id ? $i : await misskeyApi('users/show', { userId: appearNote.userId });
const { menu, cleanup } = getUserMenu(user);
cleanups.push(cleanup);
return menu;
},
},
/*
...($i.isModerator || $i.isAdmin ? [
{ type: 'divider' },
{
icon: 'ti ti-speakerphone',
text: i18n.ts.promote,
action: promote
}]
: []
),*/
...(appearNote.userId !== $i.id ? [
{ type: 'divider' },
appearNote.userId !== $i.id ? getAbuseNoteMenu(appearNote, i18n.ts.reportAbuse) : undefined,
]
: []
),
...(appearNote.channel && (appearNote.channel.userId === $i.id || $i.isModerator || $i.isAdmin) ? [
{ type: 'divider' },
{
type: 'parent' as const,
icon: 'ti ti-device-tv',
text: i18n.ts.channel,
children: async () => {
const channelChildMenu = [] as MenuItem[];
if (props.currentClip?.userId === $i.id) {
menuItems.push({
icon: 'ti ti-backspace',
text: i18n.ts.unclip,
danger: true,
action: unclip,
}, { type: 'divider' });
}
const channel = await misskeyApi('channels/show', { channelId: appearNote.channel!.id });
if (channel.pinnedNoteIds.includes(appearNote.id)) {
channelChildMenu.push({
icon: 'ti ti-pinned-off',
text: i18n.ts.unpin,
action: () => os.apiWithDialog('channels/update', {
channelId: appearNote.channel!.id,
pinnedNoteIds: channel.pinnedNoteIds.filter(id => id !== appearNote.id),
}),
});
} else {
channelChildMenu.push({
icon: 'ti ti-pin',
text: i18n.ts.pin,
action: () => os.apiWithDialog('channels/update', {
channelId: appearNote.channel!.id,
pinnedNoteIds: [...channel.pinnedNoteIds, appearNote.id],
}),
});
}
return channelChildMenu;
},
},
]
: []
),
...(appearNote.userId === $i.id || $i.isModerator || $i.isAdmin ? [
{ type: 'divider' },
appearNote.userId === $i.id ? {
icon: 'ti ti-edit',
text: i18n.ts.deleteAndEdit,
action: delEdit,
} : undefined,
{
icon: 'ti ti-trash',
text: i18n.ts.delete,
danger: true,
action: del,
}]
: []
)]
.filter(x => x !== undefined);
} else {
menu = [{
menuItems.push({
icon: 'ti ti-info-circle',
text: i18n.ts.details,
action: openDetail,
@@ -457,35 +321,194 @@ export function getNoteMenu(props: {
icon: 'ti ti-copy',
text: i18n.ts.copyContent,
action: copyContent,
}, getCopyNoteLinkMenu(appearNote, i18n.ts.copyLink),
(appearNote.url || appearNote.uri) ? {
icon: 'ti ti-external-link',
text: i18n.ts.showOnRemote,
action: () => {
window.open(appearNote.url ?? appearNote.uri, '_blank', 'noopener');
}, getCopyNoteLinkMenu(appearNote, i18n.ts.copyLink));
if (appearNote.url || appearNote.uri) {
menuItems.push({
icon: 'ti ti-external-link',
text: i18n.ts.showOnRemote,
action: () => {
window.open(appearNote.url ?? appearNote.uri, '_blank', 'noopener');
},
});
} else {
menuItems.push(getNoteEmbedCodeMenu(appearNote, i18n.ts.genEmbedCode));
}
if (isSupportShare()) {
menuItems.push({
icon: 'ti ti-share',
text: i18n.ts.share,
action: share,
});
}
if ($i.policies.canUseTranslator && instance.translatorAvailable) {
menuItems.push({
icon: 'ti ti-language-hiragana',
text: i18n.ts.translate,
action: translate,
});
}
menuItems.push({ type: 'divider' });
menuItems.push(statePromise.then(state => state.isFavorited ? {
icon: 'ti ti-star-off',
text: i18n.ts.unfavorite,
action: () => toggleFavorite(false),
} : {
icon: 'ti ti-star',
text: i18n.ts.favorite,
action: () => toggleFavorite(true),
}));
menuItems.push({
type: 'parent',
icon: 'ti ti-paperclip',
text: i18n.ts.clip,
children: () => getNoteClipMenu(props),
});
menuItems.push(statePromise.then(state => state.isMutedThread ? {
icon: 'ti ti-message-off',
text: i18n.ts.unmuteThread,
action: () => toggleThreadMute(false),
} : {
icon: 'ti ti-message-off',
text: i18n.ts.muteThread,
action: () => toggleThreadMute(true),
}));
if (appearNote.userId === $i.id) {
if (($i.pinnedNoteIds ?? []).includes(appearNote.id)) {
menuItems.push({
icon: 'ti ti-pinned-off',
text: i18n.ts.unpin,
action: () => togglePin(false),
});
} else {
menuItems.push({
icon: 'ti ti-pin',
text: i18n.ts.pin,
action: () => togglePin(true),
});
}
}
menuItems.push({
type: 'parent',
icon: 'ti ti-user',
text: i18n.ts.user,
children: async () => {
const user = appearNote.userId === $i?.id ? $i : await misskeyApi('users/show', { userId: appearNote.userId });
const { menu, cleanup } = getUserMenu(user);
cleanups.push(cleanup);
return menu;
},
} : getNoteEmbedCodeMenu(appearNote, i18n.ts.genEmbedCode)]
.filter(x => x !== undefined);
});
if (appearNote.userId !== $i.id) {
menuItems.push({ type: 'divider' });
menuItems.push(getAbuseNoteMenu(appearNote, i18n.ts.reportAbuse));
}
if (appearNote.channel && (appearNote.channel.userId === $i.id || $i.isModerator || $i.isAdmin)) {
menuItems.push({ type: 'divider' });
menuItems.push({
type: 'parent',
icon: 'ti ti-device-tv',
text: i18n.ts.channel,
children: async () => {
const channelChildMenu = [] as MenuItem[];
const channel = await misskeyApi('channels/show', { channelId: appearNote.channel!.id });
if (channel.pinnedNoteIds.includes(appearNote.id)) {
channelChildMenu.push({
icon: 'ti ti-pinned-off',
text: i18n.ts.unpin,
action: () => os.apiWithDialog('channels/update', {
channelId: appearNote.channel!.id,
pinnedNoteIds: channel.pinnedNoteIds.filter(id => id !== appearNote.id),
}),
});
} else {
channelChildMenu.push({
icon: 'ti ti-pin',
text: i18n.ts.pin,
action: () => os.apiWithDialog('channels/update', {
channelId: appearNote.channel!.id,
pinnedNoteIds: [...channel.pinnedNoteIds, appearNote.id],
}),
});
}
return channelChildMenu;
},
});
}
if (appearNote.userId === $i.id || $i.isModerator || $i.isAdmin) {
menuItems.push({ type: 'divider' });
if (appearNote.userId === $i.id) {
menuItems.push({
icon: 'ti ti-edit',
text: i18n.ts.deleteAndEdit,
action: delEdit,
});
}
menuItems.push({
icon: 'ti ti-trash',
text: i18n.ts.delete,
danger: true,
action: del,
});
}
} else {
menuItems.push({
icon: 'ti ti-info-circle',
text: i18n.ts.details,
action: openDetail,
}, {
icon: 'ti ti-copy',
text: i18n.ts.copyContent,
action: copyContent,
}, getCopyNoteLinkMenu(appearNote, i18n.ts.copyLink));
if (appearNote.url || appearNote.uri) {
menuItems.push({
icon: 'ti ti-external-link',
text: i18n.ts.showOnRemote,
action: () => {
window.open(appearNote.url ?? appearNote.uri, '_blank', 'noopener');
},
});
} else {
menuItems.push(getNoteEmbedCodeMenu(appearNote, i18n.ts.genEmbedCode));
}
}
if (noteActions.length > 0) {
menu = menu.concat([{ type: 'divider' }, ...noteActions.map(action => ({
menuItems.push({ type: 'divider' });
menuItems.push(...noteActions.map(action => ({
icon: 'ti ti-plug',
text: action.title,
action: () => {
action.handler(appearNote);
},
}))]);
})));
}
if (defaultStore.state.devMode) {
menu = menu.concat([{ type: 'divider' }, {
menuItems.push({ type: 'divider' }, {
icon: 'ti ti-id',
text: i18n.ts.copyNoteId,
action: () => {
copyToClipboard(appearNote.id);
os.success();
},
}]);
});
}
const cleanup = () => {
@@ -496,7 +519,7 @@ export function getNoteMenu(props: {
};
return {
menu,
menu: menuItems,
cleanup,
};
}

View File

@@ -18,7 +18,7 @@ import { IRouter } from '@/nirax.js';
import { antennasCache, rolesCache, userListsCache } from '@/cache.js';
import { mainRouter } from '@/router/main.js';
import { genEmbedCode } from '@/scripts/get-embed-code.js';
import { MenuItem } from '@/types/menu.js';
import type { MenuItem } from '@/types/menu.js';
export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter = mainRouter) {
const meId = $i ? $i.id : null;
@@ -148,133 +148,154 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter
});
}
let menu: MenuItem[] = [{
const menuItems: MenuItem[] = [];
menuItems.push({
icon: 'ti ti-at',
text: i18n.ts.copyUsername,
action: () => {
copyToClipboard(`@${user.username}@${user.host ?? host}`);
},
}, ...( notesSearchAvailable && (user.host == null || canSearchNonLocalNotes) ? [{
icon: 'ti ti-search',
text: i18n.ts.searchThisUsersNotes,
action: () => {
router.push(`/search?username=${encodeURIComponent(user.username)}${user.host != null ? '&host=' + encodeURIComponent(user.host) : ''}`);
},
}] : [])
, ...(iAmModerator ? [{
icon: 'ti ti-user-exclamation',
text: i18n.ts.moderation,
action: () => {
router.push(`/admin/user/${user.id}`);
},
}] : []), {
});
if (notesSearchAvailable && (user.host == null || canSearchNonLocalNotes)) {
menuItems.push({
icon: 'ti ti-search',
text: i18n.ts.searchThisUsersNotes,
action: () => {
router.push(`/search?username=${encodeURIComponent(user.username)}${user.host != null ? '&host=' + encodeURIComponent(user.host) : ''}`);
},
});
}
if (iAmModerator) {
menuItems.push({
icon: 'ti ti-user-exclamation',
text: i18n.ts.moderation,
action: () => {
router.push(`/admin/user/${user.id}`);
},
});
}
menuItems.push({
icon: 'ti ti-rss',
text: i18n.ts.copyRSS,
action: () => {
copyToClipboard(`${user.host ?? host}/@${user.username}.atom`);
},
}, ...(user.host != null && user.url != null ? [{
icon: 'ti ti-external-link',
text: i18n.ts.showOnRemote,
action: () => {
if (user.url == null) return;
window.open(user.url, '_blank', 'noopener');
},
}] : [{
icon: 'ti ti-code',
text: i18n.ts.genEmbedCode,
type: 'parent' as const,
children: [{
text: i18n.ts.noteOfThisUser,
});
if (user.host != null && user.url != null) {
menuItems.push({
icon: 'ti ti-external-link',
text: i18n.ts.showOnRemote,
action: () => {
genEmbedCode('user-timeline', user.id);
if (user.url == null) return;
window.open(user.url, '_blank', 'noopener');
},
}], // TODO: ユーザーカードの埋め込みなど
}]), {
});
} else {
menuItems.push({
icon: 'ti ti-code',
text: i18n.ts.genEmbedCode,
type: 'parent',
children: [{
text: i18n.ts.noteOfThisUser,
action: () => {
genEmbedCode('user-timeline', user.id);
},
}], // TODO: ユーザーカードの埋め込みなど
});
}
menuItems.push({
icon: 'ti ti-share',
text: i18n.ts.copyProfileUrl,
action: () => {
const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`;
copyToClipboard(`${url}/${canonical}`);
},
}, ...($i ? [{
icon: 'ti ti-mail',
text: i18n.ts.sendMessage,
action: () => {
const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${user.host}`;
os.post({ specified: user, initialText: `${canonical} ` });
},
}, { type: 'divider' }, {
icon: 'ti ti-pencil',
text: i18n.ts.editMemo,
action: () => {
editMemo();
},
}, {
type: 'parent',
icon: 'ti ti-list',
text: i18n.ts.addToList,
children: async () => {
const lists = await userListsCache.fetch();
return lists.map(list => {
const isListed = ref(list.userIds.includes(user.id));
cleanups.push(watch(isListed, () => {
if (isListed.value) {
os.apiWithDialog('users/lists/push', {
listId: list.id,
userId: user.id,
}).then(() => {
list.userIds.push(user.id);
});
} else {
os.apiWithDialog('users/lists/pull', {
listId: list.id,
userId: user.id,
}).then(() => {
list.userIds.splice(list.userIds.indexOf(user.id), 1);
});
}
}));
});
return {
type: 'switch',
text: list.name,
ref: isListed,
};
});
},
}, {
type: 'parent',
icon: 'ti ti-antenna',
text: i18n.ts.addToAntenna,
children: async () => {
const antennas = await antennasCache.fetch();
const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`;
return antennas.filter((a) => a.src === 'users').map(antenna => ({
text: antenna.name,
action: async () => {
await os.apiWithDialog('antennas/update', {
antennaId: antenna.id,
name: antenna.name,
keywords: antenna.keywords,
excludeKeywords: antenna.excludeKeywords,
src: antenna.src,
userListId: antenna.userListId,
users: [...antenna.users, canonical],
caseSensitive: antenna.caseSensitive,
withReplies: antenna.withReplies,
withFile: antenna.withFile,
notify: antenna.notify,
});
antennasCache.delete();
},
}));
},
}] : [])] as any;
if ($i) {
menuItems.push({
icon: 'ti ti-mail',
text: i18n.ts.sendMessage,
action: () => {
const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${user.host}`;
os.post({ specified: user, initialText: `${canonical} ` });
},
}, { type: 'divider' }, {
icon: 'ti ti-pencil',
text: i18n.ts.editMemo,
action: editMemo,
}, {
type: 'parent',
icon: 'ti ti-list',
text: i18n.ts.addToList,
children: async () => {
const lists = await userListsCache.fetch();
return lists.map(list => {
const isListed = ref(list.userIds?.includes(user.id) ?? false);
cleanups.push(watch(isListed, () => {
if (isListed.value) {
os.apiWithDialog('users/lists/push', {
listId: list.id,
userId: user.id,
}).then(() => {
list.userIds?.push(user.id);
});
} else {
os.apiWithDialog('users/lists/pull', {
listId: list.id,
userId: user.id,
}).then(() => {
list.userIds?.splice(list.userIds?.indexOf(user.id), 1);
});
}
}));
return {
type: 'switch',
text: list.name,
ref: isListed,
};
});
},
}, {
type: 'parent',
icon: 'ti ti-antenna',
text: i18n.ts.addToAntenna,
children: async () => {
const antennas = await antennasCache.fetch();
const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`;
return antennas.filter((a) => a.src === 'users').map(antenna => ({
text: antenna.name,
action: async () => {
await os.apiWithDialog('antennas/update', {
antennaId: antenna.id,
name: antenna.name,
keywords: antenna.keywords,
excludeKeywords: antenna.excludeKeywords,
src: antenna.src,
userListId: antenna.userListId,
users: [...antenna.users, canonical],
caseSensitive: antenna.caseSensitive,
withReplies: antenna.withReplies,
withFile: antenna.withFile,
notify: antenna.notify,
});
antennasCache.delete();
},
}));
},
});
}
if ($i && meId !== user.id) {
if (iAmModerator) {
menu = menu.concat([{
menuItems.push({
type: 'parent',
icon: 'ti ti-badges',
text: i18n.ts.roles,
@@ -312,13 +333,14 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter
},
}));
},
}]);
});
}
// フォローしたとしても user.isFollowing はリアルタイム更新されないので不便なため
//if (user.isFollowing) {
const withRepliesRef = ref(user.withReplies);
menu = menu.concat([{
const withRepliesRef = ref(user.withReplies ?? false);
menuItems.push({
type: 'switch',
icon: 'ti ti-messages',
text: i18n.ts.showRepliesToOthersInTimeline,
@@ -327,7 +349,8 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter
icon: user.notify === 'none' ? 'ti ti-bell' : 'ti ti-bell-off',
text: user.notify === 'none' ? i18n.ts.notifyNotes : i18n.ts.unnotifyNotes,
action: toggleNotify,
}]);
});
watch(withRepliesRef, (withReplies) => {
misskeyApi('following/update', {
userId: user.id,
@@ -338,7 +361,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter
});
//}
menu = menu.concat([{ type: 'divider' }, {
menuItems.push({ type: 'divider' }, {
icon: user.isMuted ? 'ti ti-eye' : 'ti ti-eye-off',
text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute,
action: toggleMute,
@@ -350,70 +373,68 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter
icon: 'ti ti-ban',
text: user.isBlocking ? i18n.ts.unblock : i18n.ts.block,
action: toggleBlock,
}]);
});
if (user.isFollowed) {
menu = menu.concat([{
menuItems.push({
icon: 'ti ti-link-off',
text: i18n.ts.breakFollow,
action: invalidateFollow,
}]);
});
}
menu = menu.concat([{ type: 'divider' }, {
menuItems.push({ type: 'divider' }, {
icon: 'ti ti-exclamation-circle',
text: i18n.ts.reportAbuse,
action: reportAbuse,
}]);
});
}
if (user.host !== null) {
menu = menu.concat([{ type: 'divider' }, {
menuItems.push({ type: 'divider' }, {
icon: 'ti ti-refresh',
text: i18n.ts.updateRemoteUser,
action: userInfoUpdate,
}]);
});
}
if (defaultStore.state.devMode) {
menu = menu.concat([{ type: 'divider' }, {
menuItems.push({ type: 'divider' }, {
icon: 'ti ti-id',
text: i18n.ts.copyUserId,
action: () => {
copyToClipboard(user.id);
},
}]);
});
}
if ($i && meId === user.id) {
menu = menu.concat([{ type: 'divider' }, {
menuItems.push({ type: 'divider' }, {
icon: 'ti ti-pencil',
text: i18n.ts.editProfile,
action: () => {
router.push('/settings/profile');
},
}]);
});
}
if (userActions.length > 0) {
menu = menu.concat([{ type: 'divider' }, ...userActions.map(action => ({
menuItems.push({ type: 'divider' }, ...userActions.map(action => ({
icon: 'ti ti-plug',
text: action.title,
action: () => {
action.handler(user);
},
}))]);
})));
}
const cleanup = () => {
if (_DEV_) console.log('user menu cleanup', cleanups);
for (const cl of cleanups) {
cl();
}
};
return {
menu,
cleanup,
menu: menuItems,
cleanup: () => {
if (_DEV_) console.log('user menu cleanup', cleanups);
for (const cl of cleanups) {
cl();
}
},
};
}

View File

@@ -0,0 +1,55 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { computed, Reactive, reactive, watch } from 'vue';
function copy<T>(v: T): T {
return JSON.parse(JSON.stringify(v));
}
function unwrapReactive<T>(v: Reactive<T>): T {
return JSON.parse(JSON.stringify(v));
}
export function useForm<T extends Record<string, any>>(initialState: T, save: (newState: T) => Promise<void>) {
const currentState = reactive<T>(copy(initialState));
const previousState = reactive<T>(copy(initialState));
const modifiedStates = reactive<Record<keyof T, boolean>>({} as any);
for (const key in currentState) {
modifiedStates[key] = false;
}
const modified = computed(() => Object.values(modifiedStates).some(v => v));
const modifiedCount = computed(() => Object.values(modifiedStates).filter(v => v).length);
watch([currentState, previousState], () => {
for (const key in modifiedStates) {
modifiedStates[key] = currentState[key] !== previousState[key];
}
}, { deep: true });
async function _save() {
await save(unwrapReactive(currentState));
for (const key in currentState) {
previousState[key] = copy(currentState[key]);
}
}
function discard() {
for (const key in currentState) {
currentState[key] = copy(previousState[key]);
}
}
return {
state: currentState,
savedState: previousState,
modifiedStates,
modified,
modifiedCount,
save: _save,
discard,
};
}

View File

@@ -378,6 +378,16 @@ rt {
vertical-align: top;
}
._modified {
margin-left: 0.7em;
font-size: 65%;
padding: 2px 3px;
color: var(--warn);
border: solid 1px var(--warn);
border-radius: 4px;
vertical-align: top;
}
._table {
> ._row {
display: flex;

View File

@@ -41,7 +41,9 @@ function toolsMenuItems(): MenuItem[] {
}
export function openInstanceMenu(ev: MouseEvent) {
os.popupMenu([{
const menuItems: MenuItem[] = [];
menuItems.push({
text: instance.name ?? host,
type: 'label',
}, {
@@ -69,12 +71,18 @@ export function openInstanceMenu(ev: MouseEvent) {
text: i18n.ts.ads,
icon: 'ti ti-ad',
to: '/ads',
}, ($i && ($i.isAdmin || $i.policies.canInvite) && instance.disableRegistration) ? {
type: 'link',
to: '/invite',
text: i18n.ts.invite,
icon: 'ti ti-user-plus',
} : undefined, {
});
if ($i && ($i.isAdmin || $i.policies.canInvite) && instance.disableRegistration) {
menuItems.push({
type: 'link',
to: '/invite',
text: i18n.ts.invite,
icon: 'ti ti-user-plus',
});
}
menuItems.push({
type: 'parent',
text: i18n.ts.tools,
icon: 'ti ti-tool',
@@ -84,43 +92,69 @@ export function openInstanceMenu(ev: MouseEvent) {
text: i18n.ts.inquiry,
icon: 'ti ti-help-circle',
to: '/contact',
}, (instance.impressumUrl) ? {
type: 'a',
text: i18n.ts.impressum,
icon: 'ti ti-file-invoice',
href: instance.impressumUrl,
target: '_blank',
} : undefined, (instance.tosUrl) ? {
type: 'a',
text: i18n.ts.termsOfService,
icon: 'ti ti-notebook',
href: instance.tosUrl,
target: '_blank',
} : undefined, (instance.privacyPolicyUrl) ? {
type: 'a',
text: i18n.ts.privacyPolicy,
icon: 'ti ti-shield-lock',
href: instance.privacyPolicyUrl,
target: '_blank',
} : undefined, (!instance.impressumUrl && !instance.tosUrl && !instance.privacyPolicyUrl) ? undefined : { type: 'divider' }, {
});
if (instance.impressumUrl) {
menuItems.push({
type: 'a',
text: i18n.ts.impressum,
icon: 'ti ti-file-invoice',
href: instance.impressumUrl,
target: '_blank',
});
}
if (instance.tosUrl) {
menuItems.push({
type: 'a',
text: i18n.ts.termsOfService,
icon: 'ti ti-notebook',
href: instance.tosUrl,
target: '_blank',
});
}
if (instance.privacyPolicyUrl) {
menuItems.push({
type: 'a',
text: i18n.ts.privacyPolicy,
icon: 'ti ti-shield-lock',
href: instance.privacyPolicyUrl,
target: '_blank',
});
}
if (!instance.impressumUrl && !instance.tosUrl && !instance.privacyPolicyUrl) {
menuItems.push({ type: 'divider' });
}
menuItems.push({
type: 'a',
text: i18n.ts.document,
icon: 'ti ti-bulb',
href: 'https://misskey-hub.net/docs/for-users/',
target: '_blank',
}, ($i) ? {
text: i18n.ts._initialTutorial.launchTutorial,
icon: 'ti ti-presentation',
action: () => {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTutorialDialog.vue')), {}, {
closed: () => dispose(),
});
},
} : undefined, {
});
if ($i) {
menuItems.push({
text: i18n.ts._initialTutorial.launchTutorial,
icon: 'ti ti-presentation',
action: () => {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTutorialDialog.vue')), {}, {
closed: () => dispose(),
});
},
});
}
menuItems.push({
type: 'link',
text: i18n.ts.aboutMisskey,
to: '/about-misskey',
}], ev.currentTarget ?? ev.target, {
});
os.popupMenu(menuItems, ev.currentTarget ?? ev.target, {
align: 'left',
});
}

View File

@@ -118,7 +118,7 @@ import XMentionsColumn from '@/ui/deck/mentions-column.vue';
import XDirectColumn from '@/ui/deck/direct-column.vue';
import XRoleTimelineColumn from '@/ui/deck/role-timeline-column.vue';
import { mainRouter } from '@/router/main.js';
import { MenuItem } from '@/types/menu.js';
import type { MenuItem } from '@/types/menu.js';
const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue'));
const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announcements.vue'));

View File

@@ -22,7 +22,7 @@ import MkTimeline from '@/components/MkTimeline.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
import { MenuItem } from '@/types/menu.js';
import type { MenuItem } from '@/types/menu.js';
import { antennasCache } from '@/cache.js';
import { SoundStore } from '@/store.js';
import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js';

View File

@@ -29,7 +29,7 @@ import * as os from '@/os.js';
import { favoritedChannelsCache } from '@/cache.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
import { MenuItem } from '@/types/menu.js';
import type { MenuItem } from '@/types/menu.js';
import { SoundStore } from '@/store.js';
import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js';
import * as sound from '@/scripts/sound.js';

View File

@@ -46,7 +46,7 @@ import { onBeforeUnmount, onMounted, provide, watch, shallowRef, ref, computed }
import { updateColumn, swapLeftColumn, swapRightColumn, swapUpColumn, swapDownColumn, stackLeftColumn, popRightColumn, removeColumn, swapColumn, Column } from './deck-store.js';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { MenuItem } from '@/types/menu.js';
import type { MenuItem } from '@/types/menu.js';
provide('shouldHeaderThin', true);
provide('shouldOmitHeaderTitle', true);
@@ -104,7 +104,27 @@ function toggleActive() {
}
function getMenu() {
let items: MenuItem[] = [{
const menuItems: MenuItem[] = [];
if (props.menu) {
menuItems.push(...props.menu, {
type: 'divider',
});
}
if (props.refresher) {
menuItems.push({
icon: 'ti ti-refresh',
text: i18n.ts.reload,
action: () => {
if (props.refresher) {
props.refresher();
}
},
});
}
menuItems.push({
icon: 'ti ti-settings',
text: i18n.ts._deck.configureColumn,
action: async () => {
@@ -129,74 +149,73 @@ function getMenu() {
if (canceled) return;
updateColumn(props.column.id, result);
},
});
const moveToMenuItems: MenuItem[] = [];
moveToMenuItems.push({
icon: 'ti ti-arrow-left',
text: i18n.ts._deck.swapLeft,
action: () => {
swapLeftColumn(props.column.id);
},
}, {
type: 'parent',
text: i18n.ts.move + '...',
icon: 'ti ti-arrows-move',
children: [{
icon: 'ti ti-arrow-left',
text: i18n.ts._deck.swapLeft,
action: () => {
swapLeftColumn(props.column.id);
},
}, {
icon: 'ti ti-arrow-right',
text: i18n.ts._deck.swapRight,
action: () => {
swapRightColumn(props.column.id);
},
}, props.isStacked ? {
icon: 'ti ti-arrow-right',
text: i18n.ts._deck.swapRight,
action: () => {
swapRightColumn(props.column.id);
},
});
if (props.isStacked) {
moveToMenuItems.push({
icon: 'ti ti-arrow-up',
text: i18n.ts._deck.swapUp,
action: () => {
swapUpColumn(props.column.id);
},
} : undefined, props.isStacked ? {
}, {
icon: 'ti ti-arrow-down',
text: i18n.ts._deck.swapDown,
action: () => {
swapDownColumn(props.column.id);
},
} : undefined],
});
}
menuItems.push({
type: 'parent',
text: i18n.ts.move + '...',
icon: 'ti ti-arrows-move',
children: moveToMenuItems,
}, {
icon: 'ti ti-stack-2',
text: i18n.ts._deck.stackLeft,
action: () => {
stackLeftColumn(props.column.id);
},
}, props.isStacked ? {
icon: 'ti ti-window-maximize',
text: i18n.ts._deck.popRight,
action: () => {
popRightColumn(props.column.id);
},
} : undefined, { type: 'divider' }, {
});
if (props.isStacked) {
menuItems.push({
icon: 'ti ti-window-maximize',
text: i18n.ts._deck.popRight,
action: () => {
popRightColumn(props.column.id);
},
});
}
menuItems.push({ type: 'divider' }, {
icon: 'ti ti-trash',
text: i18n.ts.remove,
danger: true,
action: () => {
removeColumn(props.column.id);
},
}];
});
if (props.menu) {
items.unshift({ type: 'divider' });
items = props.menu.concat(items);
}
if (props.refresher) {
items = [{
icon: 'ti ti-refresh',
text: i18n.ts.reload,
action: () => {
if (props.refresher) {
props.refresher();
}
},
}, ...items];
}
return items;
return menuItems;
}
function showSettingsMenu(ev: MouseEvent) {

View File

@@ -22,7 +22,7 @@ import MkTimeline from '@/components/MkTimeline.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
import { MenuItem } from '@/types/menu.js';
import type { MenuItem } from '@/types/menu.js';
import { SoundStore } from '@/store.js';
import { userListsCache } from '@/cache.js';
import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js';

View File

@@ -21,7 +21,7 @@ import MkTimeline from '@/components/MkTimeline.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
import { MenuItem } from '@/types/menu.js';
import type { MenuItem } from '@/types/menu.js';
import { SoundStore } from '@/store.js';
import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js';
import * as sound from '@/scripts/sound.js';

View File

@@ -113,29 +113,41 @@ function onNote() {
sound.playMisskeySfxFile(soundSetting.value);
}
const menu = computed<MenuItem[]>(() => [{
icon: 'ti ti-pencil',
text: i18n.ts.timeline,
action: setType,
}, {
icon: 'ti ti-bell',
text: i18n.ts._deck.newNoteNotificationSettings,
action: () => soundSettingsButton(soundSetting),
}, {
type: 'switch',
text: i18n.ts.showRenotes,
ref: withRenotes,
}, hasWithReplies(props.column.tl) ? {
type: 'switch',
text: i18n.ts.showRepliesToOthersInTimeline,
ref: withReplies,
disabled: onlyFiles,
} : undefined, {
type: 'switch',
text: i18n.ts.fileAttachedOnly,
ref: onlyFiles,
disabled: hasWithReplies(props.column.tl) ? withReplies : false,
}]);
const menu = computed<MenuItem[]>(() => {
const menuItems: MenuItem[] = [];
menuItems.push({
icon: 'ti ti-pencil',
text: i18n.ts.timeline,
action: setType,
}, {
icon: 'ti ti-bell',
text: i18n.ts._deck.newNoteNotificationSettings,
action: () => soundSettingsButton(soundSetting),
}, {
type: 'switch',
text: i18n.ts.showRenotes,
ref: withRenotes,
});
if (hasWithReplies(props.column.tl)) {
menuItems.push({
type: 'switch',
text: i18n.ts.showRepliesToOthersInTimeline,
ref: withReplies,
disabled: onlyFiles,
});
}
menuItems.push({
type: 'switch',
text: i18n.ts.fileAttachedOnly,
ref: onlyFiles,
disabled: hasWithReplies(props.column.tl) ? withReplies : false,
});
return menuItems;
});
</script>
<style lang="scss" module>

View File

@@ -40,6 +40,7 @@ import MkContainer from '@/components/MkContainer.vue';
import MkTimeline from '@/components/MkTimeline.vue';
import { i18n } from '@/i18n.js';
import { availableBasicTimelines, isAvailableBasicTimeline, isBasicTimeline, basicTimelineIconClass } from '@/timelines.js';
import type { MenuItem } from '@/types/menu.js';
const name = 'timeline';
@@ -109,11 +110,26 @@ const choose = async (ev) => {
setSrc('list');
},
}));
os.popupMenu([...availableBasicTimelines().map(tl => ({
const menuItems: MenuItem[] = [];
menuItems.push(...availableBasicTimelines().map(tl => ({
text: i18n.ts._timelines[tl],
icon: basicTimelineIconClass(tl),
action: () => { setSrc(tl); },
})), antennaItems.length > 0 ? { type: 'divider' } : undefined, ...antennaItems, listItems.length > 0 ? { type: 'divider' } : undefined, ...listItems], ev.currentTarget ?? ev.target).then(() => {
})));
if (antennaItems.length > 0) {
menuItems.push({ type: 'divider' });
menuItems.push(...antennaItems);
}
if (listItems.length > 0) {
menuItems.push({ type: 'divider' });
menuItems.push(...listItems);
}
os.popupMenu(menuItems, ev.currentTarget ?? ev.target).then(() => {
menuOpened.value = false;
});
};

View File

@@ -1,32 +1,32 @@
import * as esbuild from "esbuild";
import { build } from "esbuild";
import { globSync } from "glob";
import { execa } from "execa";
import fs from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
import * as esbuild from 'esbuild';
import { build } from 'esbuild';
import { globSync } from 'glob';
import { execa } from 'execa';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
const _package = JSON.parse(fs.readFileSync(_dirname + '/package.json', 'utf-8'));
const entryPoints = globSync("./src/**/**.{ts,tsx}");
const entryPoints = globSync('./src/**/**.{ts,tsx}');
/** @type {import('esbuild').BuildOptions} */
const options = {
entryPoints,
minify: process.env.NODE_ENV === 'production',
outdir: "./built",
target: "es2022",
platform: "browser",
format: "esm",
outdir: './built',
target: 'es2022',
platform: 'browser',
format: 'esm',
sourcemap: 'linked',
};
// built配下をすべて削除する
fs.rmSync('./built', { recursive: true, force: true });
if (process.argv.map(arg => arg.toLowerCase()).includes("--watch")) {
if (process.argv.map(arg => arg.toLowerCase()).includes('--watch')) {
await watchSrc();
} else {
await buildSrc();
@@ -36,7 +36,7 @@ async function buildSrc() {
console.log(`[${_package.name}] start building...`);
await build(options)
.then(it => {
.then(() => {
console.log(`[${_package.name}] build succeeded.`);
})
.catch((err) => {
@@ -65,7 +65,7 @@ function buildDts() {
{
stdout: process.stdout,
stderr: process.stderr,
}
},
);
}
@@ -86,7 +86,7 @@ async function watchSrc() {
},
}];
console.log(`[${_package.name}] start watching...`)
console.log(`[${_package.name}] start watching...`);
const context = await esbuild.context({ ...options, plugins });
await context.watch();

View File

@@ -1,6 +1,7 @@
import tsParser from '@typescript-eslint/parser';
import sharedConfig from '../shared/eslint.config.js';
// eslint-disable-next-line import/no-default-export
export default [
...sharedConfig,
{

View File

@@ -199,13 +199,12 @@ export class DropAndFusionGame extends EventEmitter<{
};
if (mono.shape === 'circle') {
return Matter.Bodies.circle(x, y, mono.sizeX / 2, options);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
} else if (mono.shape === 'rectangle') {
return Matter.Bodies.rectangle(x, y, mono.sizeX, mono.sizeY, options);
} else if (mono.shape === 'custom') {
return Matter.Bodies.fromVertices(x, y, mono.vertices!.map(i => i.map(j => ({
x: (j.x / mono.verticesSize!) * mono.sizeX,
y: (j.y / mono.verticesSize!) * mono.sizeY,
} else if (mono.shape === 'custom' && mono.vertices != null && mono.verticesSize != null) { //eslint-disable-line @typescript-eslint/no-unnecessary-condition
return Matter.Bodies.fromVertices(x, y, mono.vertices.map(i => i.map(j => ({
x: (j.x / mono.verticesSize!) * mono.sizeX, //eslint-disable-line @typescript-eslint/no-non-null-assertion
y: (j.y / mono.verticesSize!) * mono.sizeY, //eslint-disable-line @typescript-eslint/no-non-null-assertion
}))), options);
} else {
throw new Error('unrecognized shape');
@@ -227,7 +226,12 @@ export class DropAndFusionGame extends EventEmitter<{
this.gameOverReadyBodyIds = this.gameOverReadyBodyIds.filter(x => x !== bodyA.id && x !== bodyB.id);
Matter.Composite.remove(this.engine.world, [bodyA, bodyB]);
const currentMono = this.monoDefinitions.find(y => y.id === bodyA.label)!;
const currentMono = this.monoDefinitions.find(y => y.id === bodyA.label);
if (currentMono == null) {
throw new Error('Current Mono Not Found');
}
const nextMono = this.monoDefinitions.find(x => x.level === currentMono.level + 1) ?? null;
if (nextMono) {
@@ -362,14 +366,18 @@ export class DropAndFusionGame extends EventEmitter<{
}
public getActiveMonos() {
return this.engine.world.bodies.map(x => this.monoDefinitions.find((mono) => mono.id === x.label)!).filter(x => x !== undefined);
return this.engine.world.bodies
.map(x => this.monoDefinitions.find((mono) => mono.id === x.label))
.filter(x => x !== undefined);
}
public drop(_x: number) {
if (this.isGameOver) return;
if (this.frame - this.latestDroppedAt < this.DROP_COOLTIME) return;
const head = this.stock.shift()!;
const head = this.stock.shift();
if (!head) return;
this.stock.push({
id: this.rng().toString(),
mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(this.rng() * this.monoDefinitions.filter(x => x.dropCandidate).length)],
@@ -411,13 +419,15 @@ export class DropAndFusionGame extends EventEmitter<{
});
if (this.holding) {
const head = this.stock.shift()!;
const head = this.stock.shift();
if (!head) return;
this.stock.unshift(this.holding);
this.holding = head;
this.emit('changeHolding', this.holding);
this.emit('changeStock', this.stock);
} else {
const head = this.stock.shift()!;
const head = this.stock.shift();
if (!head) return;
this.holding = head;
this.stock.push({
id: this.rng().toString(),

View File

@@ -671,7 +671,7 @@ export type Channels = {
};
hashtag: {
params: {
q?: string;
q: string[][];
};
events: {
note: (payload: Note) => void;

View File

@@ -1,7 +1,7 @@
{
"type": "module",
"name": "misskey-js",
"version": "2024.9.0-alpha.5",
"version": "2024.9.0-alpha.7",
"description": "Misskey SDK for JavaScript",
"license": "MIT",
"main": "./built/index.js",

View File

@@ -124,7 +124,7 @@ export type Channels = {
};
hashtag: {
params: {
q?: string;
q: string[][];
};
events: {
note: (payload: Note) => void;

View File

@@ -1,32 +1,32 @@
import * as esbuild from "esbuild";
import { build } from "esbuild";
import { globSync } from "glob";
import { execa } from "execa";
import fs from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
import * as esbuild from 'esbuild';
import { build } from 'esbuild';
import { globSync } from 'glob';
import { execa } from 'execa';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
const _package = JSON.parse(fs.readFileSync(_dirname + '/package.json', 'utf-8'));
const entryPoints = globSync("./src/**/**.{ts,tsx}");
const entryPoints = globSync('./src/**/**.{ts,tsx}');
/** @type {import('esbuild').BuildOptions} */
const options = {
entryPoints,
minify: process.env.NODE_ENV === 'production',
outdir: "./built",
target: "es2022",
platform: "browser",
format: "esm",
outdir: './built',
target: 'es2022',
platform: 'browser',
format: 'esm',
sourcemap: 'linked',
};
// built配下をすべて削除する
fs.rmSync('./built', { recursive: true, force: true });
if (process.argv.map(arg => arg.toLowerCase()).includes("--watch")) {
if (process.argv.map(arg => arg.toLowerCase()).includes('--watch')) {
await watchSrc();
} else {
await buildSrc();
@@ -36,7 +36,7 @@ async function buildSrc() {
console.log(`[${_package.name}] start building...`);
await build(options)
.then(it => {
.then(() => {
console.log(`[${_package.name}] build succeeded.`);
})
.catch((err) => {
@@ -65,7 +65,7 @@ function buildDts() {
{
stdout: process.stdout,
stderr: process.stderr,
}
},
);
}
@@ -86,7 +86,7 @@ async function watchSrc() {
},
}];
console.log(`[${_package.name}] start watching...`)
console.log(`[${_package.name}] start watching...`);
const context = await esbuild.context({ ...options, plugins });
await context.watch();

View File

@@ -1,6 +1,7 @@
import tsParser from '@typescript-eslint/parser';
import sharedConfig from '../shared/eslint.config.js';
// eslint-disable-next-line import/no-default-export
export default [
...sharedConfig,
{

View File

@@ -53,9 +53,13 @@ export class Game {
//#region Options
this.opts = opts;
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
if (this.opts.isLlotheo == null) this.opts.isLlotheo = false;
if (this.opts.canPutEverywhere == null) this.opts.canPutEverywhere = false;
if (this.opts.loopedBoard == null) this.opts.loopedBoard = false;
/* eslint-enable */
//#endregion
//#region Parse map data
@@ -123,12 +127,13 @@ export class Game {
// ターン計算
this.turn =
this.canPutSomewhere(!this.prevColor) ? !this.prevColor :
this.canPutSomewhere(this.prevColor!) ? this.prevColor :
this.canPutSomewhere(this.prevColor!) ? this.prevColor : //eslint-disable-line @typescript-eslint/no-non-null-assertion
null;
}
public undo() {
const undo = this.logs.pop()!;
const undo = this.logs.pop();
if (undo == null) return;
this.prevColor = undo.color;
this.prevPos = undo.pos;
this.board[undo.pos] = null;
@@ -183,7 +188,7 @@ export class Game {
const found: number[] = []; // 挟めるかもしれない相手の石を入れておく配列
let [x, y] = this.posToXy(initPos);
while (true) {
while (true) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition
[x, y] = nextPos(x, y);
// 座標が指し示す位置がボード外に出たとき