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

View File

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

12
locales/index.d.ts vendored
View File

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

View File

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

View File

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

View File

@@ -239,7 +239,7 @@ export class MfmService {
return null; return null;
} }
const { window } = new Window(); const { happyDOM, window } = new Window();
const doc = window.document; const doc = window.document;
@@ -457,6 +457,10 @@ export class MfmService {
appendChildren(nodes, body); 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) { if ((contentType ?? '').split(';')[0].trimEnd().toLowerCase() === 'text/html' && _followAlternate === true) {
const html = await res.text(); const html = await res.text();
const window = new Window({ const { window, happyDOM } = new Window({
settings: { settings: {
disableJavaScriptEvaluation: true, disableJavaScriptEvaluation: true,
disableJavaScriptFileLoading: true, disableJavaScriptFileLoading: true,
@@ -241,7 +241,7 @@ export class ApRequestService {
} catch (e) { } catch (e) {
// something went wrong parsing the HTML, ignore the whole thing // something went wrong parsing the HTML, ignore the whole thing
} finally { } finally {
window.close(); happyDOM.close().catch(err => {});
} }
} }
//#endregion //#endregion

View File

@@ -785,6 +785,72 @@ export class ClientServerService {
//#endregion //#endregion
//#region embed pages //#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) => { fastify.get('/embed/*', async (request, reply) => {
reply.removeHeader('X-Frame-Options'); 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) script(type='application/json' id='misskey_meta' data-generated-at=now)
!= metaJson != metaJson
script(type='application/json' id='misskey_embedCtx' data-generated-at=now)
!= embedCtx
script script
include ../boot.embed.js include ../boot.embed.js

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,8 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<div> <div>
<EmLoading v-if="loading"/> <EmTimelineContainer v-if="user && !prohibited" :showHeader="embedParams.header">
<EmTimelineContainer v-else-if="user" :showHeader="embedParams.header">
<template #header> <template #header>
<div :class="$style.userHeader"> <div :class="$style.userHeader">
<a :href="`/@${user.username}`" target="_blank" rel="noopener noreferrer" :class="$style.avatarLink"> <a :href="`/@${user.username}`" target="_blank" rel="noopener noreferrer" :class="$style.avatarLink">
@@ -46,21 +45,20 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script setup lang="ts"> <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 * 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 type { Paging } from '@/components/EmPagination.vue';
import EmNotes from '@/components/EmNotes.vue'; import EmNotes from '@/components/EmNotes.vue';
import EmAvatar from '@/components/EmAvatar.vue'; import EmAvatar from '@/components/EmAvatar.vue';
import EmLoading from '@/components/EmLoading.vue';
import EmUserName from '@/components/EmUserName.vue'; import EmUserName from '@/components/EmUserName.vue';
import I18n from '@/components/I18n.vue'; import I18n from '@/components/I18n.vue';
import XNotFound from '@/pages/not-found.vue'; import XNotFound from '@/pages/not-found.vue';
import EmTimelineContainer from '@/components/EmTimelineContainer.vue'; import EmTimelineContainer from '@/components/EmTimelineContainer.vue';
import { misskeyApi } from '@/misskey-api.js'; import { misskeyApi } from '@/misskey-api.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { serverMetadata } from '@/server-metadata.js'; import { assertServerContext } from '@/server-context.js';
import { url, instanceName } from '@@/js/config.js';
import { defaultEmbedParams } from '@@/js/embed-page.js';
import { DI } from '@/di.js'; import { DI } from '@/di.js';
const props = defineProps<{ const props = defineProps<{
@@ -69,26 +67,37 @@ const props = defineProps<{
const embedParams = inject(DI.embedParams, defaultEmbedParams); 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(() => ({ const pagination = computed(() => ({
endpoint: 'users/notes', endpoint: 'users/notes',
params: { params: {
userId: user.value?.id, userId: user.value?.id,
}, },
} as Paging)); } as Paging));
const loading = ref(true);
const notesEl = shallowRef<InstanceType<typeof EmNotes> | null>(null); const notesEl = useTemplateRef('notesEl');
misskeyApi('users/show', {
userId: props.userId,
}).then(res => {
user.value = res;
loading.value = false;
}).catch(err => {
console.error(err);
loading.value = false;
});
</script> </script>
<style lang="scss" module> <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 <div
:class="$style.routerViewContainer" :class="$style.routerViewContainer"
> >
<EmNotePage v-if="page === 'notes'" :noteId="contentId"/> <Suspense :timeout="0">
<EmUserTimelinePage v-else-if="page === 'user-timeline'" :userId="contentId"/> <EmNotePage v-if="page === 'notes'" :noteId="contentId"/>
<EmClipPage v-else-if="page === 'clips'" :clipId="contentId"/> <EmUserTimelinePage v-else-if="page === 'user-timeline'" :userId="contentId"/>
<EmTagPage v-else-if="page === 'tags'" :tag="contentId"/> <EmClipPage v-else-if="page === 'clips'" :clipId="contentId"/>
<XNotFound v-else/> <EmTagPage v-else-if="page === 'tags'" :tag="contentId"/>
<XNotFound v-else/>
<template #fallback>
<EmLoading/>
</template>
</Suspense>
</div> </div>
</div> </div>
</template> </template>
@@ -37,6 +42,7 @@ import EmUserTimelinePage from '@/pages/user-timeline.vue';
import EmClipPage from '@/pages/clip.vue'; import EmClipPage from '@/pages/clip.vue';
import EmTagPage from '@/pages/tag.vue'; import EmTagPage from '@/pages/tag.vue';
import XNotFound from '@/pages/not-found.vue'; import XNotFound from '@/pages/not-found.vue';
import EmLoading from '@/components/EmLoading.vue';
const page = location.pathname.split('/')[2]; const page = location.pathname.split('/')[2];
const contentId = location.pathname.split('/')[3]; 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 { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { miLocalStorage } from '@/local-storage.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 { del, get, set } from '@/scripts/idb-proxy.js';
import { apiUrl } from '@@/js/config.js'; import { apiUrl } from '@@/js/config.js';
import { waiting, popup, popupMenu, success, alert } from '@/os.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) { if (opts.withExtraOperation) {
popupMenu([...[{ menuItems.push({
type: 'link' as const, type: 'link',
text: i18n.ts.profile, text: i18n.ts.profile,
to: `/@${ $i.username }`, to: `/@${$i.username}`,
avatar: $i, 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', icon: 'ti ti-plus',
text: i18n.ts.addAccount, text: i18n.ts.addAccount,
children: [{ children: [{
@@ -306,18 +318,22 @@ export async function openAccountMenu(opts: {
action: () => { createAccount(); }, action: () => { createAccount(); },
}], }],
}, { }, {
type: 'link' as const, type: 'link',
icon: 'ti ti-users', icon: 'ti ti-users',
text: i18n.ts.manageAccounts, text: i18n.ts.manageAccounts,
to: '/settings/accounts', to: '/settings/accounts',
}]], ev.currentTarget ?? ev.target, {
align: 'left',
}); });
} else { } else {
popupMenu([...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises], ev.currentTarget ?? ev.target, { if (opts.includeCurrentAccount) {
align: 'left', menuItems.push(createItem($i));
}); }
menuItems.push(...accountItemPromises);
} }
popupMenu(menuItems, ev.currentTarget ?? ev.target, {
align: 'left',
});
} }
if (_DEV_) { if (_DEV_) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -237,6 +237,8 @@ onMounted(() => {
background: var(--acrylicBg); background: var(--acrylicBg);
-webkit-backdrop-filter: var(--blur, blur(15px)); -webkit-backdrop-filter: var(--blur, blur(15px));
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; border-radius: 0 0 6px 6px;
} }
</style> </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); const menuShowing = ref(false);
function showMenu(ev: MouseEvent) { function showMenu(ev: MouseEvent) {
let menu: MenuItem[] = []; const menu: MenuItem[] = [
menu = [
// TODO: 再生キューに追加 // TODO: 再生キューに追加
{ {
type: 'switch', type: 'switch',
@@ -222,7 +220,7 @@ function showMenu(ev: MouseEvent) {
menu.push({ menu.push({
type: 'divider', type: 'divider',
}, { }, {
type: 'link' as const, type: 'link',
text: i18n.ts._fileViewer.title, text: i18n.ts._fileViewer.title,
icon: 'ti ti-info-circle', icon: 'ti ti-info-circle',
to: `/my/drive/file/${props.audio.id}`, to: `/my/drive/file/${props.audio.id}`,

View File

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

View File

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

View File

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

View File

@@ -193,7 +193,7 @@ import { deepClone } from '@/scripts/clone.js';
import { useTooltip } from '@/scripts/use-tooltip.js'; import { useTooltip } from '@/scripts/use-tooltip.js';
import { claimAchievement } from '@/scripts/achievements.js'; import { claimAchievement } from '@/scripts/achievements.js';
import { getNoteSummary } from '@/scripts/get-note-summary.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 MkRippleEffect from '@/components/MkRippleEffect.vue';
import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
import { shouldCollapsed } from '@@/js/collapsed.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 { ref, shallowRef } from 'vue';
import MkModal from './MkModal.vue'; import MkModal from './MkModal.vue';
import MkMenu from './MkMenu.vue'; import MkMenu from './MkMenu.vue';
import { MenuItem } from '@/types/menu.js'; import type { MenuItem } from '@/types/menu.js';
defineProps<{ defineProps<{
items: MenuItem[]; items: MenuItem[];

View File

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

View File

@@ -5,7 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<div class="timctyfi" :class="{ disabled, easing }"> <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 v-adaptive-border class="body">
<div ref="containerEl" class="container"> <div ref="containerEl" class="container">
<div class="track"> <div class="track">
@@ -14,15 +16,25 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="steps && showTicks" class="ticks"> <div v-if="steps && showTicks" class="ticks">
<div v-for="i in (steps + 1)" class="tick" :style="{ left: (((i - 1) / steps) * 100) + '%' }"></div> <div v-for="i in (steps + 1)" class="tick" :style="{ left: (((i - 1) / steps) * 100) + '%' }"></div>
</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> </div>
<div class="caption"><slot name="caption"></slot></div> <div class="caption">
<slot name="caption"></slot>
</div>
</div> </div>
</template> </template>
<script lang="ts" setup> <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'; import * as os from '@/os.js';
const props = withDefaults(defineProps<{ 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) { function onMousedown(ev: MouseEvent | TouchEvent) {
ev.preventDefault(); ev.preventDefault();
const tooltipShowing = ref(true); tooltipForDragShowing.value = true;
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTooltip.vue')), { const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTooltip.vue')), {
showing: tooltipShowing, showing: tooltipForDragShowing,
text: computed(() => { text: computed(() => {
return props.textConverter(finalValue.value); return props.textConverter(finalValue.value);
}), }),
@@ -137,7 +173,7 @@ function onMousedown(ev: MouseEvent | TouchEvent) {
const onMouseup = () => { const onMouseup = () => {
document.head.removeChild(style); document.head.removeChild(style);
tooltipShowing.value = false; tooltipForDragShowing.value = false;
window.removeEventListener('mousemove', onDrag); window.removeEventListener('mousemove', onDrag);
window.removeEventListener('touchmove', onDrag); window.removeEventListener('touchmove', onDrag);
window.removeEventListener('mouseup', onMouseup); window.removeEventListener('mouseup', onMouseup);
@@ -261,12 +297,12 @@ function onMousedown(ev: MouseEvent | TouchEvent) {
> .container { > .container {
> .track { > .track {
> .highlight { > .highlight {
transition: width 0.2s cubic-bezier(0,0,0,1); transition: width 0.2s cubic-bezier(0, 0, 0, 1);
} }
} }
> .thumb { > .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 * as os from '@/os.js';
import { useInterval } from '@@/js/use-interval.js'; import { useInterval } from '@@/js/use-interval.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { MenuItem } from '@/types/menu.js'; import type { MenuItem } from '@/types/menu.js';
const props = defineProps<{ const props = defineProps<{
modelValue: string | null; 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 { onBeforeUnmount, onMounted, provide, shallowRef, ref } from 'vue';
import contains from '@/scripts/contains.js'; import contains from '@/scripts/contains.js';
import * as os from '@/os.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 { i18n } from '@/i18n.js';
import { defaultStore } from '@/store.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 * as sound from '@/scripts/sound.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import MkCustomEmojiDetailedDialog from '@/components/MkCustomEmojiDetailedDialog.vue'; import MkCustomEmojiDetailedDialog from '@/components/MkCustomEmojiDetailedDialog.vue';
import type { MenuItem } from '@/types/menu.js';
const props = defineProps<{ const props = defineProps<{
name: string; name: string;
@@ -85,7 +86,9 @@ const errored = ref(url.value == null);
function onClick(ev: MouseEvent) { function onClick(ev: MouseEvent) {
if (props.menu) { if (props.menu) {
os.popupMenu([{ const menuItems: MenuItem[] = [];
menuItems.push({
type: 'label', type: 'label',
text: `:${props.name}:`, text: `:${props.name}:`,
}, { }, {
@@ -95,14 +98,20 @@ function onClick(ev: MouseEvent) {
copyToClipboard(`:${props.name}:`); copyToClipboard(`:${props.name}:`);
os.success(); os.success();
}, },
}, ...(props.menuReaction && react ? [{ });
text: i18n.ts.doReaction,
icon: 'ti ti-plus', if (props.menuReaction && react) {
action: () => { menuItems.push({
react(`:${props.name}:`); text: i18n.ts.doReaction,
sound.playMisskeySfx('reaction'); icon: 'ti ti-plus',
}, action: () => {
}] : []), { react(`:${props.name}:`);
sound.playMisskeySfx('reaction');
},
});
}
menuItems.push({
text: i18n.ts.info, text: i18n.ts.info,
icon: 'ti ti-info-circle', icon: 'ti ti-info-circle',
action: async () => { action: async () => {
@@ -114,7 +123,9 @@ function onClick(ev: MouseEvent) {
closed: () => dispose(), closed: () => dispose(),
}); });
}, },
}], ev.currentTarget ?? ev.target); });
os.popupMenu(menuItems, ev.currentTarget ?? ev.target);
} }
} }
</script> </script>

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,103 +7,100 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkStickyContainer> <MkStickyContainer>
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="700" :marginMin="16" :marginMax="32"> <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32">
<FormSuspense :p="init"> <div class="_gaps">
<div class="_gaps"> <div class="_panel" style="padding: 16px;">
<div class="_panel" style="padding: 16px;"> <MkSwitch v-model="enableServerMachineStats" @change="onChange_enableServerMachineStats">
<MkSwitch v-model="enableServerMachineStats" @change="onChange_enableServerMachineStats"> <template #label>{{ i18n.ts.enableServerMachineStats }}</template>
<template #label>{{ i18n.ts.enableServerMachineStats }}</template> <template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template>
<template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template> </MkSwitch>
</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> </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> </MkSpacer>
</MkStickyContainer> </MkStickyContainer>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, computed, watch } from 'vue'; import { ref, computed } from 'vue';
import XHeader from './_header_.vue'; import XHeader from './_header_.vue';
import FormSuspense from '@/components/form/suspense.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js'; import { misskeyApi } from '@/scripts/misskey-api.js';
import { fetchInstance } from '@/instance.js'; import { fetchInstance } from '@/instance.js';
@@ -114,45 +111,15 @@ import MkFolder from '@/components/MkFolder.vue';
import MkInput from '@/components/MkInput.vue'; import MkInput from '@/components/MkInput.vue';
import MkLink from '@/components/MkLink.vue'; import MkLink from '@/components/MkLink.vue';
import MkButton from '@/components/MkButton.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 meta = await misskeyApi('admin/meta');
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 isFttModified = ref<boolean>(false); const enableServerMachineStats = ref(meta.enableServerMachineStats);
const enableIdenticonGeneration = ref(meta.enableIdenticonGeneration);
const isRbtModified = ref<boolean>(false); const enableChartsForRemoteUser = ref(meta.enableChartsForRemoteUser);
const enableChartsForFederatedInstances = ref(meta.enableChartsForFederatedInstances);
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;
});
}
function onChange_enableServerMachineStats(value: boolean) { function onChange_enableServerMachineStats(value: boolean) {
os.apiWithDialog('admin/update-meta', { os.apiWithDialog('admin/update-meta', {
@@ -186,28 +153,33 @@ function onChange_enableChartsForFederatedInstances(value: boolean) {
}); });
} }
function saveFtt() { const fttForm = useForm({
os.apiWithDialog('admin/update-meta', { enableFanoutTimeline: meta.enableFanoutTimeline,
enableFanoutTimeline: enableFanoutTimeline.value, enableFanoutTimelineDbFallback: meta.enableFanoutTimelineDbFallback,
enableFanoutTimelineDbFallback: enableFanoutTimelineDbFallback.value, perLocalUserUserTimelineCacheMax: meta.perLocalUserUserTimelineCacheMax,
perLocalUserUserTimelineCacheMax: perLocalUserUserTimelineCacheMax.value, perRemoteUserUserTimelineCacheMax: meta.perRemoteUserUserTimelineCacheMax,
perRemoteUserUserTimelineCacheMax: perRemoteUserUserTimelineCacheMax.value, perUserHomeTimelineCacheMax: meta.perUserHomeTimelineCacheMax,
perUserHomeTimelineCacheMax: perUserHomeTimelineCacheMax.value, perUserListTimelineCacheMax: meta.perUserListTimelineCacheMax,
perUserListTimelineCacheMax: perUserListTimelineCacheMax.value, }, async (state) => {
}).then(() => { await os.apiWithDialog('admin/update-meta', {
isFttModified.value = false; enableFanoutTimeline: state.enableFanoutTimeline,
fetchInstance(true); enableFanoutTimelineDbFallback: state.enableFanoutTimelineDbFallback,
perLocalUserUserTimelineCacheMax: state.perLocalUserUserTimelineCacheMax,
perRemoteUserUserTimelineCacheMax: state.perRemoteUserUserTimelineCacheMax,
perUserHomeTimelineCacheMax: state.perUserHomeTimelineCacheMax,
perUserListTimelineCacheMax: state.perUserListTimelineCacheMax,
}); });
} fetchInstance(true);
});
function saveRbt() { const rbtForm = useForm({
os.apiWithDialog('admin/update-meta', { enableReactionsBuffering: meta.enableReactionsBuffering,
enableReactionsBuffering: enableReactionsBuffering.value, }, async (state) => {
}).then(() => { await os.apiWithDialog('admin/update-meta', {
isRbtModified.value = false; enableReactionsBuffering: state.enableReactionsBuffering,
fetchInstance(true);
}); });
} fetchInstance(true);
});
const headerActions = computed(() => []); const headerActions = computed(() => []);

View File

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

View File

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

View File

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

View File

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

View File

@@ -80,7 +80,7 @@ import { $i } from '@/account.js';
import { isSupportShare } from '@/scripts/navigator.js'; import { isSupportShare } from '@/scripts/navigator.js';
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { useRouter } from '@/router/supplier.js'; import { useRouter } from '@/router/supplier.js';
import { MenuItem } from '@/types/menu'; import type { MenuItem } from '@/types/menu.js';
const router = useRouter(); const router = useRouter();
@@ -171,35 +171,35 @@ function reportAbuse() {
function showMenu(ev: MouseEvent) { function showMenu(ev: MouseEvent) {
if (!post.value) return; if (!post.value) return;
const menu: MenuItem[] = [ const menuItems: 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;
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 }); watch(() => props.postId, fetchPost, { immediate: true });

View File

@@ -134,12 +134,14 @@ async function removeUser(item, ev) {
async function showMembershipMenu(item, ev) { async function showMembershipMenu(item, ev) {
const withRepliesRef = ref(item.withReplies); const withRepliesRef = ref(item.withReplies);
os.popupMenu([{ os.popupMenu([{
type: 'switch', type: 'switch',
text: i18n.ts.showRepliesToOthersInTimeline, text: i18n.ts.showRepliesToOthersInTimeline,
icon: 'ti ti-messages', icon: 'ti ti-messages',
ref: withRepliesRef, ref: withRepliesRef,
}], ev.currentTarget ?? ev.target); }], ev.currentTarget ?? ev.target);
watch(withRepliesRef, withReplies => { watch(withRepliesRef, withReplies => {
misskeyApi('users/lists/update-membership', { misskeyApi('users/lists/update-membership', {
listId: list.value!.id, listId: list.value!.id,

View File

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

View File

@@ -50,7 +50,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
import { antennasCache, userListsCache, favoritedChannelsCache } from '@/cache.js'; import { antennasCache, userListsCache, favoritedChannelsCache } from '@/cache.js';
import { deviceKind } from '@/scripts/device-kind.js'; import { deviceKind } from '@/scripts/device-kind.js';
import { deepMerge } from '@/scripts/merge.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 { miLocalStorage } from '@/local-storage.js';
import { availableBasicTimelines, hasWithReplies, isAvailableBasicTimeline, isBasicTimeline, basicTimelineIconClass } from '@/timelines.js'; import { availableBasicTimelines, hasWithReplies, isAvailableBasicTimeline, isBasicTimeline, basicTimelineIconClass } from '@/timelines.js';
import type { BasicTimelineType } 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' }), (channels.length === 0 ? undefined : { type: 'divider' }),
{ {
type: 'link' as const, type: 'link',
icon: 'ti ti-plus', icon: 'ti ti-plus',
text: i18n.ts.createNew, text: i18n.ts.createNew,
to: '/channels', to: '/channels',
@@ -258,16 +258,24 @@ const headerActions = computed(() => {
icon: 'ti ti-dots', icon: 'ti ti-dots',
text: i18n.ts.options, text: i18n.ts.options,
handler: (ev) => { handler: (ev) => {
os.popupMenu([{ const menuItems: MenuItem[] = [];
menuItems.push({
type: 'switch', type: 'switch',
text: i18n.ts.showRenotes, text: i18n.ts.showRenotes,
ref: withRenotes, ref: withRenotes,
}, isBasicTimeline(src.value) && hasWithReplies(src.value) ? { });
type: 'switch',
text: i18n.ts.showRepliesToOthersInTimeline, if (isBasicTimeline(src.value) && hasWithReplies(src.value)) {
ref: withReplies, menuItems.push({
disabled: onlyFiles, type: 'switch',
} : undefined, { text: i18n.ts.showRepliesToOthersInTimeline,
ref: withReplies,
disabled: onlyFiles,
});
}
menuItems.push({
type: 'switch', type: 'switch',
text: i18n.ts.withSensitive, text: i18n.ts.withSensitive,
ref: withSensitive, ref: withSensitive,
@@ -276,7 +284,9 @@ const headerActions = computed(() => {
text: i18n.ts.fileAttachedOnly, text: i18n.ts.fileAttachedOnly,
ref: onlyFiles, ref: onlyFiles,
disabled: isBasicTimeline(src.value) && hasWithReplies(src.value) ? withReplies : false, 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 { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js'; import { misskeyApi } from '@/scripts/misskey-api.js';
import { MenuItem } from '@/types/menu.js'; import type { MenuItem } from '@/types/menu.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
function rename(file: Misskey.entities.DriveFile) { 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[] { export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Misskey.entities.DriveFolder | null): MenuItem[] {
const isImage = file.type.startsWith('image/'); const isImage = file.type.startsWith('image/');
let menu;
menu = [{ const menuItems: MenuItem[] = [];
menuItems.push({
type: 'link', type: 'link',
to: `/my/drive/file/${file.id}`, to: `/my/drive/file/${file.id}`,
text: i18n.ts._fileViewer.title, text: i18n.ts._fileViewer.title,
@@ -109,14 +111,20 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Miss
text: i18n.ts.describeFile, text: i18n.ts.describeFile,
icon: 'ti ti-text-caption', icon: 'ti ti-text-caption',
action: () => describe(file), action: () => describe(file),
}, ...isImage ? [{ });
text: i18n.ts.cropImage,
icon: 'ti ti-crop', if (isImage) {
action: () => os.cropImage(file, { menuItems.push({
aspectRatio: NaN, text: i18n.ts.cropImage,
uploadFolder: folder ? folder.id : folder, icon: 'ti ti-crop',
}), action: () => os.cropImage(file, {
}] : [], { type: 'divider' }, { aspectRatio: NaN,
uploadFolder: folder ? folder.id : folder,
}),
});
}
menuItems.push({ type: 'divider' }, {
text: i18n.ts.createNoteFromTheFile, text: i18n.ts.createNoteFromTheFile,
icon: 'ti ti-pencil', icon: 'ti ti-pencil',
action: () => os.post({ action: () => os.post({
@@ -138,17 +146,17 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Miss
icon: 'ti ti-trash', icon: 'ti ti-trash',
danger: true, danger: true,
action: () => deleteFile(file), action: () => deleteFile(file),
}]; });
if (defaultStore.state.devMode) { if (defaultStore.state.devMode) {
menu = menu.concat([{ type: 'divider' }, { menuItems.push({ type: 'divider' }, {
icon: 'ti ti-id', icon: 'ti ti-id',
text: i18n.ts.copyFileId, text: i18n.ts.copyFileId,
action: () => { action: () => {
copyToClipboard(file.id); 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 { miLocalStorage } from '@/local-storage.js';
import { getUserMenu } from '@/scripts/get-user-menu.js'; import { getUserMenu } from '@/scripts/get-user-menu.js';
import { clipsCache, favoritedChannelsCache } from '@/cache.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 MkRippleEffect from '@/components/MkRippleEffect.vue';
import { isSupportShare } from '@/scripts/navigator.js'; import { isSupportShare } from '@/scripts/navigator.js';
import { getAppearNote } from '@/scripts/get-appear-note.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, { const { canceled, result } = await os.form(i18n.ts.createNewClip, {
name: { name: {
type: 'string', type: 'string',
default: null,
label: i18n.ts.name, label: i18n.ts.name,
}, },
description: { description: {
type: 'string', type: 'string',
required: false, required: false,
default: null,
multiline: true, multiline: true,
label: i18n.ts.description, label: i18n.ts.description,
}, },
@@ -264,7 +266,7 @@ export function getNoteMenu(props: {
title: i18n.ts.numberOfDays, title: i18n.ts.numberOfDays,
}); });
if (canceled) return; if (canceled || days == null) return;
os.apiWithDialog('admin/promo/create', { os.apiWithDialog('admin/promo/create', {
noteId: appearNote.id, noteId: appearNote.id,
@@ -295,161 +297,23 @@ export function getNoteMenu(props: {
props.translation.value = res; props.translation.value = res;
} }
let menu: MenuItem[]; const menuItems: MenuItem[] = [];
if ($i) { if ($i) {
const statePromise = misskeyApi('notes/state', { const statePromise = misskeyApi('notes/state', {
noteId: appearNote.id, noteId: appearNote.id,
}); });
menu = [ if (props.currentClip?.userId === $i.id) {
...( menuItems.push({
props.currentClip?.userId === $i.id ? [{ icon: 'ti ti-backspace',
icon: 'ti ti-backspace', text: i18n.ts.unclip,
text: i18n.ts.unclip, danger: true,
danger: true, action: unclip,
action: unclip, }, { type: 'divider' });
}, { 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[];
const channel = await misskeyApi('channels/show', { channelId: appearNote.channel!.id }); menuItems.push({
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 = [{
icon: 'ti ti-info-circle', icon: 'ti ti-info-circle',
text: i18n.ts.details, text: i18n.ts.details,
action: openDetail, action: openDetail,
@@ -457,35 +321,194 @@ export function getNoteMenu(props: {
icon: 'ti ti-copy', icon: 'ti ti-copy',
text: i18n.ts.copyContent, text: i18n.ts.copyContent,
action: copyContent, action: copyContent,
}, getCopyNoteLinkMenu(appearNote, i18n.ts.copyLink), }, getCopyNoteLinkMenu(appearNote, i18n.ts.copyLink));
(appearNote.url || appearNote.uri) ? {
icon: 'ti ti-external-link', if (appearNote.url || appearNote.uri) {
text: i18n.ts.showOnRemote, menuItems.push({
action: () => { icon: 'ti ti-external-link',
window.open(appearNote.url ?? appearNote.uri, '_blank', 'noopener'); 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) { 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', icon: 'ti ti-plug',
text: action.title, text: action.title,
action: () => { action: () => {
action.handler(appearNote); action.handler(appearNote);
}, },
}))]); })));
} }
if (defaultStore.state.devMode) { if (defaultStore.state.devMode) {
menu = menu.concat([{ type: 'divider' }, { menuItems.push({ type: 'divider' }, {
icon: 'ti ti-id', icon: 'ti ti-id',
text: i18n.ts.copyNoteId, text: i18n.ts.copyNoteId,
action: () => { action: () => {
copyToClipboard(appearNote.id); copyToClipboard(appearNote.id);
os.success();
}, },
}]); });
} }
const cleanup = () => { const cleanup = () => {
@@ -496,7 +519,7 @@ export function getNoteMenu(props: {
}; };
return { return {
menu, menu: menuItems,
cleanup, cleanup,
}; };
} }

View File

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

View File

@@ -41,7 +41,9 @@ function toolsMenuItems(): MenuItem[] {
} }
export function openInstanceMenu(ev: MouseEvent) { export function openInstanceMenu(ev: MouseEvent) {
os.popupMenu([{ const menuItems: MenuItem[] = [];
menuItems.push({
text: instance.name ?? host, text: instance.name ?? host,
type: 'label', type: 'label',
}, { }, {
@@ -69,12 +71,18 @@ export function openInstanceMenu(ev: MouseEvent) {
text: i18n.ts.ads, text: i18n.ts.ads,
icon: 'ti ti-ad', icon: 'ti ti-ad',
to: '/ads', to: '/ads',
}, ($i && ($i.isAdmin || $i.policies.canInvite) && instance.disableRegistration) ? { });
type: 'link',
to: '/invite', if ($i && ($i.isAdmin || $i.policies.canInvite) && instance.disableRegistration) {
text: i18n.ts.invite, menuItems.push({
icon: 'ti ti-user-plus', type: 'link',
} : undefined, { to: '/invite',
text: i18n.ts.invite,
icon: 'ti ti-user-plus',
});
}
menuItems.push({
type: 'parent', type: 'parent',
text: i18n.ts.tools, text: i18n.ts.tools,
icon: 'ti ti-tool', icon: 'ti ti-tool',
@@ -84,43 +92,69 @@ export function openInstanceMenu(ev: MouseEvent) {
text: i18n.ts.inquiry, text: i18n.ts.inquiry,
icon: 'ti ti-help-circle', icon: 'ti ti-help-circle',
to: '/contact', to: '/contact',
}, (instance.impressumUrl) ? { });
type: 'a',
text: i18n.ts.impressum, if (instance.impressumUrl) {
icon: 'ti ti-file-invoice', menuItems.push({
href: instance.impressumUrl, type: 'a',
target: '_blank', text: i18n.ts.impressum,
} : undefined, (instance.tosUrl) ? { icon: 'ti ti-file-invoice',
type: 'a', href: instance.impressumUrl,
text: i18n.ts.termsOfService, target: '_blank',
icon: 'ti ti-notebook', });
href: instance.tosUrl, }
target: '_blank',
} : undefined, (instance.privacyPolicyUrl) ? { if (instance.tosUrl) {
type: 'a', menuItems.push({
text: i18n.ts.privacyPolicy, type: 'a',
icon: 'ti ti-shield-lock', text: i18n.ts.termsOfService,
href: instance.privacyPolicyUrl, icon: 'ti ti-notebook',
target: '_blank', href: instance.tosUrl,
} : undefined, (!instance.impressumUrl && !instance.tosUrl && !instance.privacyPolicyUrl) ? undefined : { type: 'divider' }, { 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', type: 'a',
text: i18n.ts.document, text: i18n.ts.document,
icon: 'ti ti-bulb', icon: 'ti ti-bulb',
href: 'https://misskey-hub.net/docs/for-users/', href: 'https://misskey-hub.net/docs/for-users/',
target: '_blank', target: '_blank',
}, ($i) ? { });
text: i18n.ts._initialTutorial.launchTutorial,
icon: 'ti ti-presentation', if ($i) {
action: () => { menuItems.push({
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTutorialDialog.vue')), {}, { text: i18n.ts._initialTutorial.launchTutorial,
closed: () => dispose(), icon: 'ti ti-presentation',
}); action: () => {
}, const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTutorialDialog.vue')), {}, {
} : undefined, { closed: () => dispose(),
});
},
});
}
menuItems.push({
type: 'link', type: 'link',
text: i18n.ts.aboutMisskey, text: i18n.ts.aboutMisskey,
to: '/about-misskey', to: '/about-misskey',
}], ev.currentTarget ?? ev.target, { });
os.popupMenu(menuItems, ev.currentTarget ?? ev.target, {
align: 'left', 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 XDirectColumn from '@/ui/deck/direct-column.vue';
import XRoleTimelineColumn from '@/ui/deck/role-timeline-column.vue'; import XRoleTimelineColumn from '@/ui/deck/role-timeline-column.vue';
import { mainRouter } from '@/router/main.js'; 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 XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue'));
const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announcements.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 * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js'; import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { MenuItem } from '@/types/menu.js'; import type { MenuItem } from '@/types/menu.js';
import { antennasCache } from '@/cache.js'; import { antennasCache } from '@/cache.js';
import { SoundStore } from '@/store.js'; import { SoundStore } from '@/store.js';
import { soundSettingsButton } from '@/ui/deck/tl-note-notification.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 { favoritedChannelsCache } from '@/cache.js';
import { misskeyApi } from '@/scripts/misskey-api.js'; import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { MenuItem } from '@/types/menu.js'; import type { MenuItem } from '@/types/menu.js';
import { SoundStore } from '@/store.js'; import { SoundStore } from '@/store.js';
import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js'; import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js';
import * as sound from '@/scripts/sound.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 { updateColumn, swapLeftColumn, swapRightColumn, swapUpColumn, swapDownColumn, stackLeftColumn, popRightColumn, removeColumn, swapColumn, Column } from './deck-store.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { MenuItem } from '@/types/menu.js'; import type { MenuItem } from '@/types/menu.js';
provide('shouldHeaderThin', true); provide('shouldHeaderThin', true);
provide('shouldOmitHeaderTitle', true); provide('shouldOmitHeaderTitle', true);
@@ -104,7 +104,27 @@ function toggleActive() {
} }
function getMenu() { 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', icon: 'ti ti-settings',
text: i18n.ts._deck.configureColumn, text: i18n.ts._deck.configureColumn,
action: async () => { action: async () => {
@@ -129,74 +149,73 @@ function getMenu() {
if (canceled) return; if (canceled) return;
updateColumn(props.column.id, result); 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', icon: 'ti ti-arrow-right',
text: i18n.ts.move + '...', text: i18n.ts._deck.swapRight,
icon: 'ti ti-arrows-move', action: () => {
children: [{ swapRightColumn(props.column.id);
icon: 'ti ti-arrow-left', },
text: i18n.ts._deck.swapLeft, });
action: () => {
swapLeftColumn(props.column.id); if (props.isStacked) {
}, moveToMenuItems.push({
}, {
icon: 'ti ti-arrow-right',
text: i18n.ts._deck.swapRight,
action: () => {
swapRightColumn(props.column.id);
},
}, props.isStacked ? {
icon: 'ti ti-arrow-up', icon: 'ti ti-arrow-up',
text: i18n.ts._deck.swapUp, text: i18n.ts._deck.swapUp,
action: () => { action: () => {
swapUpColumn(props.column.id); swapUpColumn(props.column.id);
}, },
} : undefined, props.isStacked ? { }, {
icon: 'ti ti-arrow-down', icon: 'ti ti-arrow-down',
text: i18n.ts._deck.swapDown, text: i18n.ts._deck.swapDown,
action: () => { action: () => {
swapDownColumn(props.column.id); 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', icon: 'ti ti-stack-2',
text: i18n.ts._deck.stackLeft, text: i18n.ts._deck.stackLeft,
action: () => { action: () => {
stackLeftColumn(props.column.id); stackLeftColumn(props.column.id);
}, },
}, props.isStacked ? { });
icon: 'ti ti-window-maximize',
text: i18n.ts._deck.popRight, if (props.isStacked) {
action: () => { menuItems.push({
popRightColumn(props.column.id); icon: 'ti ti-window-maximize',
}, text: i18n.ts._deck.popRight,
} : undefined, { type: 'divider' }, { action: () => {
popRightColumn(props.column.id);
},
});
}
menuItems.push({ type: 'divider' }, {
icon: 'ti ti-trash', icon: 'ti ti-trash',
text: i18n.ts.remove, text: i18n.ts.remove,
danger: true, danger: true,
action: () => { action: () => {
removeColumn(props.column.id); removeColumn(props.column.id);
}, },
}]; });
if (props.menu) { return menuItems;
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;
} }
function showSettingsMenu(ev: MouseEvent) { function showSettingsMenu(ev: MouseEvent) {

View File

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

View File

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

View File

@@ -40,6 +40,7 @@ import MkContainer from '@/components/MkContainer.vue';
import MkTimeline from '@/components/MkTimeline.vue'; import MkTimeline from '@/components/MkTimeline.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { availableBasicTimelines, isAvailableBasicTimeline, isBasicTimeline, basicTimelineIconClass } from '@/timelines.js'; import { availableBasicTimelines, isAvailableBasicTimeline, isBasicTimeline, basicTimelineIconClass } from '@/timelines.js';
import type { MenuItem } from '@/types/menu.js';
const name = 'timeline'; const name = 'timeline';
@@ -109,11 +110,26 @@ const choose = async (ev) => {
setSrc('list'); setSrc('list');
}, },
})); }));
os.popupMenu([...availableBasicTimelines().map(tl => ({
const menuItems: MenuItem[] = [];
menuItems.push(...availableBasicTimelines().map(tl => ({
text: i18n.ts._timelines[tl], text: i18n.ts._timelines[tl],
icon: basicTimelineIconClass(tl), icon: basicTimelineIconClass(tl),
action: () => { setSrc(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; menuOpened.value = false;
}); });
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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