Merge branch 'develop' into feat-1714

This commit is contained in:
かっこかり
2024-06-13 15:02:47 +09:00
committed by GitHub
118 changed files with 7642 additions and 466 deletions

View File

@@ -22,6 +22,66 @@ export function abuseUserReport() {
};
}
export function channel(id = 'somechannelid', name = 'Some Channel', bannerUrl: string | null = 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true'): entities.Channel {
return {
id,
createdAt: '2016-12-28T22:49:51.000Z',
lastNotedAt: '2016-12-28T22:49:51.000Z',
name,
description: null,
userId: null,
bannerUrl,
pinnedNoteIds: [],
color: '#000',
isArchived: false,
usersCount: 1,
notesCount: 1,
isSensitive: false,
allowRenoteToExternal: false,
};
}
export function clip(id = 'someclipid', name = 'Some Clip'): entities.Clip {
return {
id,
createdAt: '2016-12-28T22:49:51.000Z',
lastClippedAt: null,
userId: 'someuserid',
user: {
id: 'someuserid',
name: 'Misskey User',
username: 'miskist',
host: 'misskey-hub.net',
avatarUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true',
avatarBlurhash: 'eQFRshof5NWBRi},juayfPju53WB?0ofs;s*a{ofjuay^SoMEJR%ay',
avatarDecorations: [],
emojis: {},
badgeRoles: [],
onlineStatus: 'unknown',
},
notesCount: undefined,
name,
description: 'Some clip description',
isPublic: false,
favoritedCount: 0,
};
}
export function emojiDetailed(id = 'someemojiid', name = 'some_emoji'): entities.EmojiDetailed {
return {
id,
aliases: ['alias1', 'alias2'],
name,
category: 'emojiCategory',
host: null,
url: '/client-assets/about-icon.png',
license: null,
isSensitive: false,
localOnly: false,
roleIdsThatCanBeUsedThisEmojiAsReaction: ['roleId1', 'roleId2'],
};
}
export function galleryPost(isSensitive = false) {
return {
id: 'somepostid',

View File

@@ -397,7 +397,7 @@ function toStories(component: string): Promise<string> {
const globs = await Promise.all([
glob('src/components/global/Mk*.vue'),
glob('src/components/global/RouterView.vue'),
glob('src/components/Mk{A,B}*.vue'),
glob('src/components/Mk[A-C]*.vue'),
glob('src/components/MkDigitalClock.vue'),
glob('src/components/MkGalleryPostPreview.vue'),
glob('src/components/MkSignupServerRules.vue'),

View File

@@ -15,6 +15,7 @@ const _dirname = fileURLToPath(new URL('.', import.meta.url));
const config = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
staticDirs: [{ from: '../assets', to: '/client-assets' }],
addons: [
getAbsolutePath('@storybook/addon-essentials'),
getAbsolutePath('@storybook/addon-interactions'),

View File

@@ -7,7 +7,7 @@ import { FORCE_REMOUNT } from '@storybook/core-events';
import { addons } from '@storybook/preview-api';
import { type Preview, setup } from '@storybook/vue3';
import isChromatic from 'chromatic/isChromatic';
import { initialize, mswDecorator } from 'msw-storybook-addon';
import { initialize, mswLoader } from 'msw-storybook-addon';
import { userDetailed } from './fakes.js';
import locale from './locale.js';
import { commonHandlers, onUnhandledRequest } from './mocks.js';
@@ -122,7 +122,6 @@ const preview = {
}
return story;
},
mswDecorator,
(Story, context) => {
return {
setup() {
@@ -137,6 +136,7 @@ const preview = {
};
},
],
loaders: [mswLoader],
parameters: {
controls: {
exclude: /^__/,

View File

@@ -104,6 +104,7 @@
"@types/node": "20.12.7",
"@types/punycode": "2.1.4",
"@types/sanitize-html": "2.11.0",
"@types/seedrandom": "3.0.8",
"@types/throttle-debounce": "5.0.2",
"@types/tinycolor2": "1.4.6",
"@types/uuid": "9.0.8",
@@ -128,6 +129,7 @@
"prettier": "3.2.5",
"react": "18.3.1",
"react-dom": "18.3.1",
"seedrandom": "3.0.5",
"start-server-and-test": "2.0.3",
"storybook": "8.0.9",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",

View File

@@ -11,6 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:type="type"
:name="name"
:value="value"
:disabled="disabled"
@click="emit('click', $event)"
@mousedown="onMousedown"
>
@@ -55,6 +56,7 @@ const props = defineProps<{
asLike?: boolean;
name?: string;
value?: string;
disabled?: boolean;
}>();
const emit = defineEmits<{

View File

@@ -0,0 +1,77 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import { HttpResponse, http } from 'msw';
import { action } from '@storybook/addon-actions';
import { expect, userEvent, within } from '@storybook/test';
import { channel } from '../../.storybook/fakes.js';
import { commonHandlers } from '../../.storybook/mocks.js';
import MkChannelFollowButton from './MkChannelFollowButton.vue';
import { semaphore } from '@/scripts/test-utils.js';
import { i18n } from '@/i18n.js';
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
const s = semaphore();
export const Default = {
render(args) {
return {
components: {
MkChannelFollowButton,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkChannelFollowButton v-bind="props" />',
};
},
args: {
channel: channel(),
full: true,
},
async play({ canvasElement }) {
await s.acquire();
await sleep(1000);
const canvas = within(canvasElement);
const buttonElement = canvas.getByRole<HTMLButtonElement>('button');
await expect(buttonElement).toHaveTextContent(i18n.ts.follow);
await userEvent.click(buttonElement);
await sleep(1000);
await expect(buttonElement).toHaveTextContent(i18n.ts.unfollow);
await sleep(100);
await userEvent.click(buttonElement);
s.release();
},
parameters: {
layout: 'centered',
msw: {
handlers: [
...commonHandlers,
http.post('/api/channels/follow', async ({ request }) => {
action('POST /api/channels/follow')(await request.json());
return HttpResponse.json({});
}),
http.post('/api/channels/unfollow', async ({ request }) => {
action('POST /api/channels/unfollow')(await request.json());
return HttpResponse.json({});
}),
],
},
},
} satisfies StoryObj<typeof MkChannelFollowButton>;

View File

@@ -26,17 +26,18 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { ref } from 'vue';
import * as Misskey from 'misskey-js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
const props = withDefaults(defineProps<{
channel: Record<string, any>;
channel: Misskey.entities.Channel;
full?: boolean;
}>(), {
full: false,
});
const isFollowing = ref<boolean>(props.channel.isFollowing);
const isFollowing = ref(props.channel.isFollowing);
const wait = ref(false);
async function onClick() {

View File

@@ -0,0 +1,65 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import { HttpResponse, http } from 'msw';
import { action } from '@storybook/addon-actions';
import { channel } from '../../.storybook/fakes.js';
import { commonHandlers } from '../../.storybook/mocks.js';
import MkChannelList from './MkChannelList.vue';
export const Default = {
render(args) {
return {
components: {
MkChannelList,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkChannelList v-bind="props" />',
};
},
args: {
pagination: {
endpoint: 'channels/search',
limit: 10,
},
},
parameters: {
chromatic: {
// NOTE: ロードが終わるまで待つ
delay: 3000,
},
layout: 'fullscreen',
msw: {
handlers: [
...commonHandlers,
http.post('/api/channels/search', async ({ request, params }) => {
action('POST /api/channels/search')(await request.json());
return HttpResponse.json(params.untilId === 'lastchannel' ? [] : [
channel(),
channel('lastchannel', 'Last Channel', null),
]);
}),
],
},
},
decorators: [
() => ({
template: '<div style="display: flex; align-items: center; justify-content: center; height: 100vh"><div style="max-width: 700px; width: 100%; margin: 3rem"><story/></div></div>',
}),
],
} satisfies StoryObj<typeof MkChannelList>;

View File

@@ -0,0 +1,43 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import { channel } from '../../.storybook/fakes.js';
import MkChannelPreview from './MkChannelPreview.vue';
export const Default = {
render(args) {
return {
components: {
MkChannelPreview,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkChannelPreview v-bind="props" />',
};
},
args: {
channel: channel(),
},
parameters: {
layout: 'fullscreen',
},
decorators: [
() => ({
template: '<div style="display: flex; align-items: center; justify-content: center; height: 100vh"><div style="max-width: 700px; width: 100%; margin: 3rem"><story/></div></div>',
}),
],
} satisfies StoryObj<typeof MkChannelPreview>;

View File

@@ -0,0 +1,117 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import { DefaultBodyType, HttpResponse, HttpResponseResolver, JsonBodyType, PathParams, http } from 'msw';
import seedrandom from 'seedrandom';
import { action } from '@storybook/addon-actions';
import { commonHandlers } from '../../.storybook/mocks.js';
import MkChart from './MkChart.vue';
function getChartArray(seed: string, limit: number, option?: { accumulate?: boolean, mul?: number }): number[] {
const rng = seedrandom(seed);
const max = Math.floor(option?.mul ?? 250 * rng());
let accumulation = 0;
const array: number[] = [];
for (let i = 0; i < limit; i++) {
const num = Math.floor((max + 1) * rng());
if (option?.accumulate) {
accumulation += num;
array.unshift(accumulation);
} else {
array.push(num);
}
}
return array;
}
function getChartResolver(fields: string[], option?: { accumulate?: boolean, mulMap?: Record<string, number> }): HttpResponseResolver<PathParams, DefaultBodyType, JsonBodyType> {
return ({ request }) => {
action(`GET ${request.url}`)();
const limitParam = new URL(request.url).searchParams.get('limit');
const limit = limitParam ? parseInt(limitParam) : 30;
const res = {};
for (const field of fields) {
const layers = field.split('.');
let current = res;
while (layers.length > 1) {
const currentKey = layers.shift()!;
if (current[currentKey] == null) current[currentKey] = {};
current = current[currentKey];
}
current[layers[0]] = getChartArray(field, limit, {
accumulate: option?.accumulate,
mul: option?.mulMap != null && field in option.mulMap ? option.mulMap[field] : undefined,
});
}
return HttpResponse.json(res);
};
}
const Base = {
render(args) {
return {
components: {
MkChart,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkChart v-bind="props" />',
};
},
args: {
src: 'federation',
span: 'hour',
},
parameters: {
layout: 'centered',
msw: {
handlers: [
...commonHandlers,
http.get('/api/charts/federation', getChartResolver(
['deliveredInstances', 'inboxInstances', 'stalled', 'sub', 'pub', 'pubsub', 'subActive', 'pubActive'],
)),
http.get('/api/charts/notes', getChartResolver(
['local.total', 'remote.total'],
{ accumulate: true },
)),
http.get('/api/charts/drive', getChartResolver(
['local.incSize', 'local.decSize', 'remote.incSize', 'remote.decSize'],
{ mulMap: { 'local.incSize': 1e7, 'local.decSize': 5e6, 'remote.incSize': 1e6, 'remote.decSize': 5e5 } },
)),
],
},
},
} satisfies StoryObj<typeof MkChart>;
export const FederationChart = {
...Base,
args: {
src: 'federation',
},
} satisfies StoryObj<typeof MkChart>;
export const NotesTotalChart = {
...Base,
args: {
src: 'notes-total',
},
} satisfies StoryObj<typeof MkChart>;
export const DriveChart = {
...Base,
args: {
src: 'drive',
},
} satisfies StoryObj<typeof MkChart>;

View File

@@ -19,8 +19,9 @@ SPDX-License-Identifier: AGPL-3.0-only
id-denylist violation when setting it. This is causing about 60+ lint issues.
As this is part of Chart.js's API it makes sense to disable the check here.
*/
import { onMounted, ref, shallowRef, watch, PropType } from 'vue';
import { onMounted, ref, shallowRef, watch } from 'vue';
import { Chart } from 'chart.js';
import * as Misskey from 'misskey-js';
import { misskeyApiGet } from '@/scripts/misskey-api.js';
import { defaultStore } from '@/store.js';
import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
@@ -34,44 +35,55 @@ import MkChartLegend from '@/components/MkChartLegend.vue';
initChart();
const props = defineProps({
src: {
type: String,
required: true,
},
args: {
type: Object,
required: false,
},
limit: {
type: Number,
required: false,
default: 90,
},
span: {
type: String as PropType<'hour' | 'day'>,
required: true,
},
detailed: {
type: Boolean,
required: false,
default: false,
},
stacked: {
type: Boolean,
required: false,
default: false,
},
bar: {
type: Boolean,
required: false,
default: false,
},
aspectRatio: {
type: Number,
required: false,
default: null,
},
type ChartSrc =
| 'federation'
| 'ap-request'
| 'users'
| 'users-total'
| 'active-users'
| 'notes'
| 'local-notes'
| 'remote-notes'
| 'notes-total'
| 'drive'
| 'drive-files'
| 'instance-requests'
| 'instance-users'
| 'instance-users-total'
| 'instance-notes'
| 'instance-notes-total'
| 'instance-ff'
| 'instance-ff-total'
| 'instance-drive-usage'
| 'instance-drive-usage-total'
| 'instance-drive-files'
| 'instance-drive-files-total'
| 'per-user-notes'
| 'per-user-pv'
| 'per-user-following'
| 'per-user-followers'
| 'per-user-drive'
const props = withDefaults(defineProps<{
src: ChartSrc;
args?: {
host?: string;
user?: Misskey.entities.UserLite;
withoutAll?: boolean;
};
limit?: number;
span: 'hour' | 'day';
detailed?: boolean;
stacked?: boolean;
bar?: boolean;
aspectRatio?: number | null;
}>(), {
args: undefined,
limit: 90,
detailed: false,
stacked: false,
bar: false,
aspectRatio: null,
});
const legendEl = shallowRef<InstanceType<typeof MkChartLegend>>();

View File

@@ -0,0 +1,7 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import MkChartLegend from './MkChartLegend.vue';
void MkChartLegend;

View File

@@ -0,0 +1,7 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import MkChartTooltip from './MkChartTooltip.vue';
void MkChartTooltip;

View File

@@ -0,0 +1,79 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import { HttpResponse, http } from 'msw';
import { action } from '@storybook/addon-actions';
import { expect, within } from '@storybook/test';
import { commonHandlers } from '../../.storybook/mocks.js';
import MkClickerGame from './MkClickerGame.vue';
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
export const Default = {
render(args) {
return {
components: {
MkClickerGame,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkClickerGame v-bind="props" />',
};
},
async play({ canvasElement }) {
await sleep(1000);
const canvas = within(canvasElement);
const count = canvas.getByTestId('count');
// NOTE: flaky なので N/A も通しておく
await expect(count).toHaveTextContent(/^(0|N\/A)$/);
// FIXME: flaky
// const buttonElement = canvas.getByRole<HTMLButtonElement>('button');
// await userEvent.click(buttonElement);
// await expect(count).toHaveTextContent('1');
},
parameters: {
layout: 'centered',
msw: {
handlers: [
...commonHandlers,
http.post('/api/i/registry/get', async ({ request }) => {
action('POST /api/i/registry/get')(await request.json());
return HttpResponse.json({
error: {
message: 'No such key.',
code: 'NO_SUCH_KEY',
id: 'ac3ed68a-62f0-422b-a7bc-d5e09e8f6a6a',
},
}, {
status: 400,
});
}),
http.post('/api/i/registry/set', async ({ request }) => {
action('POST /api/i/registry/set')(await request.json());
return HttpResponse.json(undefined, { status: 204 });
}),
http.post('/api/i/claim-achievement', async ({ request }) => {
action('POST /api/i/claim-achievement')(await request.json());
return HttpResponse.json(undefined, { status: 204 });
}),
],
},
},
} satisfies StoryObj<typeof MkClickerGame>;

View File

@@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div>
<div v-if="game.ready" :class="$style.game">
<div :class="$style.cps" class="">{{ number(cps) }}cps</div>
<div :class="$style.count" class=""><i class="ti ti-cookie" style="font-size: 70%;"></i> {{ number(cookies) }}</div>
<div :class="$style.count" class="" data-testid="count"><i class="ti ti-cookie" style="font-size: 70%;"></i> {{ number(cookies) }}</div>
<button v-click-anime class="_button" @click="onClick">
<img src="/client-assets/cookie.png" :class="$style.img">
</button>

View File

@@ -0,0 +1,43 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import { clip } from '../../.storybook/fakes.js';
import MkClipPreview from './MkClipPreview.vue';
export const Default = {
render(args) {
return {
components: {
MkClipPreview,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkClipPreview v-bind="props" />',
};
},
args: {
clip: clip(),
},
parameters: {
layout: 'fullscreen',
},
decorators: [
() => ({
template: '<div style="display: flex; align-items: center; justify-content: center; height: 100vh"><div style="max-width: 700px; width: 100%; margin: 3rem"><story/></div></div>',
}),
],
} satisfies StoryObj<typeof MkClipPreview>;

View File

@@ -0,0 +1,7 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import MkCode_core from './MkCode.core.vue';
void MkCode_core;

View File

@@ -0,0 +1,44 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import MkCode from './MkCode.vue';
const code = `for (let i, 100) {
<: if (i % 15 == 0) "FizzBuzz"
elif (i % 3 == 0) "Fizz"
elif (i % 5 == 0) "Buzz"
else i
}`;
export const Default = {
render(args) {
return {
components: {
MkCode,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkCode v-bind="props" />',
};
},
args: {
code,
lang: 'is',
},
parameters: {
layout: 'centered',
},
} satisfies StoryObj<typeof MkCode>;

View File

@@ -0,0 +1,62 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import { action } from '@storybook/addon-actions';
import MkCodeEditor from './MkCodeEditor.vue';
const code = `for (let i, 100) {
<: if (i % 15 == 0) "FizzBuzz"
elif (i % 3 == 0) "Fizz"
elif (i % 5 == 0) "Buzz"
else i
}`;
export const Default = {
render(args) {
return {
components: {
MkCodeEditor,
},
data() {
return {
code,
};
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
events() {
return {
'change': action('change'),
'keydown': action('keydown'),
'enter': action('enter'),
'update:modelValue': action('update:modelValue'),
};
},
},
template: '<MkCodeEditor v-model="code" v-bind="props" v-on="events" />',
};
},
args: {
lang: 'aiscript',
},
parameters: {
layout: 'fullscreen',
},
decorators: [
() => ({
template: '<div style="display: flex; align-items: center; justify-content: center; height: 100vh"><div style="max-width: 800px; width: 100%; margin: 3rem"><Suspense><story/></Suspense></div></div>',
}),
],
} satisfies StoryObj<typeof MkCodeEditor>;

View File

@@ -0,0 +1,37 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import MkCodeInline from './MkCodeInline.vue';
export const Default = {
render(args) {
return {
components: {
MkCodeInline,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkCodeInline v-bind="props"/>',
};
},
args: {
code: '<: "Hello, world!"',
},
parameters: {
layout: 'centered',
},
} satisfies StoryObj<typeof MkCodeInline>;

View File

@@ -0,0 +1,50 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import { action } from '@storybook/addon-actions';
import MkColorInput from './MkColorInput.vue';
export const Default = {
render(args) {
return {
components: {
MkColorInput,
},
data() {
return {
color: '#cccccc',
};
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
events() {
return {
'update:modelValue': action('update:modelValue'),
};
},
},
template: '<MkColorInput v-model="color" v-bind="props" v-on="events" />',
};
},
parameters: {
layout: 'fullscreen',
},
decorators: [
() => ({
template: '<div style="display: flex; align-items: center; justify-content: center; height: 100vh"><div style="max-width: 800px; width: 100%; margin: 3rem"><story/></div></div>',
}),
],
} satisfies StoryObj<typeof MkColorInput>;

View File

@@ -0,0 +1,7 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import MkContainer from './MkContainer.vue';
void MkContainer;

View File

@@ -0,0 +1,58 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import { userEvent, within } from '@storybook/test';
import MkContextMenu from './MkContextMenu.vue';
import * as os from '@/os.js';
export const Empty = {
render(args) {
return {
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
methods: {
onContextmenu(ev: MouseEvent) {
os.contextMenu(args.items, ev);
},
},
template: '<div @contextmenu.stop="onContextmenu">Right Click Here</div>',
};
},
args: {
items: [],
},
async play({ canvasElement }) {
const canvas = within(canvasElement);
const target = canvas.getByText('Right Click Here');
await userEvent.pointer({ keys: '[MouseRight>]', target });
},
parameters: {
layout: 'centered',
},
} satisfies StoryObj<typeof MkContextMenu>;
export const SomeTabs = {
...Empty,
args: {
items: [
{
text: 'Home',
icon: 'ti ti-home',
action() {},
},
],
},
} satisfies StoryObj<typeof MkContextMenu>;

View File

@@ -0,0 +1,75 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import { HttpResponse, http } from 'msw';
import { action } from '@storybook/addon-actions';
import { file } from '../../.storybook/fakes.js';
import { commonHandlers } from '../../.storybook/mocks.js';
import MkCropperDialog from './MkCropperDialog.vue';
export const Default = {
render(args) {
return {
components: {
MkCropperDialog,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
events() {
return {
'ok': action('ok'),
'cancel': action('cancel'),
'closed': action('closed'),
};
},
},
template: '<MkCropperDialog v-bind="props" v-on="events" />',
};
},
args: {
file: file(),
aspectRatio: NaN,
},
parameters: {
chromatic: {
// NOTE: ロードが終わるまで待つ
delay: 3000,
},
layout: 'centered',
msw: {
handlers: [
...commonHandlers,
http.get('/proxy/image.webp', async ({ request }) => {
const url = new URL(request.url).searchParams.get('url');
if (url === 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true') {
const image = await (await fetch('client-assets/fedi.jpg')).blob();
return new HttpResponse(image, {
headers: {
'Content-Type': 'image/jpeg',
},
});
} else {
return new HttpResponse(null, { status: 404 });
}
}),
http.post('/api/drive/files/create', async ({ request }) => {
action('POST /api/drive/files/create')(await request.formData());
return HttpResponse.json(file());
}),
],
},
},
} satisfies StoryObj<typeof MkCropperDialog>;

View File

@@ -0,0 +1,38 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import { emojiDetailed } from '../../.storybook/fakes.js';
import MkCustomEmojiDetailedDialog from './MkCustomEmojiDetailedDialog.vue';
export const Default = {
render(args) {
return {
components: {
MkCustomEmojiDetailedDialog,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkCustomEmojiDetailedDialog v-bind="props" />',
};
},
args: {
emoji: emojiDetailed(),
},
parameters: {
layout: 'centered',
},
} satisfies StoryObj<typeof MkCustomEmojiDetailedDialog>;

View File

@@ -0,0 +1,89 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import { action } from '@storybook/addon-actions';
import { expect, userEvent, within } from '@storybook/test';
import { file } from '../../.storybook/fakes.js';
import MkCwButton from './MkCwButton.vue';
import { i18n } from '@/i18n.js';
import { semaphore } from '@/scripts/test-utils.js';
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
const s = semaphore();
export const Default = {
render(args) {
return {
components: {
MkCwButton,
},
data() {
return {
showContent: false,
};
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
events() {
return {
'update:modelValue': action('update:modelValue'),
};
},
},
template: '<MkCwButton v-model="showContent" v-bind="props" v-on="events" />',
};
},
args: {
text: 'Some CW content',
},
async play({ canvasElement }) {
await s.acquire();
await sleep(1000);
const canvas = within(canvasElement);
const buttonElement = canvas.getByRole<HTMLButtonElement>('button');
await expect(buttonElement).toHaveTextContent(i18n.ts._cw.show);
await expect(buttonElement).toHaveTextContent(i18n.tsx._cw.chars({ count: 15 }));
await userEvent.click(buttonElement);
await expect(buttonElement).toHaveTextContent(i18n.ts._cw.hide);
await userEvent.click(buttonElement);
s.release();
},
parameters: {
chromatic: {
// NOTE: テストが終わるまで待つ
delay: 5000,
},
layout: 'centered',
},
} satisfies StoryObj<typeof MkCwButton>;
export const IncludesTextAndDriveFile = {
...Default,
args: {
text: 'Some CW content',
files: [file()],
},
async play({ canvasElement }) {
const canvas = within(canvasElement);
const buttonElement = canvas.getByRole<HTMLButtonElement>('button');
await expect(buttonElement).toHaveTextContent(i18n.tsx._cw.chars({ count: 15 }));
await expect(buttonElement).toHaveTextContent(' / ');
await expect(buttonElement).toHaveTextContent(i18n.tsx._cw.files({ count: 1 }));
},
} satisfies StoryObj<typeof MkCwButton>;

View File

@@ -0,0 +1,32 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div
class="default" :style="[
marginTopBottom ? { marginTop: marginTopBottom, marginBottom: marginTopBottom } : {},
marginLeftRight ? { marginLeft: marginLeftRight, marginRight: marginLeftRight } : {},
borderStyle ? { borderStyle: borderStyle } : {},
borderWidth ? { borderWidth: borderWidth } : {},
borderColor ? { borderColor: borderColor } : {},
]"
/>
</template>
<script setup lang="ts">
defineProps<{
marginTopBottom?: string;
marginLeftRight?: string;
borderStyle?: string;
borderWidth?: string;
borderColor?: string;
}>();
</script>
<style scoped lang="scss">
.default {
border-top: solid 0.5px var(--divider);
}
</style>

View File

@@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
@keydown.enter="toggle"
>
<XButton :checked="checked" :disabled="disabled" @toggle="toggle"/>
<span :class="$style.body">
<span v-if="!noBody" :class="$style.body">
<!-- TODO: 無名slotの方は廃止 -->
<span :class="$style.label">
<span @click="toggle">
@@ -34,16 +34,19 @@ const props = defineProps<{
modelValue: boolean | Ref<boolean>;
disabled?: boolean;
helpText?: string;
noBody?: boolean;
}>();
const emit = defineEmits<{
(ev: 'update:modelValue', v: boolean): void;
(ev: 'change', v: boolean): void;
}>();
const checked = toRefs(props).modelValue;
const toggle = () => {
if (props.disabled) return;
emit('update:modelValue', !checked.value);
emit('change', !checked.value);
};
</script>

View File

@@ -0,0 +1,45 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineAsyncComponent } from 'vue';
import * as os from '@/os.js';
export type SystemWebhookEventType = 'abuseReport' | 'abuseReportResolved';
export type MkSystemWebhookEditorProps = {
mode: 'create' | 'edit';
id?: string;
requiredEvents?: SystemWebhookEventType[];
};
export type MkSystemWebhookResult = {
id?: string;
isActive: boolean;
name: string;
on: SystemWebhookEventType[];
url: string;
secret: string;
};
export async function showSystemWebhookEditorDialog(props: MkSystemWebhookEditorProps): Promise<MkSystemWebhookResult | null> {
const { dispose, result } = await new Promise<{ dispose: () => void, result: MkSystemWebhookResult | null }>(async resolve => {
const res = await os.popup(
defineAsyncComponent(() => import('@/components/MkSystemWebhookEditor.vue')),
props,
{
submitted: (ev: MkSystemWebhookResult) => {
resolve({ dispose: res.dispose, result: ev });
},
closed: () => {
resolve({ dispose: res.dispose, result: null });
},
},
);
});
dispose();
return result;
}

View File

@@ -0,0 +1,217 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModalWindow
:width="450"
:height="590"
:canClose="true"
:withOkButton="false"
:okButtonDisabled="false"
@click="onCancelClicked"
@close="onCancelClicked"
@closed="onCancelClicked"
>
<template #header>
{{ mode === 'create' ? i18n.ts._webhookSettings.createWebhook : i18n.ts._webhookSettings.modifyWebhook }}
</template>
<MkSpacer :marginMin="20" :marginMax="28">
<MkLoading v-if="loading !== 0"/>
<div v-else :class="$style.root" class="_gaps_m">
<MkInput v-model="title">
<template #label>{{ i18n.ts._webhookSettings.name }}</template>
</MkInput>
<MkInput v-model="url">
<template #label>URL</template>
</MkInput>
<MkInput v-model="secret">
<template #label>{{ i18n.ts._webhookSettings.secret }}</template>
</MkInput>
<MkFolder :defaultOpen="true">
<template #label>{{ i18n.ts._webhookSettings.events }}</template>
<div class="_gaps_s">
<MkSwitch v-model="events.abuseReport" :disabled="disabledEvents.abuseReport">
<template #label>{{ i18n.ts._webhookSettings._systemEvents.abuseReport }}</template>
</MkSwitch>
<MkSwitch v-model="events.abuseReportResolved" :disabled="disabledEvents.abuseReportResolved">
<template #label>{{ i18n.ts._webhookSettings._systemEvents.abuseReportResolved }}</template>
</MkSwitch>
</div>
</MkFolder>
<MkSwitch v-model="isActive">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
<div :class="$style.footer" class="_buttonsCenter">
<MkButton primary :disabled="disableSubmitButton" @click="onSubmitClicked">
<i class="ti ti-check"></i>
{{ i18n.ts.ok }}
</MkButton>
<MkButton @click="onCancelClicked"><i class="ti ti-x"></i> {{ i18n.ts.cancel }}</MkButton>
</div>
</div>
</MkSpacer>
</MkModalWindow>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, toRefs } from 'vue';
import FormSection from '@/components/form/section.vue';
import MkInput from '@/components/MkInput.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import {
MkSystemWebhookEditorProps,
MkSystemWebhookResult,
SystemWebhookEventType,
} from '@/components/MkSystemWebhookEditor.impl.js';
import { i18n } from '@/i18n.js';
import MkButton from '@/components/MkButton.vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkFolder from '@/components/MkFolder.vue';
import * as os from '@/os.js';
type EventType = {
abuseReport: boolean;
abuseReportResolved: boolean;
}
const emit = defineEmits<{
(ev: 'submitted', result: MkSystemWebhookResult): void;
(ev: 'closed'): void;
}>();
const props = defineProps<MkSystemWebhookEditorProps>();
const { mode, id, requiredEvents } = toRefs(props);
const loading = ref<number>(0);
const title = ref<string>('');
const url = ref<string>('');
const secret = ref<string>('');
const events = ref<EventType>({
abuseReport: true,
abuseReportResolved: true,
});
const isActive = ref<boolean>(true);
const disabledEvents = ref<EventType>({
abuseReport: false,
abuseReportResolved: false,
});
const disableSubmitButton = computed(() => {
if (!title.value) {
return true;
}
if (!url.value) {
return true;
}
if (!secret.value) {
return true;
}
return false;
});
async function onSubmitClicked() {
await loadingScope(async () => {
const params = {
isActive: isActive.value,
name: title.value,
url: url.value,
secret: secret.value,
on: Object.keys(events.value).filter(ev => events.value[ev as keyof EventType]) as SystemWebhookEventType[],
};
try {
switch (mode.value) {
case 'create': {
const result = await misskeyApi('admin/system-webhook/create', params);
emit('submitted', result);
break;
}
case 'edit': {
// eslint-disable-next-line
const result = await misskeyApi('admin/system-webhook/update', { id: id.value!, ...params });
emit('submitted', result);
break;
}
}
// eslint-disable-next-line
} catch (ex: any) {
const msg = ex.message ?? i18n.ts.internalServerErrorDescription;
await os.alert({ type: 'error', title: i18n.ts.error, text: msg });
emit('closed');
}
});
}
function onCancelClicked() {
emit('closed');
}
async function loadingScope<T>(fn: () => Promise<T>): Promise<T> {
loading.value++;
try {
return await fn();
} finally {
loading.value--;
}
}
onMounted(async () => {
await loadingScope(async () => {
switch (mode.value) {
case 'edit': {
if (!id.value) {
throw new Error('id is required');
}
try {
const res = await misskeyApi('admin/system-webhook/show', { id: id.value });
title.value = res.name;
url.value = res.url;
secret.value = res.secret;
isActive.value = res.isActive;
for (const ev of Object.keys(events.value)) {
events.value[ev] = res.on.includes(ev as SystemWebhookEventType);
}
// eslint-disable-next-line
} catch (ex: any) {
const msg = ex.message ?? i18n.ts.internalServerErrorDescription;
await os.alert({ type: 'error', title: i18n.ts.error, text: msg });
emit('closed');
}
break;
}
}
for (const ev of requiredEvents.value ?? []) {
disabledEvents.value[ev] = true;
}
});
});
</script>
<style module lang="scss">
.root {
display: flex;
flex-direction: column;
justify-content: center;
align-items: stretch;
}
.footer {
display: flex;
justify-content: center;
align-items: flex-end;
margin-top: 20px;
}
</style>

View File

@@ -0,0 +1,307 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModalWindow
ref="dialog"
:width="400"
:height="490"
:withOkButton="false"
:okButtonDisabled="false"
@close="onCancelClicked"
@closed="emit('closed')"
>
<template #header>
{{ mode === 'create' ? i18n.ts._abuseReport._notificationRecipient.createRecipient : i18n.ts._abuseReport._notificationRecipient.modifyRecipient }}
</template>
<div v-if="loading === 0">
<MkSpacer :marginMin="20" :marginMax="28">
<div :class="$style.root" class="_gaps_m">
<MkInput v-model="title">
<template #label>{{ i18n.ts.title }}</template>
</MkInput>
<MkSelect v-model="method">
<template #label>{{ i18n.ts._abuseReport._notificationRecipient.recipientType }}</template>
<option value="email">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.mail }}</option>
<option value="webhook">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.webhook }}</option>
<template #caption>
{{ methodCaption }}
</template>
</MkSelect>
<div>
<MkSelect v-if="method === 'email'" v-model="userId">
<template #label>{{ i18n.ts._abuseReport._notificationRecipient.notifiedUser }}</template>
<option v-for="user in moderators" :key="user.id" :value="user.id">
{{ user.name ? `${user.name}(${user.username})` : user.username }}
</option>
</MkSelect>
<div v-else-if="method === 'webhook'" :class="$style.systemWebhook">
<MkSelect v-model="systemWebhookId" style="flex: 1">
<template #label>{{ i18n.ts._abuseReport._notificationRecipient.notifiedWebhook }}</template>
<option v-for="webhook in systemWebhooks" :key="webhook.id ?? undefined" :value="webhook.id">
{{ webhook.name }}
</option>
</MkSelect>
<MkButton rounded @click="onEditSystemWebhookClicked">
<span v-if="systemWebhookId === null" class="ti ti-plus" style="line-height: normal"/>
<span v-else class="ti ti-settings" style="line-height: normal"/>
</MkButton>
</div>
</div>
<MkDivider/>
<MkSwitch v-model="isActive">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
</div>
</MkSpacer>
<div :class="$style.footer" class="_buttonsCenter">
<MkButton primary :disabled="disableSubmitButton" @click="onSubmitClicked"><i class="ti ti-check"></i> {{ i18n.ts.ok }}</MkButton>
<MkButton @click="onCancelClicked"><i class="ti ti-x"></i> {{ i18n.ts.cancel }}</MkButton>
</div>
</div>
<div v-else>
<MkLoading/>
</div>
</MkModalWindow>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref, toRefs } from 'vue';
import { entities } from 'misskey-js';
import MkButton from '@/components/MkButton.vue';
import MkModalWindow from '@/components/MkModalWindow.vue';
import { i18n } from '@/i18n.js';
import MkInput from '@/components/MkInput.vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
import MkSelect from '@/components/MkSelect.vue';
import { MkSystemWebhookResult, showSystemWebhookEditorDialog } from '@/components/MkSystemWebhookEditor.impl.js';
import MkSwitch from '@/components/MkSwitch.vue';
import MkDivider from '@/components/MkDivider.vue';
import * as os from '@/os.js';
type NotificationRecipientMethod = 'email' | 'webhook';
const emit = defineEmits<{
(ev: 'submitted'): void;
(ev: 'closed'): void;
}>();
const props = defineProps<{
mode: 'create' | 'edit';
id?: string;
}>();
const { mode, id } = toRefs(props);
const loading = ref<number>(0);
const title = ref<string>('');
const method = ref<NotificationRecipientMethod>('email');
const userId = ref<string | null>(null);
const systemWebhookId = ref<string | null>(null);
const isActive = ref<boolean>(true);
const moderators = ref<entities.User[]>([]);
const systemWebhooks = ref<(entities.SystemWebhook | { id: null, name: string })[]>([]);
const methodCaption = computed(() => {
switch (method.value) {
case 'email': {
return i18n.ts._abuseReport._notificationRecipient._recipientType._captions.mail;
}
case 'webhook': {
return i18n.ts._abuseReport._notificationRecipient._recipientType._captions.webhook;
}
default: {
return '';
}
}
});
const disableSubmitButton = computed(() => {
if (!title.value) {
return true;
}
switch (method.value) {
case 'email': {
return userId.value === null;
}
case 'webhook': {
return systemWebhookId.value === null;
}
default: {
return true;
}
}
});
async function onSubmitClicked() {
await loadingScope(async () => {
const _userId = (method.value === 'email') ? userId.value : null;
const _systemWebhookId = (method.value === 'webhook') ? systemWebhookId.value : null;
const params = {
isActive: isActive.value,
name: title.value,
method: method.value,
userId: _userId ?? undefined,
systemWebhookId: _systemWebhookId ?? undefined,
};
try {
switch (mode.value) {
case 'create': {
await misskeyApi('admin/abuse-report/notification-recipient/create', params);
break;
}
case 'edit': {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await misskeyApi('admin/abuse-report/notification-recipient/update', { id: id.value!, ...params });
break;
}
}
emit('submitted');
// eslint-disable-next-line
} catch (ex: any) {
const msg = ex.message ?? i18n.ts.internalServerErrorDescription;
await os.alert({ type: 'error', title: i18n.ts.error, text: msg });
emit('closed');
}
});
}
function onCancelClicked() {
emit('closed');
}
async function onEditSystemWebhookClicked() {
let result: MkSystemWebhookResult | null;
if (systemWebhookId.value === null) {
result = await showSystemWebhookEditorDialog({
mode: 'create',
});
} else {
result = await showSystemWebhookEditorDialog({
mode: 'edit',
id: systemWebhookId.value,
});
}
if (!result) {
return;
}
await fetchSystemWebhooks();
systemWebhookId.value = result.id ?? null;
}
async function fetchSystemWebhooks() {
await loadingScope(async () => {
systemWebhooks.value = [
{ id: null, name: i18n.ts.createNew },
...await misskeyApi('admin/system-webhook/list', { }),
];
});
}
async function fetchModerators() {
await loadingScope(async () => {
const users = Array.of<entities.User>();
for (; ;) {
const res = await misskeyApi('admin/show-users', {
limit: 100,
state: 'adminOrModerator',
origin: 'local',
offset: users.length,
});
if (res.length === 0) {
break;
}
users.push(...res);
}
moderators.value = users;
});
}
async function loadingScope<T>(fn: () => Promise<T>): Promise<T> {
loading.value++;
try {
return await fn();
} finally {
loading.value--;
}
}
onMounted(async () => {
await loadingScope(async () => {
await fetchModerators();
await fetchSystemWebhooks();
if (mode.value === 'edit') {
if (!id.value) {
throw new Error('id is required');
}
try {
const res = await misskeyApi('admin/abuse-report/notification-recipient/show', { id: id.value });
title.value = res.name;
method.value = res.method;
userId.value = res.userId ?? null;
systemWebhookId.value = res.systemWebhookId ?? null;
isActive.value = res.isActive;
// eslint-disable-next-line
} catch (ex: any) {
const msg = ex.message ?? i18n.ts.internalServerErrorDescription;
await os.alert({ type: 'error', title: i18n.ts.error, text: msg });
emit('closed');
}
} else {
userId.value = moderators.value[0]?.id ?? null;
systemWebhookId.value = systemWebhooks.value[0]?.id ?? null;
}
});
});
</script>
<style lang="scss" module>
.root {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
}
.footer {
display: flex;
justify-content: center;
align-items: flex-end;
margin-top: 20px;
}
.systemWebhook {
display: flex;
flex-direction: row;
justify-content: stretch;
align-items: flex-end;
gap: 8px;
button {
width: 2.5em;
height: 2.5em;
min-width: 2.5em;
min-height: 2.5em;
box-sizing: border-box;
padding: 6px;
}
}
</style>

View File

@@ -0,0 +1,114 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.root" class="_panel _gaps_s">
<div :class="$style.rightDivider" style="width: 80px;"><span :class="`ti ${methodIcon}`"/> {{ methodName }}</div>
<div :class="$style.rightDivider" style="flex: 0.5">{{ entity.name }}</div>
<div :class="$style.rightDivider" style="flex: 1">
<div v-if="method === 'email' && user">
{{
`${i18n.ts._abuseReport._notificationRecipient.notifiedUser}: ` + ((user.name) ? `${user.name}(${user.username})` : user.username)
}}
</div>
<div v-if="method === 'webhook' && systemWebhook">
{{ `${i18n.ts._abuseReport._notificationRecipient.notifiedWebhook}: ` + systemWebhook.name }}
</div>
</div>
<div :class="$style.recipientButtons" style="margin-left: auto">
<button :class="$style.recipientButton" @click="onEditButtonClicked()">
<span class="ti ti-settings"/>
</button>
<button :class="$style.recipientButton" @click="onDeleteButtonClicked()">
<span class="ti ti-trash"/>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { entities } from 'misskey-js';
import { computed, toRefs } from 'vue';
import { i18n } from '@/i18n.js';
const emit = defineEmits<{
(ev: 'edit', id: entities.AbuseReportNotificationRecipient['id']): void;
(ev: 'delete', id: entities.AbuseReportNotificationRecipient['id']): void;
}>();
const props = defineProps<{
entity: entities.AbuseReportNotificationRecipient;
}>();
const { entity } = toRefs(props);
const method = computed(() => entity.value.method);
const user = computed(() => entity.value.user);
const systemWebhook = computed(() => entity.value.systemWebhook);
const methodIcon = computed(() => {
switch (entity.value.method) {
case 'email':
return 'ti-mail';
case 'webhook':
return 'ti-webhook';
default:
return 'ti-help';
}
});
const methodName = computed(() => {
switch (entity.value.method) {
case 'email':
return i18n.ts._abuseReport._notificationRecipient._recipientType.mail;
case 'webhook':
return i18n.ts._abuseReport._notificationRecipient._recipientType.webhook;
default:
return '不明';
}
});
function onEditButtonClicked() {
emit('edit', entity.value.id);
}
function onDeleteButtonClicked() {
emit('delete', entity.value.id);
}
</script>
<style module lang="scss">
.root {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
padding: 4px 8px;
}
.rightDivider {
border-right: 0.5px solid var(--divider);
}
.recipientButtons {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
margin-right: -4;
}
.recipientButton {
background-color: transparent;
border: none;
border-radius: 9999px;
box-sizing: border-box;
margin-top: -2px;
margin-bottom: -2px;
padding: 8px;
&:hover {
background-color: var(--buttonBg);
}
}
</style>

View File

@@ -0,0 +1,176 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header>
<XHeader :actions="headerActions" :tabs="headerTabs"/>
</template>
<MkSpacer :contentMax="900">
<div :class="$style.root" class="_gaps_m">
<div :class="$style.addButton">
<MkButton primary @click="onAddButtonClicked">
<span class="ti ti-plus"/> {{ i18n.ts._abuseReport._notificationRecipient.createRecipient }}
</MkButton>
</div>
<div :class="$style.subMenus" class="_gaps_s">
<MkSelect v-model="filterMethod" style="flex: 1">
<template #label>{{ i18n.ts._abuseReport._notificationRecipient.recipientType }}</template>
<option :value="null">-</option>
<option :value="'email'">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.mail }}</option>
<option :value="'webhook'">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.webhook }}</option>
</MkSelect>
<MkInput v-model="filterText" type="search" style="flex: 1">
<template #label>{{ i18n.ts._abuseReport._notificationRecipient.keywords }}</template>
</MkInput>
</div>
<MkDivider/>
<div :class="$style.recipients" class="_gaps_s">
<XRecipient
v-for="r in filteredRecipients"
:key="r.id"
:entity="r"
@edit="onEditButtonClicked"
@delete="onDeleteButtonClicked"
/>
</div>
</div>
</MkSpacer>
</MkStickyContainer>
</template>
<script setup lang="ts">
import { entities } from 'misskey-js';
import { computed, defineAsyncComponent, onMounted, ref } from 'vue';
import XRecipient from './notification-recipient.item.vue';
import XHeader from '@/pages/admin/_header_.vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
import MkInput from '@/components/MkInput.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js';
import MkDivider from '@/components/MkDivider.vue';
import { i18n } from '@/i18n.js';
const recipients = ref<entities.AbuseReportNotificationRecipient[]>([]);
const filterMethod = ref<string | null>(null);
const filterText = ref<string>('');
const filteredRecipients = computed(() => {
const method = filterMethod.value;
const text = filterText.value.trim().length === 0 ? null : filterText.value;
return recipients.value.filter(it => {
if (method ?? text) {
if (text) {
const keywords = [it.name, it.systemWebhook?.name, it.user?.name, it.user?.username];
if (keywords.filter(k => k?.includes(text)).length !== 0) {
return true;
}
}
if (method) {
return it.method.includes(method);
}
return false;
}
return true;
});
});
const headerActions = computed(() => []);
const headerTabs = computed(() => []);
async function onAddButtonClicked() {
await showEditor('create');
}
async function onEditButtonClicked(id: string) {
await showEditor('edit', id);
}
async function onDeleteButtonClicked(id: string) {
const res = await os.confirm({
type: 'warning',
title: i18n.ts._abuseReport._notificationRecipient.deleteConfirm,
});
if (!res.canceled) {
await misskeyApi('admin/abuse-report/notification-recipient/delete', { id: id });
await fetchRecipients();
}
}
async function showEditor(mode: 'create' | 'edit', id?: string) {
const { dispose, needLoad } = await new Promise<{ dispose: () => void, needLoad: boolean }>(async resolve => {
const res = await os.popup(
defineAsyncComponent(() => import('./notification-recipient.editor.vue')),
{
mode,
id,
},
{
submitted: async () => {
resolve({ dispose: res.dispose, needLoad: true });
},
closed: () => {
resolve({ dispose: res.dispose, needLoad: false });
},
},
);
});
dispose();
if (needLoad) {
await fetchRecipients();
}
}
async function fetchRecipients() {
const result = await misskeyApi('admin/abuse-report/notification-recipient/list', {
method: ['email', 'webhook'],
});
recipients.value = result.sort((a, b) => (a.method + a.id).localeCompare(b.method + b.id));
}
onMounted(async () => {
await fetchRecipients();
});
</script>
<style module lang="scss">
.root {
display: flex;
flex-direction: column;
justify-content: center;
align-items: stretch;
}
.addButton {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.subMenus {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-end;
}
.recipients {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
}
</style>

View File

@@ -7,30 +7,33 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkStickyContainer>
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="900">
<div>
<div class="reports">
<div class="">
<div class="inputs" style="display: flex;">
<MkSelect v-model="state" style="margin: 0; flex: 1;">
<template #label>{{ i18n.ts.state }}</template>
<option value="all">{{ i18n.ts.all }}</option>
<option value="unresolved">{{ i18n.ts.unresolved }}</option>
<option value="resolved">{{ i18n.ts.resolved }}</option>
</MkSelect>
<MkSelect v-model="targetUserOrigin" style="margin: 0; flex: 1;">
<template #label>{{ i18n.ts.reporteeOrigin }}</template>
<option value="combined">{{ i18n.ts.all }}</option>
<option value="local">{{ i18n.ts.local }}</option>
<option value="remote">{{ i18n.ts.remote }}</option>
</MkSelect>
<MkSelect v-model="reporterOrigin" style="margin: 0; flex: 1;">
<template #label>{{ i18n.ts.reporterOrigin }}</template>
<option value="combined">{{ i18n.ts.all }}</option>
<option value="local">{{ i18n.ts.local }}</option>
<option value="remote">{{ i18n.ts.remote }}</option>
</MkSelect>
</div>
<!-- TODO
<div :class="$style.root" class="_gaps">
<div :class="$style.subMenus" class="_gaps">
<MkButton link to="/admin/abuse-report-notification-recipient" primary>{{ "通知設定" }}</MkButton>
</div>
<div :class="$style.inputs" class="_gaps">
<MkSelect v-model="state" style="margin: 0; flex: 1;">
<template #label>{{ i18n.ts.state }}</template>
<option value="all">{{ i18n.ts.all }}</option>
<option value="unresolved">{{ i18n.ts.unresolved }}</option>
<option value="resolved">{{ i18n.ts.resolved }}</option>
</MkSelect>
<MkSelect v-model="targetUserOrigin" style="margin: 0; flex: 1;">
<template #label>{{ i18n.ts.reporteeOrigin }}</template>
<option value="combined">{{ i18n.ts.all }}</option>
<option value="local">{{ i18n.ts.local }}</option>
<option value="remote">{{ i18n.ts.remote }}</option>
</MkSelect>
<MkSelect v-model="reporterOrigin" style="margin: 0; flex: 1;">
<template #label>{{ i18n.ts.reporterOrigin }}</template>
<option value="combined">{{ i18n.ts.all }}</option>
<option value="local">{{ i18n.ts.local }}</option>
<option value="remote">{{ i18n.ts.remote }}</option>
</MkSelect>
</div>
<!-- TODO
<div class="inputs" style="display: flex; padding-top: 1.2em;">
<MkInput v-model="searchUsername" style="margin: 0; flex: 1;" type="text" :spellcheck="false">
<span>{{ i18n.ts.username }}</span>
@@ -41,11 +44,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
-->
<MkPagination v-slot="{items}" ref="reports" :pagination="pagination" style="margin-top: var(--margin);">
<XAbuseReport v-for="report in items" :key="report.id" :report="report" @resolved="resolved"/>
</MkPagination>
</div>
</div>
<MkPagination v-slot="{items}" ref="reports" :pagination="pagination" style="margin-top: var(--margin);">
<XAbuseReport v-for="report in items" :key="report.id" :report="report" @resolved="resolved"/>
</MkPagination>
</div>
</MkSpacer>
</MkStickyContainer>
@@ -60,6 +61,7 @@ import MkPagination from '@/components/MkPagination.vue';
import XAbuseReport from '@/components/MkAbuseReport.vue';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import MkButton from '@/components/MkButton.vue';
const reports = shallowRef<InstanceType<typeof MkPagination>>();
@@ -80,7 +82,7 @@ const pagination = {
};
function resolved(reportId) {
reports.value.removeItem(reportId);
reports.value?.removeItem(reportId);
}
const headerActions = computed(() => []);
@@ -92,3 +94,26 @@ definePageMetadata(() => ({
icon: 'ti ti-exclamation-circle',
}));
</script>
<style module lang="scss">
.root {
display: flex;
flex-direction: column;
justify-content: center;
align-items: stretch;
}
.subMenus {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
}
.inputs {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
</style>

View File

@@ -214,6 +214,11 @@ const menuDef = computed(() => [{
text: i18n.ts.externalServices,
to: '/admin/external-services',
active: currentPage.value?.route.name === 'external-services',
}, {
icon: 'ti ti-webhook',
text: 'Webhook',
to: '/admin/system-webhook',
active: currentPage.value?.route.name === 'system-webhook',
}, {
icon: 'ti ti-adjustments',
text: i18n.ts.other,

View File

@@ -8,9 +8,35 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>
<b
:class="{
[$style.logGreen]: ['createRole', 'addCustomEmoji', 'createGlobalAnnouncement', 'createUserAnnouncement', 'createAd', 'createInvitation', 'createAvatarDecoration'].includes(log.type),
[$style.logYellow]: ['markSensitiveDriveFile', 'resetPassword'].includes(log.type),
[$style.logRed]: ['suspend', 'deleteRole', 'suspendRemoteInstance', 'deleteGlobalAnnouncement', 'deleteUserAnnouncement', 'deleteCustomEmoji', 'deleteNote', 'deleteDriveFile', 'deleteAd', 'deleteAvatarDecoration'].includes(log.type)
[$style.logGreen]: [
'createRole',
'addCustomEmoji',
'createGlobalAnnouncement',
'createUserAnnouncement',
'createAd',
'createInvitation',
'createAvatarDecoration',
'createSystemWebhook',
'createAbuseReportNotificationRecipient',
].includes(log.type),
[$style.logYellow]: [
'markSensitiveDriveFile',
'resetPassword'
].includes(log.type),
[$style.logRed]: [
'suspend',
'deleteRole',
'suspendRemoteInstance',
'deleteGlobalAnnouncement',
'deleteUserAnnouncement',
'deleteCustomEmoji',
'deleteNote',
'deleteDriveFile',
'deleteAd',
'deleteAvatarDecoration',
'deleteSystemWebhook',
'deleteAbuseReportNotificationRecipient',
].includes(log.type)
}"
>{{ i18n.ts._moderationLogTypes[log.type] }}</b>
<span v-if="log.type === 'updateUserNote'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
@@ -40,6 +66,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="log.type === 'createAvatarDecoration'">: {{ log.info.avatarDecoration.name }}</span>
<span v-else-if="log.type === 'updateAvatarDecoration'">: {{ log.info.before.name }}</span>
<span v-else-if="log.type === 'deleteAvatarDecoration'">: {{ log.info.avatarDecoration.name }}</span>
<span v-else-if="log.type === 'createSystemWebhook'">: {{ log.info.webhook.name }}</span>
<span v-else-if="log.type === 'updateSystemWebhook'">: {{ log.info.before.name }}</span>
<span v-else-if="log.type === 'deleteSystemWebhook'">: {{ log.info.webhook.name }}</span>
<span v-else-if="log.type === 'createAbuseReportNotificationRecipient'">: {{ log.info.recipient.name }}</span>
<span v-else-if="log.type === 'updateAbuseReportNotificationRecipient'">: {{ log.info.before.name }}</span>
<span v-else-if="log.type === 'deleteAbuseReportNotificationRecipient'">: {{ log.info.recipient.name }}</span>
</template>
<template #icon>
<MkAvatar :user="log.user" :class="$style.avatar"/>
@@ -116,6 +148,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<CodeDiff :context="5" :hideHeader="true" :oldString="log.info.before ?? ''" :newString="log.info.after ?? ''" maxHeight="300px"/>
</div>
</template>
<template v-else-if="log.type === 'updateSystemWebhook'">
<div :class="$style.diff">
<CodeDiff :context="5" :hideHeader="true" :oldString="JSON5.stringify(log.info.before, null, '\t')" :newString="JSON5.stringify(log.info.after, null, '\t')" language="javascript" maxHeight="300px"/>
</div>
</template>
<template v-else-if="log.type === 'updateAbuseReportNotificationRecipient'">
<div :class="$style.diff">
<CodeDiff :context="5" :hideHeader="true" :oldString="JSON5.stringify(log.info.before, null, '\t')" :newString="JSON5.stringify(log.info.after, null, '\t')" language="javascript" maxHeight="300px"/>
</div>
</template>
<details>
<summary>raw</summary>

View File

@@ -0,0 +1,117 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.main">
<span :class="$style.icon">
<i v-if="!entity.isActive" class="ti ti-player-pause"/>
<i v-else-if="entity.latestStatus === null" class="ti ti-circle"/>
<i
v-else-if="[200, 201, 204].includes(entity.latestStatus)"
class="ti ti-check"
:style="{ color: 'var(--success)' }"
/>
<i v-else class="ti ti-alert-triangle" :style="{ color: 'var(--error)' }"/>
</span>
<span :class="$style.text">{{ entity.name || entity.url }}</span>
<span :class="$style.suffix">
<MkTime v-if="entity.latestSentAt" :time="entity.latestSentAt" style="margin-right: 8px"/>
<button :class="$style.suffixButton" @click="onEditClick">
<i class="ti ti-settings"></i>
</button>
<button :class="$style.suffixButton" @click="onDeleteClick">
<i class="ti ti-trash"></i>
</button>
</span>
</div>
</template>
<script lang="ts" setup>
import { entities } from 'misskey-js';
import { toRefs } from 'vue';
const emit = defineEmits<{
(ev: 'edit', value: entities.SystemWebhook): void;
(ev: 'delete', value: entities.SystemWebhook): void;
}>();
const props = defineProps<{
entity: entities.SystemWebhook;
}>();
const { entity } = toRefs(props);
function onEditClick() {
emit('edit', entity.value);
}
function onDeleteClick() {
emit('delete', entity.value);
}
</script>
<style module lang="scss">
.main {
display: flex;
align-items: center;
width: 100%;
box-sizing: border-box;
padding: 10px 14px;
background: var(--buttonBg);
border: none;
border-radius: 6px;
font-size: 0.9em;
&:hover {
text-decoration: none;
background: var(--buttonHoverBg);
}
&.active {
color: var(--accent);
background: var(--buttonHoverBg);
}
}
.icon {
margin-right: 0.75em;
flex-shrink: 0;
text-align: center;
color: var(--fgTransparentWeak);
}
.text {
flex-shrink: 1;
white-space: normal;
padding-right: 12px;
text-align: center;
}
.suffix {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gaps: 4px;
margin-left: auto;
margin-right: -8px;
opacity: 0.7;
white-space: nowrap;
}
.suffixButton {
background: transparent;
border: none;
border-radius: 9999px;
margin-top: -8px;
margin-bottom: -8px;
padding: 8px;
&:hover {
background: var(--buttonBg);
}
}
</style>

View File

@@ -0,0 +1,96 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header>
<XHeader :actions="headerActions" :tabs="headerTabs"/>
</template>
<MkSpacer :contentMax="900">
<div class="_gaps_m">
<MkButton :class="$style.linkButton" full @click="onCreateWebhookClicked">
{{ i18n.ts._webhookSettings.createWebhook }}
</MkButton>
<FormSection>
<div class="_gaps">
<XItem v-for="item in webhooks" :key="item.id" :entity="item" @edit="onEditButtonClicked" @delete="onDeleteButtonClicked"/>
</div>
</FormSection>
</div>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { entities } from 'misskey-js';
import XItem from './system-webhook.item.vue';
import FormSection from '@/components/form/section.vue';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { i18n } from '@/i18n.js';
import XHeader from '@/pages/admin/_header_.vue';
import MkButton from '@/components/MkButton.vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { showSystemWebhookEditorDialog } from '@/components/MkSystemWebhookEditor.impl.js';
import * as os from '@/os.js';
const webhooks = ref<entities.SystemWebhook[]>([]);
const headerActions = computed(() => []);
const headerTabs = computed(() => []);
async function onCreateWebhookClicked() {
await showSystemWebhookEditorDialog({
mode: 'create',
});
await fetchWebhooks();
}
async function onEditButtonClicked(webhook: entities.SystemWebhook) {
await showSystemWebhookEditorDialog({
mode: 'edit',
id: webhook.id,
});
await fetchWebhooks();
}
async function onDeleteButtonClicked(webhook: entities.SystemWebhook) {
const result = await os.confirm({
type: 'warning',
title: i18n.ts._webhookSettings.deleteConfirm,
});
if (!result.canceled) {
await misskeyApi('admin/system-webhook/delete', {
id: webhook.id,
});
await fetchWebhooks();
}
}
async function fetchWebhooks() {
const result = await misskeyApi('admin/system-webhook/list', {});
webhooks.value = result.sort((a, b) => a.id.localeCompare(b.id));
}
onMounted(async () => {
await fetchWebhooks();
});
definePageMetadata(() => ({
title: 'SystemWebhook',
icon: 'ti ti-webhook',
}));
</script>
<style module lang="scss">
.linkButton {
text-align: left;
padding: 10px 18px;
}
</style>

View File

@@ -97,7 +97,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="user.fields.length > 0" class="fields">
<dl v-for="(field, i) in user.fields" :key="i" class="field">
<dt class="name">
<Mfm :text="field.name" :plain="true" :colored="false"/>
<Mfm :text="field.name" :author="user" :plain="true" :colored="false"/>
</dt>
<dd class="value">
<Mfm :text="field.value" :author="user" :colored="false"/>

View File

@@ -471,6 +471,14 @@ const routes: RouteDef[] = [{
path: '/invites',
name: 'invites',
component: page(() => import('@/pages/admin/invites.vue')),
}, {
path: '/abuse-report-notification-recipient',
name: 'abuse-report-notification-recipient',
component: page(() => import('@/pages/admin/abuse-report/notification-recipient.vue')),
}, {
path: '/system-webhook',
name: 'system-webhook',
component: page(() => import('@/pages/admin/system-webhook.vue')),
}, {
path: '/',
component: page(() => import('@/pages/_empty_.vue')),

View File

@@ -7,3 +7,13 @@ export async function tick(): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
await new Promise((globalThis.requestIdleCallback ?? setTimeout) as never);
}
/**
* @see https://github.com/misskey-dev/misskey/issues/11267
*/
export function semaphore(counter = 0, waiting: (() => void)[] = []) {
return {
acquire: () => ++counter > 1 && new Promise<void>(resolve => waiting.push(resolve)),
release: () => --counter && waiting.pop()?.(),
};
}

View File

@@ -5,7 +5,7 @@ import { type UserConfig, defineConfig } from 'vite';
import locales from '../../locales/index.js';
import meta from '../../package.json';
import packageInfo from './package.json' assert { type: 'json' };
import packageInfo from './package.json' with { type: 'json' };
import pluginUnwindCssModuleClassName from './lib/rollup-plugin-unwind-css-module-class-name.js';
import pluginJson5 from './vite.json5.js';