mirror of
https://github.com/kokonect-link/cherrypick
synced 2024-11-27 06:18:46 +09:00
Merge remote-branch 'misskey/develop'
This commit is contained in:
commit
a69aa6b7ed
16
.github/workflows/report-api-diff.yml
vendored
16
.github/workflows/report-api-diff.yml
vendored
@ -79,15 +79,13 @@ jobs:
|
||||
if (( "$DIFF_BYTES" <= 1 )); then
|
||||
echo '차이점이 없습니다.' >> ./output.md
|
||||
else
|
||||
cat <<- EOF >> ./output.md
|
||||
<details>
|
||||
<summary>차이점은 여기에서 볼 수 있음</summary>
|
||||
|
||||
\`\`\`diff
|
||||
$(cat ./api.json.diff)
|
||||
\`\`\`
|
||||
</details>
|
||||
EOF
|
||||
echo '<details>' >> ./output.md
|
||||
echo '<summary>차이점은 여기에서 볼 수 있음</summary>' >> ./output.md
|
||||
echo >> ./output.md
|
||||
echo '```diff' >> ./output.md
|
||||
cat ./api.json.diff >> ./output.md
|
||||
echo '```' >> ./output.md
|
||||
echo '</details>' >> .output.md
|
||||
fi
|
||||
|
||||
echo "$FOOTER" >> ./output.md
|
||||
|
@ -2,6 +2,8 @@
|
||||
|
||||
### General
|
||||
- Feat: UserWebhookとSystemWebhookのテスト送信機能を追加 (#14445)
|
||||
- Feat: モデレーターはユーザーにかかわらずファイルが添付されているノートを検索できるように
|
||||
(Cherry-picked from https://github.com/MisskeyIO/misskey/pull/680)
|
||||
- Enhance: ユーザーによるコンテンツインポートの可否をロールポリシーで制御できるように
|
||||
|
||||
### Client
|
||||
@ -11,7 +13,9 @@
|
||||
- Enhance: アイコンデコレーション管理画面にプレビューを追加
|
||||
- Enhance: コントロールパネル内のファイル一覧でセンシティブなファイルを区別しやすく
|
||||
- Enhance: ScratchpadにUIインスペクターを追加
|
||||
- Enhance: Play編集画面の項目の並びを少しリデザイン
|
||||
- Fix: サーバーメトリクスが2つ以上あるとリロード直後の表示がおかしくなる問題を修正
|
||||
- Fix: コントロールパネル内のAp requests内のチャートの表示がおかしかった問題を修正
|
||||
- Fix: 月の違う同じ日はセパレータが表示されないのを修正
|
||||
- Fix: タッチ画面でレンジスライダーを操作するとツールチップが複数表示される問題を修正
|
||||
(Cherry-picked from https://github.com/taiyme/misskey/pull/265)
|
||||
@ -20,6 +24,7 @@
|
||||
- Fix: 設定変更時のリロード確認ダイアログが複数個表示されることがある問題を修正
|
||||
- Fix: ファイルの詳細ページのファイルの説明で改行が正しく表示されない問題を修正
|
||||
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/commit/bde6bb0bd2e8b0d027e724d2acdb8ae0585a8110)
|
||||
- Fix: 一部画面のページネーションが動作しにくくなっていたのを修正 ( #12766 , #11449 )
|
||||
|
||||
### Server
|
||||
- Feat: Misskey® Reactions Buffering Technology™ (RBT)により、リアクションの作成負荷を低減することが可能に
|
||||
@ -28,6 +33,7 @@
|
||||
- Fix: ファイルがサイズの制限を超えてアップロードされた際にエラーを返さなかった問題を修正
|
||||
- Fix: 外部ページを解析する際に、ページに紐づけられた関連リソースも読み込まれてしまう問題を修正
|
||||
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/commit/26e0412fbb91447c37e8fb06ffb0487346063bb8)
|
||||
- Fix: Continue importing from file if single emoji import fails
|
||||
- Fix: `Retry-After`ヘッダーが送信されなかった問題を修正
|
||||
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/commit/8a982c61c01909e7540ff1be9f019df07c3f0624)
|
||||
- Fix: サーバーサイドのDOM解析完了時にリソースを開放するように
|
||||
|
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "cherrypick",
|
||||
"version": "4.12.0-beta.1",
|
||||
"basedMisskeyVersion": "2024.9.0-alpha.7",
|
||||
"basedMisskeyVersion": "2024.9.0-alpha.8",
|
||||
"codename": "nasubi",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
@ -87,23 +87,30 @@ export class ImportCustomEmojisProcessorService {
|
||||
await this.emojisRepository.delete({
|
||||
name: emojiInfo.name,
|
||||
});
|
||||
const driveFile = await this.driveService.addFile({
|
||||
user: null,
|
||||
path: emojiPath,
|
||||
name: record.fileName,
|
||||
force: true,
|
||||
});
|
||||
await this.customEmojiService.add({
|
||||
name: emojiInfo.name,
|
||||
category: emojiInfo.category,
|
||||
host: null,
|
||||
aliases: emojiInfo.aliases,
|
||||
driveFile,
|
||||
license: emojiInfo.license,
|
||||
isSensitive: emojiInfo.isSensitive,
|
||||
localOnly: emojiInfo.localOnly,
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: [],
|
||||
});
|
||||
try {
|
||||
const driveFile = await this.driveService.addFile({
|
||||
user: null,
|
||||
path: emojiPath,
|
||||
name: record.fileName,
|
||||
force: true,
|
||||
});
|
||||
await this.customEmojiService.add({
|
||||
name: emojiInfo.name,
|
||||
category: emojiInfo.category,
|
||||
host: null,
|
||||
aliases: emojiInfo.aliases,
|
||||
driveFile,
|
||||
license: emojiInfo.license,
|
||||
isSensitive: emojiInfo.isSensitive,
|
||||
localOnly: emojiInfo.localOnly,
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: [],
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof Error || typeof e === 'string') {
|
||||
this.logger.error(`couldn't import ${emojiPath} for ${emojiInfo.name}: ${e}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
cleanup();
|
||||
|
@ -10,6 +10,7 @@ import { QueryService } from '@/core/QueryService.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '../../../error.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['drive', 'notes'],
|
||||
@ -61,12 +62,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
|
||||
private noteEntityService: NoteEntityService,
|
||||
private queryService: QueryService,
|
||||
private roleService: RoleService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
// Fetch file
|
||||
const file = await this.driveFilesRepository.findOneBy({
|
||||
id: ps.fileId,
|
||||
userId: me.id,
|
||||
userId: await this.roleService.isModerator(me) ? undefined : me.id,
|
||||
});
|
||||
|
||||
if (file == null) {
|
||||
|
@ -2,7 +2,7 @@
|
||||
"type": "module",
|
||||
"name": "cherrypick-js",
|
||||
"version": "4.12.0-beta.1",
|
||||
"basedMisskeyVersion": "2024.9.0-alpha.7",
|
||||
"basedMisskeyVersion": "2024.9.0-alpha.8",
|
||||
"description": "CherryPick SDK for JavaScript",
|
||||
"license": "MIT",
|
||||
"main": "./built/index.js",
|
||||
|
@ -36,19 +36,27 @@ export function getScrollPosition(el: HTMLElement | null): number {
|
||||
return container == null ? window.scrollY : container.scrollTop;
|
||||
}
|
||||
|
||||
export function onScrollTop(el: HTMLElement, cb: () => unknown, tolerance = 1, once = false) {
|
||||
export function onScrollTop(el: HTMLElement, cb: (topVisible: boolean) => unknown, tolerance = 1, once = false) {
|
||||
// とりあえず評価してみる
|
||||
if (el.isConnected && isTopVisible(el)) {
|
||||
cb();
|
||||
const firstTopVisible = isTopVisible(el);
|
||||
if (el.isConnected && firstTopVisible) {
|
||||
cb(firstTopVisible);
|
||||
if (once) return null;
|
||||
}
|
||||
|
||||
const container = getScrollContainer(el) ?? window;
|
||||
|
||||
// 以下のケースにおいて、cbが何度も呼び出されてしまって具合が悪いので1回呼んだら以降は無視するようにする
|
||||
// - スクロールイベントは1回のスクロールで複数回発生することがある
|
||||
// - toleranceの範囲内に収まる程度の微量なスクロールが発生した
|
||||
let prevTopVisible = firstTopVisible;
|
||||
const onScroll = () => {
|
||||
if (!document.body.contains(el)) return;
|
||||
if (isTopVisible(el, tolerance)) {
|
||||
cb();
|
||||
|
||||
const topVisible = isTopVisible(el, tolerance);
|
||||
if (topVisible !== prevTopVisible) {
|
||||
prevTopVisible = topVisible;
|
||||
cb(topVisible);
|
||||
if (once) removeListener();
|
||||
}
|
||||
};
|
||||
@ -126,6 +134,7 @@ export function scrollToBottom(
|
||||
|
||||
export function isTopVisible(el: HTMLElement, tolerance = 1): boolean {
|
||||
const scrollTop = getScrollPosition(el);
|
||||
if (_DEV_) console.log(scrollTop, tolerance, scrollTop <= tolerance);
|
||||
return scrollTop <= tolerance;
|
||||
}
|
||||
|
||||
|
@ -405,8 +405,9 @@ function toStories(component: string): Promise<string> {
|
||||
glob('src/components/MkUserSetupDialog.*.vue'),
|
||||
glob('src/components/MkInstanceCardMini.vue'),
|
||||
glob('src/components/MkInviteCode.vue'),
|
||||
glob('src/pages/search.vue'),
|
||||
glob('src/pages/admin/overview.ap-requests.vue'),
|
||||
glob('src/pages/user/home.vue'),
|
||||
glob('src/pages/search.vue'),
|
||||
glob('src/components/global/CP*.vue'),
|
||||
]);
|
||||
const components = globs.flat();
|
||||
|
@ -125,8 +125,6 @@ const items = ref<MisskeyEntityMap>(new Map());
|
||||
*/
|
||||
const queue = ref<MisskeyEntityMap>(new Map());
|
||||
|
||||
const offset = ref(0);
|
||||
|
||||
/**
|
||||
* 初期化中かどうか(trueならMkLoadingで全て隠す)
|
||||
*/
|
||||
@ -179,7 +177,9 @@ watch([backed, contentEl], () => {
|
||||
if (!backed.value) {
|
||||
if (!contentEl.value) return;
|
||||
|
||||
scrollRemove.value = (props.pagination.reversed ? onScrollBottom : onScrollTop)(contentEl.value, executeQueue, TOLERANCE);
|
||||
scrollRemove.value = props.pagination.reversed
|
||||
? onScrollBottom(contentEl.value, executeQueue, TOLERANCE)
|
||||
: onScrollTop(contentEl.value, (topVisible) => { if (topVisible) executeQueue(); }, TOLERANCE);
|
||||
} else {
|
||||
if (scrollRemove.value) scrollRemove.value();
|
||||
scrollRemove.value = null;
|
||||
@ -223,7 +223,6 @@ async function init(): Promise<void> {
|
||||
more.value = true;
|
||||
}
|
||||
|
||||
offset.value = res.length;
|
||||
error.value = false;
|
||||
fetching.value = false;
|
||||
}, err => {
|
||||
@ -244,7 +243,7 @@ const fetchMore = async (): Promise<void> => {
|
||||
...params,
|
||||
limit: SECOND_FETCH_LIMIT,
|
||||
...(props.pagination.offsetMode ? {
|
||||
offset: offset.value,
|
||||
offset: items.value.size,
|
||||
} : {
|
||||
untilId: Array.from(items.value.keys()).at(-1),
|
||||
}),
|
||||
@ -294,7 +293,6 @@ const fetchMore = async (): Promise<void> => {
|
||||
moreFetching.value = false;
|
||||
}
|
||||
}
|
||||
offset.value += res.length;
|
||||
}, err => {
|
||||
moreFetching.value = false;
|
||||
});
|
||||
@ -308,7 +306,7 @@ const fetchMoreAhead = async (): Promise<void> => {
|
||||
...params,
|
||||
limit: SECOND_FETCH_LIMIT,
|
||||
...(props.pagination.offsetMode ? {
|
||||
offset: offset.value,
|
||||
offset: items.value.size,
|
||||
} : {
|
||||
sinceId: Array.from(items.value.keys()).at(-1),
|
||||
}),
|
||||
@ -320,7 +318,6 @@ const fetchMoreAhead = async (): Promise<void> => {
|
||||
items.value = concatMapWithArray(items.value, res);
|
||||
more.value = true;
|
||||
}
|
||||
offset.value += res.length;
|
||||
moreFetching.value = false;
|
||||
}, err => {
|
||||
moreFetching.value = false;
|
||||
|
@ -44,6 +44,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<MkButton danger @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="tab === 'notes' && info" class="_gaps_m">
|
||||
<XNotes :fileId="fileId"/>
|
||||
</div>
|
||||
<div v-else-if="tab === 'ip' && info" class="_gaps_m">
|
||||
<MkInfo v-if="!iAmAdmin" warn>{{ i18n.ts.requireAdminForView }}</MkInfo>
|
||||
<MkKeyValue v-if="info.requestIp" class="_monospace" :copy="info.requestIp" oneline>
|
||||
@ -67,7 +70,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, defineAsyncComponent, ref } from 'vue';
|
||||
import * as Misskey from 'cherrypick-js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
@ -88,6 +91,7 @@ const tab = ref('overview');
|
||||
const file = ref<Misskey.entities.DriveFile | null>(null);
|
||||
const info = ref<Misskey.entities.AdminDriveShowFileResponse | null>(null);
|
||||
const isSensitive = ref<boolean>(false);
|
||||
const XNotes = defineAsyncComponent(() => import('./drive.file.notes.vue'));
|
||||
|
||||
const props = defineProps<{
|
||||
fileId: string,
|
||||
@ -131,6 +135,10 @@ const headerTabs = computed(() => [{
|
||||
title: i18n.ts.overview,
|
||||
icon: 'ti ti-info-circle',
|
||||
}, iAmModerator ? {
|
||||
key: 'notes',
|
||||
title: i18n.ts._fileViewer.attachedNotes,
|
||||
icon: 'ti ti-pencil',
|
||||
} : null, iAmModerator ? {
|
||||
key: 'ip',
|
||||
title: 'IP',
|
||||
icon: 'ti ti-password',
|
||||
|
@ -0,0 +1,41 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { commonHandlers } from '../../../.storybook/mocks.js';
|
||||
import overview_ap_requests from './overview.ap-requests.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
overview_ap_requests,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
template: '<overview_ap_requests />',
|
||||
};
|
||||
},
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
msw: {
|
||||
handlers: [
|
||||
...commonHandlers,
|
||||
http.post('/api/charts/ap-request', async ({ request }) => {
|
||||
action('POST /api/charts/ap-request')(await request.json());
|
||||
return HttpResponse.json({
|
||||
deliverFailed: [0, 0, 0, 2, 0, 0, 1, 0, 0, 2, 0, 0, 0, 0, 0, 0, 2, 0, 1, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 1, 0, 0, 0, 3, 1, 1, 2, 0, 0],
|
||||
deliverSucceeded: [0, 1, 51, 34, 136, 189, 51, 17, 17, 34, 1, 17, 18, 51, 34, 68, 287, 0, 17, 33, 32, 96, 96, 0, 49, 64, 0, 32, 0, 32, 81, 48, 65, 1, 16, 50, 90, 148, 33, 43, 72, 127, 17, 138, 78, 91, 78, 91, 13, 52],
|
||||
inboxReceived: [507, 1173, 1096, 871, 958, 937, 908, 1026, 956, 909, 807, 1002, 832, 995, 1039, 1047, 1109, 930, 711, 835, 764, 679, 835, 958, 634, 654, 691, 895, 811, 676, 1044, 1389, 1318, 863, 887, 952, 1011, 1061, 592, 900, 611, 595, 604, 562, 607, 621, 854, 666, 1197, 644],
|
||||
});
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies StoryObj<typeof overview_ap_requests>;
|
@ -23,6 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
import { onMounted, shallowRef, ref } from 'vue';
|
||||
import { Chart } from 'chart.js';
|
||||
import gradient from 'chartjs-plugin-gradient';
|
||||
import isChromatic from 'chromatic';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
|
||||
import { chartVLine } from '@/scripts/chart-vline.js';
|
||||
@ -41,7 +42,7 @@ const { handler: externalTooltipHandler } = useChartTooltip();
|
||||
const { handler: externalTooltipHandler2 } = useChartTooltip();
|
||||
|
||||
onMounted(async () => {
|
||||
const now = new Date();
|
||||
const now = isChromatic() ? new Date('2024-08-31T10:00:00Z') : new Date();
|
||||
|
||||
const getDate = (ago: number) => {
|
||||
const y = now.getFullYear();
|
||||
@ -51,14 +52,14 @@ onMounted(async () => {
|
||||
return new Date(y, m, d - ago);
|
||||
};
|
||||
|
||||
const format = (arr) => {
|
||||
const format = (arr: number[]) => {
|
||||
return arr.map((v, i) => ({
|
||||
x: getDate(i).getTime(),
|
||||
y: v,
|
||||
}));
|
||||
};
|
||||
|
||||
const formatMinus = (arr) => {
|
||||
const formatMinus = (arr: number[]) => {
|
||||
return arr.map((v, i) => ({
|
||||
x: getDate(i).getTime(),
|
||||
y: -v,
|
||||
@ -78,7 +79,6 @@ onMounted(async () => {
|
||||
type: 'line',
|
||||
data: {
|
||||
datasets: [{
|
||||
stack: 'a',
|
||||
parsing: false,
|
||||
label: 'Out: Succ',
|
||||
data: format(raw.deliverSucceeded).slice().reverse(),
|
||||
@ -92,7 +92,6 @@ onMounted(async () => {
|
||||
fill: true,
|
||||
clip: 8,
|
||||
}, {
|
||||
stack: 'a',
|
||||
parsing: false,
|
||||
label: 'Out: Fail',
|
||||
data: formatMinus(raw.deliverFailed).slice().reverse(),
|
||||
@ -137,7 +136,6 @@ onMounted(async () => {
|
||||
min: getDate(chartLimit).getTime(),
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
position: 'left',
|
||||
suggestedMax: 10,
|
||||
grid: {
|
||||
@ -171,6 +169,9 @@ onMounted(async () => {
|
||||
duration: 0,
|
||||
},
|
||||
external: externalTooltipHandler,
|
||||
callbacks: {
|
||||
label: context => `${context.dataset.label}: ${Math.abs(context.parsed.y)}`,
|
||||
},
|
||||
},
|
||||
gradient,
|
||||
},
|
||||
|
@ -11,6 +11,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<MkInput v-model="title">
|
||||
<template #label>{{ i18n.ts._play.title }}</template>
|
||||
</MkInput>
|
||||
<MkSelect v-model="visibility">
|
||||
<template #label>{{ i18n.ts.visibility }}</template>
|
||||
<template #caption>{{ i18n.ts._play.visibilityDescription }}</template>
|
||||
<option :key="'public'" :value="'public'">{{ i18n.ts.public }}</option>
|
||||
<option :key="'private'" :value="'private'">{{ i18n.ts.private }}</option>
|
||||
</MkSelect>
|
||||
<MkTextarea v-model="summary" :mfmAutocomplete="true" :mfmPreview="true">
|
||||
<template #label>{{ i18n.ts._play.summary }}</template>
|
||||
</MkTextarea>
|
||||
@ -18,19 +24,19 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<MkCodeEditor v-model="script" lang="is">
|
||||
<template #label>{{ i18n.ts._play.script }}</template>
|
||||
</MkCodeEditor>
|
||||
<MkSelect v-model="visibility">
|
||||
<template #label>{{ i18n.ts.visibility }}</template>
|
||||
<template #caption>{{ i18n.ts._play.visibilityDescription }}</template>
|
||||
<option :key="'public'" :value="'public'">{{ i18n.ts.public }}</option>
|
||||
<option :key="'private'" :value="'private'">{{ i18n.ts.private }}</option>
|
||||
</MkSelect>
|
||||
<div class="_buttons">
|
||||
<MkButton primary @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
|
||||
<MkButton @click="show"><i class="ti ti-eye"></i> {{ i18n.ts.show }}</MkButton>
|
||||
<MkButton v-if="flash" danger @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
<template #footer>
|
||||
<div :class="$style.footer">
|
||||
<MkSpacer>
|
||||
<div class="_buttons">
|
||||
<MkButton primary @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
|
||||
<MkButton @click="show"><i class="ti ti-eye"></i> {{ i18n.ts.show }}</MkButton>
|
||||
<MkButton v-if="flash" danger @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</div>
|
||||
</template>
|
||||
</MkStickyContainer>
|
||||
</template>
|
||||
|
||||
@ -459,3 +465,10 @@ definePageMetadata(() => ({
|
||||
title: flash.value ? `${i18n.ts._play.edit}: ${flash.value.title}` : i18n.ts._play.new,
|
||||
}));
|
||||
</script>
|
||||
<style lang="scss" module>
|
||||
.footer {
|
||||
backdrop-filter: var(--blur, blur(15px));
|
||||
background: var(--acrylicBg);
|
||||
border-top: solid .5px var(--divider);
|
||||
}
|
||||
</style>
|
||||
|
Loading…
Reference in New Issue
Block a user