mirror of
https://git.joinsharkey.org/Sharkey/Sharkey.git
synced 2024-11-27 01:03:09 +02:00
Merge pull request #74 from transfem-org/feature/separate-quote
Separate Quote from Boost
This commit is contained in:
commit
5180b4093f
7 changed files with 412 additions and 205 deletions
|
@ -535,7 +535,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.renote && data.renote.userId !== user.id && !user.isBot) {
|
if (data.renote && data.text == null && data.renote.userId !== user.id && !user.isBot) {
|
||||||
this.incRenoteCount(data.renote);
|
this.incRenoteCount(data.renote);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -34,6 +34,7 @@ export const paramDef = {
|
||||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||||
sinceId: { type: 'string', format: 'misskey:id' },
|
sinceId: { type: 'string', format: 'misskey:id' },
|
||||||
untilId: { type: 'string', format: 'misskey:id' },
|
untilId: { type: 'string', format: 'misskey:id' },
|
||||||
|
showQuotes: { type: 'boolean', default: true },
|
||||||
},
|
},
|
||||||
required: ['noteId'],
|
required: ['noteId'],
|
||||||
} as const;
|
} as const;
|
||||||
|
@ -51,17 +52,19 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||||
.andWhere(new Brackets(qb => {
|
.andWhere(new Brackets(qb => {
|
||||||
qb
|
qb
|
||||||
.where('note.replyId = :noteId', { noteId: ps.noteId })
|
.where('note.replyId = :noteId', { noteId: ps.noteId });
|
||||||
.orWhere(new Brackets(qb => {
|
if (ps.showQuotes) {
|
||||||
qb
|
qb.orWhere(new Brackets(qb => {
|
||||||
.where('note.renoteId = :noteId', { noteId: ps.noteId })
|
qb
|
||||||
.andWhere(new Brackets(qb => {
|
.where('note.renoteId = :noteId', { noteId: ps.noteId })
|
||||||
qb
|
.andWhere(new Brackets(qb => {
|
||||||
.where('note.text IS NOT NULL')
|
qb
|
||||||
.orWhere('note.fileIds != \'{}\'')
|
.where('note.text IS NOT NULL')
|
||||||
.orWhere('note.hasPoll = TRUE');
|
.orWhere('note.fileIds != \'{}\'')
|
||||||
}));
|
.orWhere('note.hasPoll = TRUE');
|
||||||
}));
|
}));
|
||||||
|
}));
|
||||||
|
}
|
||||||
}))
|
}))
|
||||||
.innerJoinAndSelect('note.user', 'user')
|
.innerJoinAndSelect('note.user', 'user')
|
||||||
.leftJoinAndSelect('note.reply', 'reply')
|
.leftJoinAndSelect('note.reply', 'reply')
|
||||||
|
|
|
@ -44,6 +44,7 @@ export const paramDef = {
|
||||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||||
sinceId: { type: 'string', format: 'misskey:id' },
|
sinceId: { type: 'string', format: 'misskey:id' },
|
||||||
untilId: { type: 'string', format: 'misskey:id' },
|
untilId: { type: 'string', format: 'misskey:id' },
|
||||||
|
quote: { type: 'boolean', default: false },
|
||||||
},
|
},
|
||||||
required: ['noteId'],
|
required: ['noteId'],
|
||||||
} as const;
|
} as const;
|
||||||
|
@ -74,7 +75,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
|
|
||||||
if (ps.userId) {
|
if (ps.userId) {
|
||||||
query.andWhere("user.id = :userId", { userId: ps.userId });
|
query.andWhere("user.id = :userId", { userId: ps.userId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ps.quote) {
|
||||||
|
query.andWhere("note.text IS NOT NULL");
|
||||||
|
} else {
|
||||||
|
query.andWhere("note.text IS NULL");
|
||||||
|
}
|
||||||
|
|
||||||
this.queryService.generateVisibilityQuery(query, me);
|
this.queryService.generateVisibilityQuery(query, me);
|
||||||
if (me) this.queryService.generateMutedUserQuery(query, me);
|
if (me) this.queryService.generateMutedUserQuery(query, me);
|
||||||
|
|
|
@ -38,6 +38,7 @@ export const paramDef = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
noteId: { type: 'string', format: 'misskey:id' },
|
noteId: { type: 'string', format: 'misskey:id' },
|
||||||
|
quote: { type: 'boolean', default: false },
|
||||||
},
|
},
|
||||||
required: ['noteId'],
|
required: ['noteId'],
|
||||||
} as const;
|
} as const;
|
||||||
|
@ -66,7 +67,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const note of renotes) {
|
for (const note of renotes) {
|
||||||
this.noteDeleteService.delete(await this.usersRepository.findOneByOrFail({ id: me.id }), note, false);
|
if (ps.quote) {
|
||||||
|
if (note.text) this.noteDeleteService.delete(await this.usersRepository.findOneByOrFail({ id: me.id }), note, false);
|
||||||
|
} else {
|
||||||
|
if (!note.text) this.noteDeleteService.delete(await this.usersRepository.findOneByOrFail({ id: me.id }), note, false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -110,6 +110,17 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<button v-else :class="$style.footerButton" class="_button" disabled>
|
<button v-else :class="$style.footerButton" class="_button" disabled>
|
||||||
<i class="ph-prohibit ph-bold ph-lg"></i>
|
<i class="ph-prohibit ph-bold ph-lg"></i>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canRenote"
|
||||||
|
ref="quoteButton"
|
||||||
|
:class="$style.footerButton"
|
||||||
|
class="_button"
|
||||||
|
:style="quoted ? 'color: var(--accent) !important;' : ''"
|
||||||
|
v-on:click.stop
|
||||||
|
@mousedown="quoted ? undoQuote(appearNote) : quote()"
|
||||||
|
>
|
||||||
|
<i class="ph-quotes ph-bold ph-lg"></i>
|
||||||
|
</button>
|
||||||
<button v-if="appearNote.myReaction == null && appearNote.reactionAcceptance !== 'likeOnly'" ref="likeButton" :class="$style.footerButton" class="_button" v-on:click.stop @click="like()">
|
<button v-if="appearNote.myReaction == null && appearNote.reactionAcceptance !== 'likeOnly'" ref="likeButton" :class="$style.footerButton" class="_button" v-on:click.stop @click="like()">
|
||||||
<i class="ph-heart ph-bold ph-lg"></i>
|
<i class="ph-heart ph-bold ph-lg"></i>
|
||||||
</button>
|
</button>
|
||||||
|
@ -216,6 +227,7 @@ const menuButton = shallowRef<HTMLElement>();
|
||||||
const renoteButton = shallowRef<HTMLElement>();
|
const renoteButton = shallowRef<HTMLElement>();
|
||||||
const renoteTime = shallowRef<HTMLElement>();
|
const renoteTime = shallowRef<HTMLElement>();
|
||||||
const reactButton = shallowRef<HTMLElement>();
|
const reactButton = shallowRef<HTMLElement>();
|
||||||
|
const quoteButton = shallowRef<HTMLElement>();
|
||||||
const clipButton = shallowRef<HTMLElement>();
|
const clipButton = shallowRef<HTMLElement>();
|
||||||
const likeButton = shallowRef<HTMLElement>();
|
const likeButton = shallowRef<HTMLElement>();
|
||||||
let appearNote = $computed(() => isRenote ? note.renote as Misskey.entities.Note : note);
|
let appearNote = $computed(() => isRenote ? note.renote as Misskey.entities.Note : note);
|
||||||
|
@ -229,6 +241,7 @@ const isLong = shouldCollapsed(appearNote);
|
||||||
const collapsed = ref(appearNote.cw == null && isLong);
|
const collapsed = ref(appearNote.cw == null && isLong);
|
||||||
const isDeleted = ref(false);
|
const isDeleted = ref(false);
|
||||||
const renoted = ref(false);
|
const renoted = ref(false);
|
||||||
|
const quoted = ref(false);
|
||||||
const muted = ref($i ? checkWordMute(appearNote, $i, $i.mutedWords) : false);
|
const muted = ref($i ? checkWordMute(appearNote, $i, $i.mutedWords) : false);
|
||||||
const translation = ref<any>(null);
|
const translation = ref<any>(null);
|
||||||
const translating = ref(false);
|
const translating = ref(false);
|
||||||
|
@ -271,6 +284,25 @@ useTooltip(renoteButton, async (showing) => {
|
||||||
}, {}, 'closed');
|
}, {}, 'closed');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useTooltip(quoteButton, async (showing) => {
|
||||||
|
const renotes = await os.api('notes/renotes', {
|
||||||
|
noteId: appearNote.id,
|
||||||
|
limit: 11,
|
||||||
|
quote: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const users = renotes.map(x => x.user);
|
||||||
|
|
||||||
|
if (users.length < 1) return;
|
||||||
|
|
||||||
|
os.popup(MkUsersTooltip, {
|
||||||
|
showing,
|
||||||
|
users,
|
||||||
|
count: appearNote.renoteCount,
|
||||||
|
targetElement: quoteButton.value,
|
||||||
|
}, {}, 'closed');
|
||||||
|
});
|
||||||
|
|
||||||
if ($i) {
|
if ($i) {
|
||||||
os.api("notes/renotes", {
|
os.api("notes/renotes", {
|
||||||
noteId: appearNote.id,
|
noteId: appearNote.id,
|
||||||
|
@ -279,6 +311,15 @@ if ($i) {
|
||||||
}).then((res) => {
|
}).then((res) => {
|
||||||
renoted.value = res.length > 0;
|
renoted.value = res.length > 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
os.api("notes/renotes", {
|
||||||
|
noteId: appearNote.id,
|
||||||
|
userId: $i.id,
|
||||||
|
limit: 1,
|
||||||
|
quote: true,
|
||||||
|
}).then((res) => {
|
||||||
|
quoted.value = res.length > 0;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
type Visibility = 'public' | 'home' | 'followers' | 'specified';
|
type Visibility = 'public' | 'home' | 'followers' | 'specified';
|
||||||
|
@ -292,88 +333,103 @@ function smallerVisibility(a: Visibility | string, b: Visibility | string): Visi
|
||||||
return 'public';
|
return 'public';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renote(viaKeyboard = false) {
|
function renote() {
|
||||||
pleaseLogin();
|
pleaseLogin();
|
||||||
showMovedDialog();
|
showMovedDialog();
|
||||||
|
|
||||||
let items = [] as MenuItem[];
|
if (appearNote.channel) {
|
||||||
|
const el = renoteButton.value as HTMLElement | null | undefined;
|
||||||
|
if (el) {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const x = rect.left + (el.offsetWidth / 2);
|
||||||
|
const y = rect.top + (el.offsetHeight / 2);
|
||||||
|
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||||
|
}
|
||||||
|
|
||||||
|
os.api('notes/create', {
|
||||||
|
renoteId: appearNote.id,
|
||||||
|
channelId: appearNote.channelId,
|
||||||
|
}).then(() => {
|
||||||
|
os.toast(i18n.ts.renoted);
|
||||||
|
renoted.value = true;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const el = renoteButton.value as HTMLElement | null | undefined;
|
||||||
|
if (el) {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const x = rect.left + (el.offsetWidth / 2);
|
||||||
|
const y = rect.top + (el.offsetHeight / 2);
|
||||||
|
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuredVisibility = defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility;
|
||||||
|
const localOnly = defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly;
|
||||||
|
|
||||||
|
let visibility = appearNote.visibility;
|
||||||
|
visibility = smallerVisibility(visibility, configuredVisibility);
|
||||||
|
if (appearNote.channel?.isSensitive) {
|
||||||
|
visibility = smallerVisibility(visibility, 'home');
|
||||||
|
}
|
||||||
|
|
||||||
|
os.api('notes/create', {
|
||||||
|
localOnly,
|
||||||
|
visibility,
|
||||||
|
renoteId: appearNote.id,
|
||||||
|
}).then(() => {
|
||||||
|
os.toast(i18n.ts.renoted);
|
||||||
|
renoted.value = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function quote() {
|
||||||
|
pleaseLogin();
|
||||||
|
showMovedDialog();
|
||||||
|
|
||||||
if (appearNote.channel) {
|
if (appearNote.channel) {
|
||||||
items = items.concat([{
|
os.post({
|
||||||
text: i18n.ts.inChannelRenote,
|
renote: appearNote,
|
||||||
icon: 'ph-rocket-launch ph-bold ph-lg',
|
channel: appearNote.channel,
|
||||||
action: () => {
|
}).then(() => {
|
||||||
const el = renoteButton.value as HTMLElement | null | undefined;
|
os.api("notes/renotes", {
|
||||||
if (el) {
|
noteId: appearNote.id,
|
||||||
|
userId: $i.id,
|
||||||
|
limit: 1,
|
||||||
|
quote: true,
|
||||||
|
}).then((res) => {
|
||||||
|
const el = quoteButton.value as HTMLElement | null | undefined;
|
||||||
|
if (el && res.length > 0) {
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
const x = rect.left + (el.offsetWidth / 2);
|
const x = rect.left + (el.offsetWidth / 2);
|
||||||
const y = rect.top + (el.offsetHeight / 2);
|
const y = rect.top + (el.offsetHeight / 2);
|
||||||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||||
}
|
}
|
||||||
|
|
||||||
os.api('notes/create', {
|
quoted.value = res.length > 0;
|
||||||
renoteId: appearNote.id,
|
});
|
||||||
channelId: appearNote.channelId,
|
});
|
||||||
}).then(() => {
|
} else {
|
||||||
os.toast(i18n.ts.renoted);
|
os.post({
|
||||||
renoted.value = true;
|
renote: appearNote,
|
||||||
});
|
}).then(() => {
|
||||||
},
|
os.api("notes/renotes", {
|
||||||
}, {
|
noteId: appearNote.id,
|
||||||
text: i18n.ts.inChannelQuote,
|
userId: $i.id,
|
||||||
icon: 'ph-quotes ph-bold ph-lg',
|
limit: 1,
|
||||||
action: () => {
|
quote: true,
|
||||||
os.post({
|
}).then((res) => {
|
||||||
renote: appearNote,
|
const el = quoteButton.value as HTMLElement | null | undefined;
|
||||||
channel: appearNote.channel,
|
if (el && res.length > 0) {
|
||||||
});
|
const rect = el.getBoundingClientRect();
|
||||||
},
|
const x = rect.left + (el.offsetWidth / 2);
|
||||||
}, null]);
|
const y = rect.top + (el.offsetHeight / 2);
|
||||||
|
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||||
|
}
|
||||||
|
|
||||||
|
quoted.value = res.length > 0;
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
items = items.concat([{
|
|
||||||
text: i18n.ts.renote,
|
|
||||||
icon: 'ph-rocket-launch ph-bold ph-lg',
|
|
||||||
action: () => {
|
|
||||||
const el = renoteButton.value as HTMLElement | null | undefined;
|
|
||||||
if (el) {
|
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
const x = rect.left + (el.offsetWidth / 2);
|
|
||||||
const y = rect.top + (el.offsetHeight / 2);
|
|
||||||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
|
||||||
}
|
|
||||||
|
|
||||||
const configuredVisibility = defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility;
|
|
||||||
const localOnly = defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly;
|
|
||||||
|
|
||||||
let visibility = appearNote.visibility;
|
|
||||||
visibility = smallerVisibility(visibility, configuredVisibility);
|
|
||||||
if (appearNote.channel?.isSensitive) {
|
|
||||||
visibility = smallerVisibility(visibility, 'home');
|
|
||||||
}
|
|
||||||
|
|
||||||
os.api('notes/create', {
|
|
||||||
localOnly,
|
|
||||||
visibility,
|
|
||||||
renoteId: appearNote.id,
|
|
||||||
}).then(() => {
|
|
||||||
os.toast(i18n.ts.renoted);
|
|
||||||
renoted.value = true;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}, {
|
|
||||||
text: i18n.ts.quote,
|
|
||||||
icon: 'ph-quotes ph-bold ph-lg',
|
|
||||||
action: () => {
|
|
||||||
os.post({
|
|
||||||
renote: appearNote,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}]);
|
|
||||||
|
|
||||||
os.popupMenu(items, renoteButton.value, {
|
|
||||||
viaKeyboard,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function reply(viaKeyboard = false): void {
|
function reply(viaKeyboard = false): void {
|
||||||
|
@ -443,13 +499,20 @@ function undoReact(note): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
function undoRenote(note) : void {
|
function undoRenote(note) : void {
|
||||||
if (!renoted.value) return;
|
|
||||||
os.api("notes/unrenote", {
|
os.api("notes/unrenote", {
|
||||||
noteId: note.id,
|
noteId: note.id
|
||||||
});
|
});
|
||||||
renoted.value = false;
|
renoted.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function undoQuote(note) : void {
|
||||||
|
os.api("notes/unrenote", {
|
||||||
|
noteId: note.id,
|
||||||
|
quote: true
|
||||||
|
});
|
||||||
|
quoted.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
function onContextmenu(ev: MouseEvent): void {
|
function onContextmenu(ev: MouseEvent): void {
|
||||||
const isLink = (el: HTMLElement) => {
|
const isLink = (el: HTMLElement) => {
|
||||||
if (el.tagName === 'A') return true;
|
if (el.tagName === 'A') return true;
|
||||||
|
|
|
@ -120,6 +120,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<button v-else class="_button" :class="$style.noteFooterButton" disabled>
|
<button v-else class="_button" :class="$style.noteFooterButton" disabled>
|
||||||
<i class="ph-prohibit ph-bold ph-lg"></i>
|
<i class="ph-prohibit ph-bold ph-lg"></i>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canRenote"
|
||||||
|
ref="quoteButton"
|
||||||
|
class="_button"
|
||||||
|
:class="$style.noteFooterButton"
|
||||||
|
:style="quoted ? 'color: var(--accent) !important;' : ''"
|
||||||
|
@mousedown="quoted ? undoQuote() : quote()"
|
||||||
|
>
|
||||||
|
<i class="ph-quotes ph-bold ph-lg"></i>
|
||||||
|
</button>
|
||||||
<button v-if="appearNote.myReaction == null && appearNote.reactionAcceptance !== 'likeOnly'" ref="likeButton" :class="$style.noteFooterButton" class="_button" @mousedown="like()">
|
<button v-if="appearNote.myReaction == null && appearNote.reactionAcceptance !== 'likeOnly'" ref="likeButton" :class="$style.noteFooterButton" class="_button" @mousedown="like()">
|
||||||
<i class="ph-heart ph-bold ph-lg"></i>
|
<i class="ph-heart ph-bold ph-lg"></i>
|
||||||
</button>
|
</button>
|
||||||
|
@ -141,6 +151,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<div :class="$style.tabs">
|
<div :class="$style.tabs">
|
||||||
<button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'replies' }]" @click="tab = 'replies'"><i class="ph-arrow-u-up-left ph-bold pg-lg"></i> {{ i18n.ts.replies }}</button>
|
<button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'replies' }]" @click="tab = 'replies'"><i class="ph-arrow-u-up-left ph-bold pg-lg"></i> {{ i18n.ts.replies }}</button>
|
||||||
<button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'renotes' }]" @click="tab = 'renotes'"><i class="ph-rocket-launch ph-bold ph-lg"></i> {{ i18n.ts.renotes }}</button>
|
<button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'renotes' }]" @click="tab = 'renotes'"><i class="ph-rocket-launch ph-bold ph-lg"></i> {{ i18n.ts.renotes }}</button>
|
||||||
|
<button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'quotes' }]" @click="tab = 'quotes'"><i class="ph-quotes ph-bold ph-lg"></i> {{ i18n.ts._notification._types.quote }}</button>
|
||||||
<button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'reactions' }]" @click="tab = 'reactions'"><i class="ph-smiley ph-bold pg-lg"></i> {{ i18n.ts.reactions }}</button>
|
<button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'reactions' }]" @click="tab = 'reactions'"><i class="ph-smiley ph-bold pg-lg"></i> {{ i18n.ts.reactions }}</button>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
@ -161,6 +172,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</template>
|
</template>
|
||||||
</MkPagination>
|
</MkPagination>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="tab === 'quotes'" :class="$style.tab_replies">
|
||||||
|
<div v-if="!quotesLoaded" style="padding: 16px">
|
||||||
|
<MkButton style="margin: 0 auto;" primary rounded @click="loadQuotes">{{ i18n.ts.loadReplies }}</MkButton>
|
||||||
|
</div>
|
||||||
|
<MkNoteSub v-for="note in quotes" :key="note.id" :note="note" :class="$style.reply" :detail="true"/>
|
||||||
|
</div>
|
||||||
<div v-else-if="tab === 'reactions'" :class="$style.tab_reactions">
|
<div v-else-if="tab === 'reactions'" :class="$style.tab_reactions">
|
||||||
<div :class="$style.reactionTabs">
|
<div :class="$style.reactionTabs">
|
||||||
<button v-for="reaction in Object.keys(appearNote.reactions)" :key="reaction" :class="[$style.reactionTab, { [$style.reactionTabActive]: reactionTabType === reaction }]" class="_button" @click="reactionTabType = reaction">
|
<button v-for="reaction in Object.keys(appearNote.reactions)" :key="reaction" :class="[$style.reactionTab, { [$style.reactionTabActive]: reactionTabType === reaction }]" class="_button" @click="reactionTabType = reaction">
|
||||||
|
@ -258,6 +275,7 @@ const menuButton = shallowRef<HTMLElement>();
|
||||||
const renoteButton = shallowRef<HTMLElement>();
|
const renoteButton = shallowRef<HTMLElement>();
|
||||||
const renoteTime = shallowRef<HTMLElement>();
|
const renoteTime = shallowRef<HTMLElement>();
|
||||||
const reactButton = shallowRef<HTMLElement>();
|
const reactButton = shallowRef<HTMLElement>();
|
||||||
|
const quoteButton = shallowRef<HTMLElement>();
|
||||||
const clipButton = shallowRef<HTMLElement>();
|
const clipButton = shallowRef<HTMLElement>();
|
||||||
const likeButton = shallowRef<HTMLElement>();
|
const likeButton = shallowRef<HTMLElement>();
|
||||||
let appearNote = $computed(() => isRenote ? note.renote as Misskey.entities.Note : note);
|
let appearNote = $computed(() => isRenote ? note.renote as Misskey.entities.Note : note);
|
||||||
|
@ -268,6 +286,7 @@ const isMyRenote = $i && ($i.id === note.userId);
|
||||||
const showContent = ref(false);
|
const showContent = ref(false);
|
||||||
const isDeleted = ref(false);
|
const isDeleted = ref(false);
|
||||||
const renoted = ref(false);
|
const renoted = ref(false);
|
||||||
|
const quoted = ref(false);
|
||||||
const muted = ref($i ? checkWordMute(appearNote, $i, $i.mutedWords) : false);
|
const muted = ref($i ? checkWordMute(appearNote, $i, $i.mutedWords) : false);
|
||||||
const translation = ref(null);
|
const translation = ref(null);
|
||||||
const translating = ref(false);
|
const translating = ref(false);
|
||||||
|
@ -275,6 +294,7 @@ const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)).fil
|
||||||
const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance);
|
const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance);
|
||||||
const conversation = ref<Misskey.entities.Note[]>([]);
|
const conversation = ref<Misskey.entities.Note[]>([]);
|
||||||
const replies = ref<Misskey.entities.Note[]>([]);
|
const replies = ref<Misskey.entities.Note[]>([]);
|
||||||
|
const quotes = ref<Misskey.entities.Note[]>([]);
|
||||||
const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || appearNote.userId === $i.id);
|
const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || appearNote.userId === $i.id);
|
||||||
|
|
||||||
if ($i) {
|
if ($i) {
|
||||||
|
@ -285,6 +305,15 @@ if ($i) {
|
||||||
}).then((res) => {
|
}).then((res) => {
|
||||||
renoted.value = res.length > 0;
|
renoted.value = res.length > 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
os.api("notes/renotes", {
|
||||||
|
noteId: appearNote.id,
|
||||||
|
userId: $i.id,
|
||||||
|
limit: 1,
|
||||||
|
quote: true,
|
||||||
|
}).then((res) => {
|
||||||
|
quoted.value = res.length > 0;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const keymap = {
|
const keymap = {
|
||||||
|
@ -340,77 +369,111 @@ useTooltip(renoteButton, async (showing) => {
|
||||||
}, {}, 'closed');
|
}, {}, 'closed');
|
||||||
});
|
});
|
||||||
|
|
||||||
function renote(viaKeyboard = false) {
|
useTooltip(quoteButton, async (showing) => {
|
||||||
|
const renotes = await os.api('notes/renotes', {
|
||||||
|
noteId: appearNote.id,
|
||||||
|
limit: 11,
|
||||||
|
quote: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const users = renotes.map(x => x.user);
|
||||||
|
|
||||||
|
if (users.length < 1) return;
|
||||||
|
|
||||||
|
os.popup(MkUsersTooltip, {
|
||||||
|
showing,
|
||||||
|
users,
|
||||||
|
count: appearNote.renoteCount,
|
||||||
|
targetElement: quoteButton.value,
|
||||||
|
}, {}, 'closed');
|
||||||
|
});
|
||||||
|
|
||||||
|
function renote() {
|
||||||
pleaseLogin();
|
pleaseLogin();
|
||||||
showMovedDialog();
|
showMovedDialog();
|
||||||
|
|
||||||
let items = [] as MenuItem[];
|
if (appearNote.channel) {
|
||||||
|
const el = renoteButton.value as HTMLElement | null | undefined;
|
||||||
|
if (el) {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const x = rect.left + (el.offsetWidth / 2);
|
||||||
|
const y = rect.top + (el.offsetHeight / 2);
|
||||||
|
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||||
|
}
|
||||||
|
|
||||||
|
os.api('notes/create', {
|
||||||
|
renoteId: appearNote.id,
|
||||||
|
channelId: appearNote.channelId,
|
||||||
|
}).then(() => {
|
||||||
|
os.toast(i18n.ts.renoted);
|
||||||
|
renoted.value = true;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const el = renoteButton.value as HTMLElement | null | undefined;
|
||||||
|
if (el) {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const x = rect.left + (el.offsetWidth / 2);
|
||||||
|
const y = rect.top + (el.offsetHeight / 2);
|
||||||
|
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||||
|
}
|
||||||
|
|
||||||
|
os.api('notes/create', {
|
||||||
|
renoteId: appearNote.id,
|
||||||
|
}).then(() => {
|
||||||
|
os.toast(i18n.ts.renoted);
|
||||||
|
renoted.value = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function quote() {
|
||||||
|
pleaseLogin();
|
||||||
|
showMovedDialog();
|
||||||
|
|
||||||
if (appearNote.channel) {
|
if (appearNote.channel) {
|
||||||
items = items.concat([{
|
os.post({
|
||||||
text: i18n.ts.inChannelRenote,
|
renote: appearNote,
|
||||||
icon: 'ph-rocket-launch ph-bold ph-lg',
|
channel: appearNote.channel,
|
||||||
action: () => {
|
}).then(() => {
|
||||||
const el = renoteButton.value as HTMLElement | null | undefined;
|
os.api("notes/renotes", {
|
||||||
if (el) {
|
noteId: appearNote.id,
|
||||||
|
userId: $i.id,
|
||||||
|
limit: 1,
|
||||||
|
quote: true,
|
||||||
|
}).then((res) => {
|
||||||
|
const el = quoteButton.value as HTMLElement | null | undefined;
|
||||||
|
if (el && res.length > 0) {
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
const x = rect.left + (el.offsetWidth / 2);
|
const x = rect.left + (el.offsetWidth / 2);
|
||||||
const y = rect.top + (el.offsetHeight / 2);
|
const y = rect.top + (el.offsetHeight / 2);
|
||||||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||||
}
|
}
|
||||||
|
|
||||||
os.api('notes/create', {
|
quoted.value = res.length > 0;
|
||||||
renoteId: appearNote.id,
|
});
|
||||||
channelId: appearNote.channelId,
|
});
|
||||||
}).then(() => {
|
} else {
|
||||||
os.toast(i18n.ts.renoted);
|
os.post({
|
||||||
renoted.value = true;
|
renote: appearNote,
|
||||||
});
|
}).then(() => {
|
||||||
},
|
os.api("notes/renotes", {
|
||||||
}, {
|
noteId: appearNote.id,
|
||||||
text: i18n.ts.inChannelQuote,
|
userId: $i.id,
|
||||||
icon: 'ph-quotes ph-bold ph-lg',
|
limit: 1,
|
||||||
action: () => {
|
quote: true,
|
||||||
os.post({
|
}).then((res) => {
|
||||||
renote: appearNote,
|
const el = quoteButton.value as HTMLElement | null | undefined;
|
||||||
channel: appearNote.channel,
|
if (el && res.length > 0) {
|
||||||
});
|
const rect = el.getBoundingClientRect();
|
||||||
},
|
const x = rect.left + (el.offsetWidth / 2);
|
||||||
}, null]);
|
const y = rect.top + (el.offsetHeight / 2);
|
||||||
|
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||||
|
}
|
||||||
|
|
||||||
|
quoted.value = res.length > 0;
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
items = items.concat([{
|
|
||||||
text: i18n.ts.renote,
|
|
||||||
icon: 'ph-rocket-launch ph-bold ph-lg',
|
|
||||||
action: () => {
|
|
||||||
const el = renoteButton.value as HTMLElement | null | undefined;
|
|
||||||
if (el) {
|
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
const x = rect.left + (el.offsetWidth / 2);
|
|
||||||
const y = rect.top + (el.offsetHeight / 2);
|
|
||||||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
|
||||||
}
|
|
||||||
|
|
||||||
os.api('notes/create', {
|
|
||||||
renoteId: appearNote.id,
|
|
||||||
}).then(() => {
|
|
||||||
os.toast(i18n.ts.renoted);
|
|
||||||
renoted.value = true;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}, {
|
|
||||||
text: i18n.ts.quote,
|
|
||||||
icon: 'ph-quotes ph-bold ph-lg',
|
|
||||||
action: () => {
|
|
||||||
os.post({
|
|
||||||
renote: appearNote,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}]);
|
|
||||||
|
|
||||||
os.popupMenu(items, renoteButton.value, {
|
|
||||||
viaKeyboard,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function reply(viaKeyboard = false): void {
|
function reply(viaKeyboard = false): void {
|
||||||
|
@ -488,6 +551,14 @@ function undoRenote() : void {
|
||||||
renoted.value = false;
|
renoted.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function undoQuote() : void {
|
||||||
|
os.api("notes/unrenote", {
|
||||||
|
noteId: appearNote.id,
|
||||||
|
quote: true
|
||||||
|
});
|
||||||
|
quoted.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
function onContextmenu(ev: MouseEvent): void {
|
function onContextmenu(ev: MouseEvent): void {
|
||||||
const isLink = (el: HTMLElement) => {
|
const isLink = (el: HTMLElement) => {
|
||||||
if (el.tagName === 'A') return true;
|
if (el.tagName === 'A') return true;
|
||||||
|
@ -550,12 +621,26 @@ function loadReplies() {
|
||||||
os.api('notes/children', {
|
os.api('notes/children', {
|
||||||
noteId: appearNote.id,
|
noteId: appearNote.id,
|
||||||
limit: 30,
|
limit: 30,
|
||||||
|
showQuotes: false,
|
||||||
}).then(res => {
|
}).then(res => {
|
||||||
replies.value = res;
|
replies.value = res;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
loadReplies();
|
loadReplies();
|
||||||
|
|
||||||
|
const quotesLoaded = ref(false);
|
||||||
|
function loadQuotes() {
|
||||||
|
quotesLoaded.value = true;
|
||||||
|
os.api('notes/renotes', {
|
||||||
|
noteId: appearNote.id,
|
||||||
|
limit: 30,
|
||||||
|
quote: true,
|
||||||
|
}).then(res => {
|
||||||
|
quotes.value = res;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
loadQuotes();
|
||||||
|
|
||||||
const conversationLoaded = ref(false);
|
const conversationLoaded = ref(false);
|
||||||
function loadConversation() {
|
function loadConversation() {
|
||||||
conversationLoaded.value = true;
|
conversationLoaded.value = true;
|
||||||
|
|
|
@ -36,6 +36,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<i class="ph-rocket-launch ph-bold ph-lg"></i>
|
<i class="ph-rocket-launch ph-bold ph-lg"></i>
|
||||||
<p v-if="note.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ note.renoteCount }}</p>
|
<p v-if="note.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ note.renoteCount }}</p>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canRenote"
|
||||||
|
ref="quoteButton"
|
||||||
|
class="_button"
|
||||||
|
:class="$style.noteFooterButton"
|
||||||
|
:style="quoted ? 'color: var(--accent) !important;' : ''"
|
||||||
|
@mousedown="quoted ? undoQuote() : quote()"
|
||||||
|
>
|
||||||
|
<i class="ph-quotes ph-bold ph-lg"></i>
|
||||||
|
</button>
|
||||||
<button v-else class="_button" :class="$style.noteFooterButton" disabled>
|
<button v-else class="_button" :class="$style.noteFooterButton" disabled>
|
||||||
<i class="ph-prohibit ph-bold ph-lg"></i>
|
<i class="ph-prohibit ph-bold ph-lg"></i>
|
||||||
</button>
|
</button>
|
||||||
|
@ -114,8 +124,10 @@ const translation = ref(null);
|
||||||
const translating = ref(false);
|
const translating = ref(false);
|
||||||
const isDeleted = ref(false);
|
const isDeleted = ref(false);
|
||||||
const renoted = ref(false);
|
const renoted = ref(false);
|
||||||
|
const quoted = ref(false);
|
||||||
const reactButton = shallowRef<HTMLElement>();
|
const reactButton = shallowRef<HTMLElement>();
|
||||||
const renoteButton = shallowRef<HTMLElement>();
|
const renoteButton = shallowRef<HTMLElement>();
|
||||||
|
const quoteButton = shallowRef<HTMLElement>();
|
||||||
const menuButton = shallowRef<HTMLElement>();
|
const menuButton = shallowRef<HTMLElement>();
|
||||||
const likeButton = shallowRef<HTMLElement>();
|
const likeButton = shallowRef<HTMLElement>();
|
||||||
|
|
||||||
|
@ -142,6 +154,15 @@ if ($i) {
|
||||||
}).then((res) => {
|
}).then((res) => {
|
||||||
renoted.value = res.length > 0;
|
renoted.value = res.length > 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
os.api("notes/renotes", {
|
||||||
|
noteId: appearNote.id,
|
||||||
|
userId: $i.id,
|
||||||
|
limit: 1,
|
||||||
|
quote: true,
|
||||||
|
}).then((res) => {
|
||||||
|
quoted.value = res.length > 0;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function focus() {
|
function focus() {
|
||||||
|
@ -223,80 +244,103 @@ function undoRenote() : void {
|
||||||
renoted.value = false;
|
renoted.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function undoQuote() : void {
|
||||||
|
os.api("notes/unrenote", {
|
||||||
|
noteId: appearNote.id,
|
||||||
|
quote: true
|
||||||
|
});
|
||||||
|
quoted.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
let showContent = $ref(false);
|
let showContent = $ref(false);
|
||||||
let replies: Misskey.entities.Note[] = $ref([]);
|
let replies: Misskey.entities.Note[] = $ref([]);
|
||||||
|
|
||||||
function renote(viaKeyboard = false) {
|
function renote() {
|
||||||
pleaseLogin();
|
pleaseLogin();
|
||||||
showMovedDialog();
|
showMovedDialog();
|
||||||
|
|
||||||
let items = [] as MenuItem[];
|
if (appearNote.channel) {
|
||||||
|
const el = renoteButton.value as HTMLElement | null | undefined;
|
||||||
|
if (el) {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const x = rect.left + (el.offsetWidth / 2);
|
||||||
|
const y = rect.top + (el.offsetHeight / 2);
|
||||||
|
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||||
|
}
|
||||||
|
|
||||||
if (props.note.channel) {
|
os.api('notes/create', {
|
||||||
items = items.concat([{
|
renoteId: props.note.id,
|
||||||
text: i18n.ts.inChannelRenote,
|
channelId: props.note.channelId,
|
||||||
icon: 'ph-rocket-launch ph-bold ph-lg',
|
}).then(() => {
|
||||||
action: () => {
|
os.toast(i18n.ts.renoted);
|
||||||
const el = renoteButton.value as HTMLElement | null | undefined;
|
renoted.value = true;
|
||||||
if (el) {
|
});
|
||||||
|
} else {
|
||||||
|
const el = renoteButton.value as HTMLElement | null | undefined;
|
||||||
|
if (el) {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const x = rect.left + (el.offsetWidth / 2);
|
||||||
|
const y = rect.top + (el.offsetHeight / 2);
|
||||||
|
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||||
|
}
|
||||||
|
|
||||||
|
os.api('notes/create', {
|
||||||
|
renoteId: props.note.id,
|
||||||
|
}).then(() => {
|
||||||
|
os.toast(i18n.ts.renoted);
|
||||||
|
renoted.value = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function quote() {
|
||||||
|
pleaseLogin();
|
||||||
|
showMovedDialog();
|
||||||
|
|
||||||
|
if (appearNote.channel) {
|
||||||
|
os.post({
|
||||||
|
renote: appearNote,
|
||||||
|
channel: appearNote.channel,
|
||||||
|
}).then(() => {
|
||||||
|
os.api("notes/renotes", {
|
||||||
|
noteId: props.note.id,
|
||||||
|
userId: $i.id,
|
||||||
|
limit: 1,
|
||||||
|
quote: true,
|
||||||
|
}).then((res) => {
|
||||||
|
const el = quoteButton.value as HTMLElement | null | undefined;
|
||||||
|
if (el && res.length > 0) {
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
const x = rect.left + (el.offsetWidth / 2);
|
const x = rect.left + (el.offsetWidth / 2);
|
||||||
const y = rect.top + (el.offsetHeight / 2);
|
const y = rect.top + (el.offsetHeight / 2);
|
||||||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||||
}
|
}
|
||||||
|
|
||||||
os.api('notes/create', {
|
quoted.value = res.length > 0;
|
||||||
renoteId: props.note.id,
|
});
|
||||||
channelId: props.note.channelId,
|
});
|
||||||
}).then(() => {
|
} else {
|
||||||
os.toast(i18n.ts.renoted);
|
os.post({
|
||||||
renoted.value = true;
|
renote: appearNote,
|
||||||
});
|
}).then(() => {
|
||||||
},
|
os.api("notes/renotes", {
|
||||||
}, {
|
noteId: props.note.id,
|
||||||
text: i18n.ts.inChannelQuote,
|
userId: $i.id,
|
||||||
icon: 'ph-quotes ph-bold ph-lg',
|
limit: 1,
|
||||||
action: () => {
|
quote: true,
|
||||||
os.post({
|
}).then((res) => {
|
||||||
renote: props.note,
|
const el = quoteButton.value as HTMLElement | null | undefined;
|
||||||
channel: props.note.channel,
|
if (el && res.length > 0) {
|
||||||
});
|
const rect = el.getBoundingClientRect();
|
||||||
},
|
const x = rect.left + (el.offsetWidth / 2);
|
||||||
}, null]);
|
const y = rect.top + (el.offsetHeight / 2);
|
||||||
|
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||||
|
}
|
||||||
|
|
||||||
|
quoted.value = res.length > 0;
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
items = items.concat([{
|
|
||||||
text: i18n.ts.renote,
|
|
||||||
icon: 'ph-rocket-launch ph-bold ph-lg',
|
|
||||||
action: () => {
|
|
||||||
const el = renoteButton.value as HTMLElement | null | undefined;
|
|
||||||
if (el) {
|
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
const x = rect.left + (el.offsetWidth / 2);
|
|
||||||
const y = rect.top + (el.offsetHeight / 2);
|
|
||||||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
|
||||||
}
|
|
||||||
|
|
||||||
os.api('notes/create', {
|
|
||||||
renoteId: props.note.id,
|
|
||||||
}).then(() => {
|
|
||||||
os.toast(i18n.ts.renoted);
|
|
||||||
renoted.value = true;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}, {
|
|
||||||
text: i18n.ts.quote,
|
|
||||||
icon: 'ph-quotes ph-bold ph-lg',
|
|
||||||
action: () => {
|
|
||||||
os.post({
|
|
||||||
renote: props.note,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}]);
|
|
||||||
|
|
||||||
os.popupMenu(items, renoteButton.value, {
|
|
||||||
viaKeyboard,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function menu(viaKeyboard = false): void {
|
function menu(viaKeyboard = false): void {
|
||||||
|
|
Loading…
Reference in a new issue