emoji more

This commit is contained in:
mattyatea 2024-06-20 13:48:44 +09:00
parent bba25036fa
commit a64c607584
10 changed files with 158 additions and 130 deletions

View file

@ -162,26 +162,30 @@ export class ReactionService {
};
// Create reaction
try {
await this.noteReactionsRepository.insert(record);
} catch (e) {
if (isDuplicateKeyValueError(e)) {
const exists = await this.noteReactionsRepository.findOneByOrFail({
const exists = await this.noteReactionsRepository.findOneBy({
noteId: note.id,
userId: user.id,
reaction: record.reaction,
});
const count = await this.noteReactionsRepository.countBy({
noteId: note.id,
userId: user.id,
});
if (exists.reaction !== reaction) {
// 別のリアクションがすでにされていたら置き換える
await this.delete(user, note);
if (count > 3) {
throw new IdentifiableError('51c42bb4-931a-456b-bff7-e5a8a70dd298');
}
if (exists == null) {
if (user.host == null) {
await this.noteReactionsRepository.insert(record);
} else {
// 同じリアクションがすでにされていたらエラー
throw new IdentifiableError('51c42bb4-931a-456b-bff7-e5a8a70dd298');
}
} else {
throw e;
}
// 同じリアクションがすでにされていたらエラー
throw new IdentifiableError('51c42bb4-931a-456b-bff7-e5a8a70dd298');
}
// Increment reactions count
@ -275,17 +279,24 @@ export class ReactionService {
}
@bindThis
public async delete(user: { id: MiUser['id']; host: MiUser['host']; isBot: MiUser['isBot']; }, note: MiNote) {
public async delete(user: { id: MiUser['id']; host: MiUser['host']; isBot: MiUser['isBot']; }, note: MiNote, reaction?: string) {
// if already unreacted
const exist = await this.noteReactionsRepository.findOneBy({
let exist;
if (reaction == null) {
exist = await this.noteReactionsRepository.findOneBy({
noteId: note.id,
userId: user.id,
});
} else {
exist = await this.noteReactionsRepository.findOneBy({
noteId: note.id,
userId: user.id,
reaction: reaction.replace(/@./, ''),
});
}
if (exist == null) {
throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted');
}
// Delete reaction
const result = await this.noteReactionsRepository.delete(exist.id);

View file

@ -209,6 +209,30 @@ export class NoteEntityService implements OnModuleInit {
return undefined;
}
@bindThis
public async populateMyReactions(note: { id: MiNote['id']; reactions: MiNote['reactions']; reactionAndUserPairCache?: MiNote['reactionAndUserPairCache']; }, meId: MiUser['id'], _hint_?: {
myReactions: Map<MiNote['id'], string | null>;
}) {
const reactionsCount = Object.values(note.reactions).reduce((a, b) => a + b, 0);
if (reactionsCount === 0) return undefined;
// パフォーマンスのためートが作成されてから2秒以上経っていない場合はリアクションを取得しない
if (this.idService.parse(note.id).date.getTime() + 2000 > Date.now()) {
return undefined;
}
const reactions = await this.noteReactionsRepository.findBy({
userId: meId,
noteId: note.id,
});
if (reactions.length > 0) {
return reactions.map(reaction => this.reactionService.convertLegacyReaction(reaction.reaction));
}
return undefined;
}
@bindThis
public async isVisibleForMe(note: MiNote, meId: MiUser['id'] | null): Promise<boolean> {
@ -382,6 +406,7 @@ export class NoteEntityService implements OnModuleInit {
...(meId && Object.keys(note.reactions).length > 0 ? {
myReaction: this.populateMyReaction(note, meId, options?._hint_),
myReactions: this.populateMyReactions(note, meId, options?._hint_),
} : {}),
} : {}),
});

View file

@ -9,7 +9,8 @@ import { MiUser } from './User.js';
import { MiNote } from './Note.js';
@Entity('note_reaction')
@Index(['userId', 'noteId'], { unique: true })
@Index(['userId', 'noteId', 'reaction'], { unique: true })
@Index(['userId', 'noteId'])
export class MiNoteReaction {
@PrimaryColumn(id())
public id: string;

View file

@ -42,6 +42,7 @@ export const paramDef = {
type: 'object',
properties: {
noteId: { type: 'string', format: 'misskey:id' },
reaction: { type: 'string' },
},
required: ['noteId'],
} as const;
@ -57,7 +58,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
throw err;
});
await this.reactionService.delete(me, note).catch(err => {
await this.reactionService.delete(me, note, ps.reaction ?? undefined).catch(err => {
if (err.id === '60527ec9-b4cb-4a88-a6bd-32d3ad26817d') throw new ApiError(meta.errors.notReacted);
throw err;
});

View file

@ -39,8 +39,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<i class="ti ti-dots" :class="$style.renoteMenu"></i>
<MkTime :time="note.createdAt"/>
</button>
<span v-if="note.visibility !== 'public'" style="margin-left: 0.5em;"
:title="i18n.ts._visibility[note.visibility]">
<span
v-if="note.visibility !== 'public'" style="margin-left: 0.5em;"
:title="i18n.ts._visibility[note.visibility]"
>
<i v-if="note.visibility === 'home'" class="ti ti-home"></i>
<i v-else-if="note.visibility === 'followers'" class="ti ti-lock"></i>
<i v-else-if="note.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
@ -127,8 +129,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<i class="ti ti-ban"></i>
</button>
<button ref="reactButton" :class="$style.footerButton" class="_button" @click="toggleReact()">
<i v-if="appearNote.reactionAcceptance === 'likeOnly' && appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--eventReactionHeart);"></i>
<i v-else-if="appearNote.myReaction != null" class="ti ti-minus" style="color: var(--accent);"></i>
<i v-if="appearNote.reactionAcceptance === 'likeOnly' && appearNote.myReactions?.length >= 3 " class="ti ti-heart-filled" style="color: var(--eventReactionHeart);"></i>
<i v-else-if="appearNote.myReactions?.length >= 3 " class="ti ti-minus" style="color: var(--accent);"></i>
<i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
<i v-else class="ti ti-plus"></i>
<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || defaultStore.state.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.reactionCount) }}</p>
@ -208,7 +210,7 @@ import {shouldCollapsed} from '@/scripts/collapsed.js';
import { isEnabledUrlPreview } from '@/instance.js';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
note: Misskey.entities.Note & {myReactions: string[]};
pinned?: boolean;
mock?: boolean;
withHardMute?: boolean;
@ -441,25 +443,9 @@ function react(viaKeyboard = false): void {
}
}
function undoReact(targetNote: Misskey.entities.Note): void {
const oldReaction = targetNote.myReaction;
if (!oldReaction) return;
if (props.mock) {
emit('removeReaction', oldReaction);
return;
}
misskeyApi('notes/reactions/delete', {
noteId: targetNote.id,
});
}
function toggleReact() {
if (appearNote.value.myReaction == null) {
if (appearNote.value.myReactions?.length > 3 || !appearNote.value.myReactions ) {
react();
} else {
undoReact(appearNote.value);
}
}
@ -491,7 +477,7 @@ function onContextmenu(ev: MouseEvent): void {
translation,
isDeleted,
currentClip: currentClip?.value
currentClip: currentClip?.value,
});
os.contextMenu(menu, ev).then(focus).finally(cleanup);
}
@ -508,7 +494,7 @@ function showMenu(viaKeyboard = false): void {
translation,
isDeleted,
currentClip: currentClip?.value
currentClip: currentClip?.value,
});
os.popupMenu(menu, menuButton.value, {
viaKeyboard,
@ -523,7 +509,7 @@ async function clip() {
os.popupMenu(await getNoteClipMenu({
note: note.value,
isDeleted,
currentClip: currentClip?.value
currentClip: currentClip?.value,
}), clipButton.value).then(focus);
}
@ -583,13 +569,6 @@ function focusAfter() {
focusNext(rootEl.value ?? null);
}
function readPromo() {
misskeyApi('promo/read', {
noteId: appearNote.value.id,
});
isDeleted.value = true;
}
function emitUpdReaction(emoji: string, delta: number) {
if (delta < 0) {
emit('removeReaction', emoji);

View file

@ -129,8 +129,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<i class="ti ti-ban"></i>
</button>
<button ref="reactButton" :class="$style.noteFooterButton" class="_button" @click="toggleReact()">
<i v-if="appearNote.reactionAcceptance === 'likeOnly' && appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--eventReactionHeart);"></i>
<i v-else-if="appearNote.myReaction != null" class="ti ti-minus" style="color: var(--accent);"></i>
<i v-if="appearNote.reactionAcceptance === 'likeOnly' && appearNote.myReactions?.length >= 3 " class="ti ti-heart-filled" style="color: var(--eventReactionHeart);"></i>
<i v-else-if="appearNote.myReactions?.length >= 3 " class="ti ti-minus" style="color: var(--accent);"></i>
<i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
<i v-else class="ti ti-plus"></i>
<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || defaultStore.state.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.reactionCount) }}</p>
@ -257,13 +257,11 @@ import MkPagination, { type Paging } from '@/components/MkPagination.vue';
import MkReactionIcon from '@/components/MkReactionIcon.vue';
import MkButton from '@/components/MkButton.vue';
import { miLocalStorage } from '@/local-storage.js';
import { infoImageUrl, instance } from '@/instance.js';
import { infoImageUrl, instance, isEnabledUrlPreview } from '@/instance.js';
import MkPostForm from '@/components/MkPostFormSimple.vue';
import { deviceKind } from '@/scripts/device-kind.js';
const MOBILE_THRESHOLD = 500;
const isMobile = ref(deviceKind === 'smartphone' || window.innerWidth <= MOBILE_THRESHOLD);
import { isEnabledUrlPreview } from '@/instance.js';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
@ -504,10 +502,8 @@ function undoReact(targetNote: Misskey.entities.Note): void {
}
function toggleReact() {
if (appearNote.value.myReaction == null) {
if (appearNote.value.myReactions?.length > 3 || !appearNote.value.myReactions ) {
react();
} else {
undoReact(appearNote.value);
}
}

View file

@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
ref="buttonEl"
v-ripple="canToggle"
class="_button"
:class="[$style.root, { [$style.gamingDark]: gamingType === 'dark',[$style.gamingLight]: gamingType === 'light' ,[$style.reacted]: note.myReaction == reaction, [$style.canToggle]: canToggle, [$style.small]: defaultStore.state.reactionsDisplaySize === 'small', [$style.large]: defaultStore.state.reactionsDisplaySize === 'large' }]"
:class="[$style.root, { [$style.gamingDark]: gamingType === 'dark',[$style.gamingLight]: gamingType === 'light' ,[$style.reacted]: note.myReactions?.includes(reaction) , [$style.canToggle]: canToggle, [$style.small]: defaultStore.state.reactionsDisplaySize === 'small', [$style.large]: defaultStore.state.reactionsDisplaySize === 'large' }]"
@click="toggleReaction()"
@contextmenu.prevent.stop="menu"
>
@ -42,7 +42,9 @@ const props = defineProps<{
reaction: string;
count: number;
isInitial: boolean;
note: Misskey.entities.Note;
note: Misskey.entities.Note & {
myReactions: string[];
}
}>();
const mock = inject<boolean>('mock', false);
@ -64,14 +66,15 @@ const canGetInfo = computed(() => !props.reaction.match(/@\w/) && props.reaction
async function toggleReaction() {
if (!canToggle.value) return;
const oldReaction = props.note.myReaction;
const oldReaction = props.note.myReactions.includes(props.reaction) ? props.reaction : null;
console.log(oldReaction);
if (oldReaction) {
const confirm = await os.confirm({
type: 'warning',
text: oldReaction !== props.reaction ? i18n.ts.changeReactionConfirm : i18n.ts.cancelReactionConfirm,
});
if (confirm.canceled) return;
props.note.myReactions.splice(props.note.myReactions.indexOf(oldReaction), 1);
if (oldReaction !== props.reaction) {
sound.playMisskeySfx('reaction');
}
@ -83,6 +86,7 @@ async function toggleReaction() {
misskeyApi('notes/reactions/delete', {
noteId: props.note.id,
reaction: oldReaction,
}).then(() => {
if (oldReaction !== props.reaction ) {
misskeyApi('notes/reactions/create', {

View file

@ -24,7 +24,9 @@ import XReaction from '@/components/MkReactionsViewer.reaction.vue';
import { defaultStore } from '@/store.js';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
note: Misskey.entities.Note & {
myReactions: string[];
}
maxNumber?: number;
}>(), {
maxNumber: Infinity,
@ -57,7 +59,6 @@ function onMockToggleReaction(emoji: string, count: number) {
watch([() => props.note.reactions, () => props.maxNumber], ([newSource, maxNumber]) => {
let newReactions: [string, number][] = [];
hasMoreReactions.value = Object.keys(newSource).length > maxNumber;
for (let i = 0; i < reactions.value.length; i++) {
const reaction = reactions.value[i][0];
if (reaction in newSource && newSource[reaction] !== 0) {

View file

@ -84,7 +84,9 @@ const props = withDefaults(defineProps<{
height: var(--size);
margin: 0 auto;
}
.text{
color: var(--fg);
}
.spinner {
position: absolute;
top: 0;

View file

@ -39,6 +39,13 @@ export function useNoteCapture(props: {
if ($i && (body.userId === $i.id)) {
note.value.myReaction = reaction;
console.log(note.value.myReactions);
if (!note.value.myReactions) {
note.value.myReactions = [];
note.value.myReactions.push(reaction);
} else if (!note.value.myReactions.includes(reaction)) {
note.value.myReactions.push(reaction);
}
}
break;
}
@ -55,6 +62,7 @@ export function useNoteCapture(props: {
if ($i && (body.userId === $i.id)) {
note.value.myReaction = null;
note.value.myReactions = note.value.myReactions.filter(r => r !== reaction);
}
break;
}