Merge pull request 'Add loading spinners to detailed note page & popups(if slow)' (#10139) from Freeplay/calckey:loaders into develop
Reviewed-on: https://codeberg.org/calckey/calckey/pulls/10139
This commit is contained in:
commit
d79968e095
|
@ -10,11 +10,13 @@
|
||||||
:class="{ renote: isRenote }"
|
:class="{ renote: isRenote }"
|
||||||
>
|
>
|
||||||
<MkNoteSub
|
<MkNoteSub
|
||||||
|
v-if="conversation"
|
||||||
v-for="note in conversation"
|
v-for="note in conversation"
|
||||||
:key="note.id"
|
:key="note.id"
|
||||||
class="reply-to"
|
class="reply-to"
|
||||||
:note="note"
|
:note="note"
|
||||||
/>
|
/>
|
||||||
|
<MkLoading v-else-if="appearNote.reply" mini />
|
||||||
<MkNoteSub
|
<MkNoteSub
|
||||||
v-if="appearNote.reply"
|
v-if="appearNote.reply"
|
||||||
:note="appearNote.reply"
|
:note="appearNote.reply"
|
||||||
|
@ -31,12 +33,14 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MkNoteSub
|
<MkNoteSub
|
||||||
|
v-if="directReplies"
|
||||||
v-for="note in directReplies"
|
v-for="note in directReplies"
|
||||||
:key="note.id"
|
:key="note.id"
|
||||||
:note="note"
|
:note="note"
|
||||||
class="reply"
|
class="reply"
|
||||||
:conversation="replies"
|
:conversation="replies"
|
||||||
/>
|
/>
|
||||||
|
<MkLoading v-else-if="appearNote.repliesCount > 0" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="_panel muted" @click="muted.muted = false">
|
<div v-else class="_panel muted" @click="muted.muted = false">
|
||||||
<I18n :src="softMuteReasonI18nSrc(muted.what)" tag="small">
|
<I18n :src="softMuteReasonI18nSrc(muted.what)" tag="small">
|
||||||
|
@ -66,31 +70,18 @@ import {
|
||||||
reactive,
|
reactive,
|
||||||
ref,
|
ref,
|
||||||
} from "vue";
|
} from "vue";
|
||||||
import * as mfm from "mfm-js";
|
|
||||||
import type * as misskey from "calckey-js";
|
import type * as misskey from "calckey-js";
|
||||||
import MkNote from "@/components/MkNote.vue";
|
import MkNote from "@/components/MkNote.vue";
|
||||||
import MkNoteSub from "@/components/MkNoteSub.vue";
|
import MkNoteSub from "@/components/MkNoteSub.vue";
|
||||||
import XNoteSimple from "@/components/MkNoteSimple.vue";
|
|
||||||
import XReactionsViewer from "@/components/MkReactionsViewer.vue";
|
|
||||||
import XMediaList from "@/components/MkMediaList.vue";
|
|
||||||
import XCwButton from "@/components/MkCwButton.vue";
|
|
||||||
import XPoll from "@/components/MkPoll.vue";
|
|
||||||
import XStarButton from "@/components/MkStarButton.vue";
|
import XStarButton from "@/components/MkStarButton.vue";
|
||||||
import XStarButtonNoEmoji from "@/components/MkStarButtonNoEmoji.vue";
|
|
||||||
import XRenoteButton from "@/components/MkRenoteButton.vue";
|
import XRenoteButton from "@/components/MkRenoteButton.vue";
|
||||||
import XQuoteButton from "@/components/MkQuoteButton.vue";
|
|
||||||
import MkUrlPreview from "@/components/MkUrlPreview.vue";
|
|
||||||
import MkInstanceTicker from "@/components/MkInstanceTicker.vue";
|
|
||||||
import MkVisibility from "@/components/MkVisibility.vue";
|
|
||||||
import { pleaseLogin } from "@/scripts/please-login";
|
import { pleaseLogin } from "@/scripts/please-login";
|
||||||
import { getWordSoftMute } from "@/scripts/check-word-mute";
|
import { getWordSoftMute } from "@/scripts/check-word-mute";
|
||||||
import { userPage } from "@/filters/user";
|
import { userPage } from "@/filters/user";
|
||||||
import { notePage } from "@/filters/note";
|
|
||||||
import { useRouter } from "@/router";
|
import { useRouter } from "@/router";
|
||||||
import * as os from "@/os";
|
import * as os from "@/os";
|
||||||
import { defaultStore, noteViewInterruptors } from "@/store";
|
import { defaultStore, noteViewInterruptors } from "@/store";
|
||||||
import { reactionPicker } from "@/scripts/reaction-picker";
|
import { reactionPicker } from "@/scripts/reaction-picker";
|
||||||
import { extractUrlFromMfm } from "@/scripts/extract-url-from-mfm";
|
|
||||||
import { $i } from "@/account";
|
import { $i } from "@/account";
|
||||||
import { i18n } from "@/i18n";
|
import { i18n } from "@/i18n";
|
||||||
import { getNoteMenu } from "@/scripts/get-note-menu";
|
import { getNoteMenu } from "@/scripts/get-note-menu";
|
||||||
|
@ -99,15 +90,11 @@ import { deepClone } from "@/scripts/clone";
|
||||||
import { stream } from "@/stream";
|
import { stream } from "@/stream";
|
||||||
import { NoteUpdatedEvent } from "calckey-js/built/streaming.types";
|
import { NoteUpdatedEvent } from "calckey-js/built/streaming.types";
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
note: misskey.entities.Note;
|
note: misskey.entities.Note;
|
||||||
pinned?: boolean;
|
pinned?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const inChannel = inject("inChannel", null);
|
|
||||||
|
|
||||||
let note = $ref(deepClone(props.note));
|
let note = $ref(deepClone(props.note));
|
||||||
|
|
||||||
const softMuteReasonI18nSrc = (what?: string) => {
|
const softMuteReasonI18nSrc = (what?: string) => {
|
||||||
|
@ -120,8 +107,6 @@ const softMuteReasonI18nSrc = (what?: string) => {
|
||||||
return i18n.ts.userSaysSomething;
|
return i18n.ts.userSaysSomething;
|
||||||
};
|
};
|
||||||
|
|
||||||
const enableEmojiReactions = defaultStore.state.enableEmojiReactions;
|
|
||||||
|
|
||||||
// plugin
|
// plugin
|
||||||
if (noteViewInterruptors.length > 0) {
|
if (noteViewInterruptors.length > 0) {
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
@ -155,16 +140,9 @@ const isDeleted = ref(false);
|
||||||
const muted = ref(getWordSoftMute(note, $i, defaultStore.state.mutedWords));
|
const muted = ref(getWordSoftMute(note, $i, defaultStore.state.mutedWords));
|
||||||
const translation = ref(null);
|
const translation = ref(null);
|
||||||
const translating = ref(false);
|
const translating = ref(false);
|
||||||
const urls = appearNote.text
|
let conversation = $ref<null | misskey.entities.Note[]>([]);
|
||||||
? extractUrlFromMfm(mfm.parse(appearNote.text)).slice(0, 5)
|
|
||||||
: null;
|
|
||||||
const showTicker =
|
|
||||||
defaultStore.state.instanceTicker === "always" ||
|
|
||||||
(defaultStore.state.instanceTicker === "remote" &&
|
|
||||||
appearNote.user.instance);
|
|
||||||
const conversation = ref<misskey.entities.Note[]>([]);
|
|
||||||
const replies = ref<misskey.entities.Note[]>([]);
|
const replies = ref<misskey.entities.Note[]>([]);
|
||||||
const directReplies = ref<misskey.entities.Note[]>([]);
|
let directReplies = $ref<null | misskey.entities.Note[]>([]);
|
||||||
let isScrolling;
|
let isScrolling;
|
||||||
|
|
||||||
const keymap = {
|
const keymap = {
|
||||||
|
@ -260,29 +238,6 @@ function menu(viaKeyboard = false): void {
|
||||||
).then(focus);
|
).then(focus);
|
||||||
}
|
}
|
||||||
|
|
||||||
function showRenoteMenu(viaKeyboard = false): void {
|
|
||||||
if (!isMyRenote) return;
|
|
||||||
os.popupMenu(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
text: i18n.ts.unrenote,
|
|
||||||
icon: "ph-trash ph-bold ph-lg",
|
|
||||||
danger: true,
|
|
||||||
action: () => {
|
|
||||||
os.api("notes/delete", {
|
|
||||||
noteId: note.id,
|
|
||||||
});
|
|
||||||
isDeleted.value = true;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
renoteTime.value,
|
|
||||||
{
|
|
||||||
viaKeyboard: viaKeyboard,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function focus() {
|
function focus() {
|
||||||
noteEl.focus();
|
noteEl.focus();
|
||||||
}
|
}
|
||||||
|
@ -291,13 +246,14 @@ function blur() {
|
||||||
noteEl.blur();
|
noteEl.blur();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
directReplies = null;
|
||||||
os.api("notes/children", {
|
os.api("notes/children", {
|
||||||
noteId: appearNote.id,
|
noteId: appearNote.id,
|
||||||
limit: 30,
|
limit: 30,
|
||||||
depth: 12,
|
depth: 12,
|
||||||
}).then((res) => {
|
}).then((res) => {
|
||||||
replies.value = res;
|
replies.value = res;
|
||||||
directReplies.value = res
|
directReplies = res
|
||||||
.filter(
|
.filter(
|
||||||
(note) =>
|
(note) =>
|
||||||
note.replyId === appearNote.id ||
|
note.replyId === appearNote.id ||
|
||||||
|
@ -306,12 +262,13 @@ os.api("notes/children", {
|
||||||
.reverse();
|
.reverse();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
conversation = null;
|
||||||
if (appearNote.replyId) {
|
if (appearNote.replyId) {
|
||||||
os.api("notes/conversation", {
|
os.api("notes/conversation", {
|
||||||
noteId: appearNote.replyId,
|
noteId: appearNote.replyId,
|
||||||
limit: 30,
|
limit: 30,
|
||||||
}).then((res) => {
|
}).then((res) => {
|
||||||
conversation.value = res.reverse();
|
conversation = res.reverse();
|
||||||
focus();
|
focus();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -345,7 +302,7 @@ async function onNoteUpdated(noteData: NoteUpdatedEvent): Promise<void> {
|
||||||
|
|
||||||
replies.value.splice(found, 0, replyNote);
|
replies.value.splice(found, 0, replyNote);
|
||||||
if (found === 0) {
|
if (found === 0) {
|
||||||
directReplies.value.push(replyNote);
|
directReplies.push(replyNote);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|
|
@ -232,7 +232,7 @@ export async function popup(
|
||||||
|
|
||||||
export function pageWindow(path: string) {
|
export function pageWindow(path: string) {
|
||||||
popup(
|
popup(
|
||||||
defineAsyncComponent(() => import("@/components/MkPageWindow.vue")),
|
defineAsyncComponent({ loader: () => import("@/components/MkPageWindow.vue"), loadingComponent: MkWaitingDialog, delay: 1000 }),
|
||||||
{
|
{
|
||||||
initialPath: path,
|
initialPath: path,
|
||||||
},
|
},
|
||||||
|
@ -243,7 +243,7 @@ export function pageWindow(path: string) {
|
||||||
|
|
||||||
export function modalPageWindow(path: string) {
|
export function modalPageWindow(path: string) {
|
||||||
popup(
|
popup(
|
||||||
defineAsyncComponent(() => import("@/components/MkModalPageWindow.vue")),
|
defineAsyncComponent({ loader: () => import("@/components/MkModalPageWindow.vue"), loadingComponent: MkWaitingDialog, delay: 1000 }),
|
||||||
{
|
{
|
||||||
initialPath: path,
|
initialPath: path,
|
||||||
},
|
},
|
||||||
|
@ -313,7 +313,7 @@ export function yesno(props: {
|
||||||
}): Promise<{ canceled: boolean }> {
|
}): Promise<{ canceled: boolean }> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
popup(
|
popup(
|
||||||
defineAsyncComponent(() => import("@/components/MkDialog.vue")),
|
defineAsyncComponent({ loader: () => import("@/components/MkDialog.vue"), loadingComponent: MkWaitingDialog, delay: 1000 }),
|
||||||
{
|
{
|
||||||
...props,
|
...props,
|
||||||
showCancelButton: true,
|
showCancelButton: true,
|
||||||
|
@ -344,7 +344,7 @@ export function inputText(props: {
|
||||||
> {
|
> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
popup(
|
popup(
|
||||||
defineAsyncComponent(() => import("@/components/MkDialog.vue")),
|
defineAsyncComponent({ loader: () => import("@/components/MkDialog.vue"), loadingComponent: MkWaitingDialog, delay: 1000 }),
|
||||||
{
|
{
|
||||||
title: props.title,
|
title: props.title,
|
||||||
text: props.text,
|
text: props.text,
|
||||||
|
@ -378,7 +378,7 @@ export function inputParagraph(props: {
|
||||||
> {
|
> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
popup(
|
popup(
|
||||||
defineAsyncComponent(() => import("@/components/MkDialog.vue")),
|
defineAsyncComponent({ loader: () => import("@/components/MkDialog.vue"), loadingComponent: MkWaitingDialog, delay: 1000 }),
|
||||||
{
|
{
|
||||||
title: props.title,
|
title: props.title,
|
||||||
text: props.text,
|
text: props.text,
|
||||||
|
@ -412,7 +412,7 @@ export function inputNumber(props: {
|
||||||
> {
|
> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
popup(
|
popup(
|
||||||
defineAsyncComponent(() => import("@/components/MkDialog.vue")),
|
defineAsyncComponent({ loader: () => import("@/components/MkDialog.vue"), loadingComponent: MkWaitingDialog, delay: 1000 }),
|
||||||
{
|
{
|
||||||
title: props.title,
|
title: props.title,
|
||||||
text: props.text,
|
text: props.text,
|
||||||
|
@ -446,7 +446,7 @@ export function inputDate(props: {
|
||||||
> {
|
> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
popup(
|
popup(
|
||||||
defineAsyncComponent(() => import("@/components/MkDialog.vue")),
|
defineAsyncComponent({ loader: () => import("@/components/MkDialog.vue"), loadingComponent: MkWaitingDialog, delay: 1000 }),
|
||||||
{
|
{
|
||||||
title: props.title,
|
title: props.title,
|
||||||
text: props.text,
|
text: props.text,
|
||||||
|
@ -501,7 +501,7 @@ export function select<C = any>(
|
||||||
> {
|
> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
popup(
|
popup(
|
||||||
defineAsyncComponent(() => import("@/components/MkDialog.vue")),
|
defineAsyncComponent({ loader: () => import("@/components/MkDialog.vue"), loadingComponent: MkWaitingDialog, delay: 1000 }),
|
||||||
{
|
{
|
||||||
title: props.title,
|
title: props.title,
|
||||||
text: props.text,
|
text: props.text,
|
||||||
|
@ -528,7 +528,7 @@ export function success() {
|
||||||
showing.value = false;
|
showing.value = false;
|
||||||
}, 1000);
|
}, 1000);
|
||||||
popup(
|
popup(
|
||||||
defineAsyncComponent(() => import("@/components/MkWaitingDialog.vue")),
|
defineAsyncComponent({ loader: () => import("@/components/MkWaitingDialog.vue"), loadingComponent: MkWaitingDialog, delay: 1000 }),
|
||||||
{
|
{
|
||||||
success: true,
|
success: true,
|
||||||
showing: showing,
|
showing: showing,
|
||||||
|
@ -545,7 +545,7 @@ export function waiting() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const showing = ref(true);
|
const showing = ref(true);
|
||||||
popup(
|
popup(
|
||||||
defineAsyncComponent(() => import("@/components/MkWaitingDialog.vue")),
|
defineAsyncComponent({ loader: () => import("@/components/MkWaitingDialog.vue"), loadingComponent: MkWaitingDialog, delay: 1000 }),
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
showing: showing,
|
showing: showing,
|
||||||
|
@ -561,7 +561,7 @@ export function waiting() {
|
||||||
export function form(title, form) {
|
export function form(title, form) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
popup(
|
popup(
|
||||||
defineAsyncComponent(() => import("@/components/MkFormDialog.vue")),
|
defineAsyncComponent({ loader: () => import("@/components/MkFormDialog.vue"), loadingComponent: MkWaitingDialog, delay: 1000 }),
|
||||||
{ title, form },
|
{ title, form },
|
||||||
{
|
{
|
||||||
done: (result) => {
|
done: (result) => {
|
||||||
|
@ -576,7 +576,7 @@ export function form(title, form) {
|
||||||
export async function selectUser() {
|
export async function selectUser() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
popup(
|
popup(
|
||||||
defineAsyncComponent(() => import("@/components/MkUserSelectDialog.vue")),
|
defineAsyncComponent({ loader: () => import("@/components/MkUserSelectDialog.vue"), loadingComponent: MkWaitingDialog, delay: 1000 }),
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
ok: (user) => {
|
ok: (user) => {
|
||||||
|
@ -591,9 +591,7 @@ export async function selectUser() {
|
||||||
export async function selectInstance(): Promise<Misskey.entities.Instance> {
|
export async function selectInstance(): Promise<Misskey.entities.Instance> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
popup(
|
popup(
|
||||||
defineAsyncComponent(
|
defineAsyncComponent({ loader: () => import("@/components/MkInstanceSelectDialog.vue"), loadingComponent: MkWaitingDialog, delay: 1000 }),
|
||||||
() => import("@/components/MkInstanceSelectDialog.vue"),
|
|
||||||
),
|
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
ok: (instance) => {
|
ok: (instance) => {
|
||||||
|
@ -608,9 +606,7 @@ export async function selectInstance(): Promise<Misskey.entities.Instance> {
|
||||||
export async function selectDriveFile(multiple: boolean) {
|
export async function selectDriveFile(multiple: boolean) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
popup(
|
popup(
|
||||||
defineAsyncComponent(
|
defineAsyncComponent({ loader: () => import("@/components/MkDriveSelectDialog.vue"), loadingComponent: MkWaitingDialog, delay: 1000 }),
|
||||||
() => import("@/components/MkDriveSelectDialog.vue"),
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
type: "file",
|
type: "file",
|
||||||
multiple,
|
multiple,
|
||||||
|
@ -630,9 +626,7 @@ export async function selectDriveFile(multiple: boolean) {
|
||||||
export async function selectDriveFolder(multiple: boolean) {
|
export async function selectDriveFolder(multiple: boolean) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
popup(
|
popup(
|
||||||
defineAsyncComponent(
|
defineAsyncComponent({ loader: () => import("@/components/MkDriveSelectDialog.vue"), loadingComponent: MkWaitingDialog, delay: 1000 }),
|
||||||
() => import("@/components/MkDriveSelectDialog.vue"),
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
type: "folder",
|
type: "folder",
|
||||||
multiple,
|
multiple,
|
||||||
|
@ -652,9 +646,7 @@ export async function selectDriveFolder(multiple: boolean) {
|
||||||
export async function pickEmoji(src: HTMLElement | null, opts) {
|
export async function pickEmoji(src: HTMLElement | null, opts) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
popup(
|
popup(
|
||||||
defineAsyncComponent(
|
defineAsyncComponent({ loader: () => import("@/components/MkEmojiPickerDialog.vue"), loadingComponent: MkWaitingDialog, delay: 1000 }),
|
||||||
() => import("@/components/MkEmojiPickerDialog.vue"),
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
src,
|
src,
|
||||||
...opts,
|
...opts,
|
||||||
|
@ -677,7 +669,7 @@ export async function cropImage(
|
||||||
): Promise<Misskey.entities.DriveFile> {
|
): Promise<Misskey.entities.DriveFile> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
popup(
|
popup(
|
||||||
defineAsyncComponent(() => import("@/components/MkCropperDialog.vue")),
|
defineAsyncComponent({ loader: () => import("@/components/MkCropperDialog.vue"), loadingComponent: MkWaitingDialog, delay: 1000 }),
|
||||||
{
|
{
|
||||||
file: image,
|
file: image,
|
||||||
aspectRatio: options.aspectRatio,
|
aspectRatio: options.aspectRatio,
|
||||||
|
@ -741,7 +733,7 @@ export async function openEmojiPicker(
|
||||||
});
|
});
|
||||||
|
|
||||||
openingEmojiPicker = await popup(
|
openingEmojiPicker = await popup(
|
||||||
defineAsyncComponent(() => import("@/components/MkEmojiPickerDialog.vue")),
|
defineAsyncComponent({ loader: () => import("@/components/MkEmojiPickerDialog.vue"), loadingComponent: MkWaitingDialog, delay: 1000 }),
|
||||||
{
|
{
|
||||||
src,
|
src,
|
||||||
...opts,
|
...opts,
|
||||||
|
@ -774,7 +766,7 @@ export function popupMenu(
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let dispose;
|
let dispose;
|
||||||
popup(
|
popup(
|
||||||
defineAsyncComponent(() => import("@/components/MkPopupMenu.vue")),
|
defineAsyncComponent({ loader: () => import("@/components/MkPopupMenu.vue"), loadingComponent: MkWaitingDialog, delay: 1000 }),
|
||||||
{
|
{
|
||||||
items,
|
items,
|
||||||
src,
|
src,
|
||||||
|
@ -802,7 +794,7 @@ export function contextMenu(
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let dispose;
|
let dispose;
|
||||||
popup(
|
popup(
|
||||||
defineAsyncComponent(() => import("@/components/MkContextMenu.vue")),
|
defineAsyncComponent({ loader: () => import("@/components/MkContextMenu.vue"), loadingComponent: MkWaitingDialog, delay: 1000 }),
|
||||||
{
|
{
|
||||||
items,
|
items,
|
||||||
ev,
|
ev,
|
||||||
|
|
Loading…
Reference in New Issue