From 8608801fee6736b8bef91c54358c53d7b7f95f11 Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih Date: Wed, 31 Jan 2024 17:55:18 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat(reversi):=20=E3=82=B2=E3=83=BC?= =?UTF-8?q?=E3=83=A0=E4=B8=AD=E3=81=AB=E3=83=AA=E3=82=A2=E3=82=AF=E3=82=B7?= =?UTF-8?q?=E3=83=A7=E3=83=B3=E3=82=92=E6=89=93=E3=81=A6=E3=82=8B=E3=82=88?= =?UTF-8?q?=E3=81=86=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/core/GlobalEventService.ts | 4 + packages/backend/src/core/ReactionService.ts | 2 +- packages/backend/src/core/ReversiService.ts | 41 ++++ .../api/stream/channels/reversi-game.ts | 8 + .../frontend/src/components/MkBalloon.vue | 77 +++++++ .../frontend/src/pages/reversi/game.board.vue | 189 +++++++++++++++++- .../src/pages/reversi/game.emoji-balloon.vue | 88 ++++++++ packages/misskey-js/src/streaming.types.ts | 2 + 8 files changed, 408 insertions(+), 3 deletions(-) create mode 100644 packages/frontend/src/components/MkBalloon.vue create mode 100644 packages/frontend/src/pages/reversi/game.emoji-balloon.vue diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 6a72671665..7e96904b4c 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -191,6 +191,10 @@ export interface ReversiGameEventTypes { canceled: { userId: MiUser['id']; }; + reacted: { + userId: MiUser['id']; + reaction: string; + }; } //#endregion diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts index 2e8f76fa8a..c205d08802 100644 --- a/packages/backend/src/core/ReactionService.ts +++ b/packages/backend/src/core/ReactionService.ts @@ -64,7 +64,7 @@ type DecodedReaction = { host?: string | null; }; -const isCustomEmojiRegexp = /^:([\w+-]+)(?:@\.)?:$/; +export const isCustomEmojiRegexp = /^:([\w+-]+)(?:@\.)?:$/; const decodeCustomEmojiRegexp = /^:([\w+-]+)(?:@([\w.-]+))?:$/; @Injectable() diff --git a/packages/backend/src/core/ReversiService.ts b/packages/backend/src/core/ReversiService.ts index 186ec6d7b1..9531bbb001 100644 --- a/packages/backend/src/core/ReversiService.ts +++ b/packages/backend/src/core/ReversiService.ts @@ -20,6 +20,8 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { IdService } from '@/core/IdService.js'; import { NotificationService } from '@/core/NotificationService.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import { isCustomEmojiRegexp, ReactionService } from '@/core/ReactionService.js'; import { Serialized } from '@/types.js'; import { ReversiGameEntityService } from './entities/ReversiGameEntityService.js'; import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common'; @@ -44,6 +46,8 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { private globalEventService: GlobalEventService, private reversiGameEntityService: ReversiGameEntityService, private idService: IdService, + private customEmojiService: CustomEmojiService, + private reactionService: ReactionService, ) { } @@ -602,6 +606,43 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { } } + @bindThis + public async sendReaction(gameId: MiReversiGame['id'], user: MiUser, reaction: string) { + const game = await this.get(gameId); + if (game == null) throw new Error('game not found'); + if (!game.isStarted || game.isEnded) return; + if ((game.user1Id !== user.id) && (game.user2Id !== user.id)) return; + + const lastReactedAt = await this.redisClient.get(`reversi:game:lastReactedAt:${game.id}:${user.id}`); + + if (lastReactedAt && (Date.now() - parseInt(lastReactedAt, 10) < 3000)) { + // レートリミット(3秒) + return; + } + + let _reaction = '❤️'; + + const custom = reaction.match(isCustomEmojiRegexp); + + if (custom) { + const name = custom[1]; + + const emoji = (await this.customEmojiService.localEmojisCache.fetch()).get(name); + if (emoji && !emoji.isSensitive) { + _reaction = `:${name}:`; + } + } else { + _reaction = this.reactionService.normalize(reaction); + } + + this.globalEventService.publishReversiGameStream(game.id, 'reacted', { + userId: user.id, + reaction: _reaction, + }); + + this.redisClient.setex(`reversi:game:lastReactedAt:${game.id}:${user.id}`, 60 * 60, Date.now().toString()); + } + @bindThis public dispose(): void { } diff --git a/packages/backend/src/server/api/stream/channels/reversi-game.ts b/packages/backend/src/server/api/stream/channels/reversi-game.ts index fb24a29b75..a2cddc749f 100644 --- a/packages/backend/src/server/api/stream/channels/reversi-game.ts +++ b/packages/backend/src/server/api/stream/channels/reversi-game.ts @@ -42,6 +42,7 @@ class ReversiGameChannel extends Channel { case 'cancel': this.cancelGame(); break; case 'putStone': this.putStone(body.pos, body.id); break; case 'claimTimeIsUp': this.claimTimeIsUp(); break; + case 'reaction': this.sendReaction(body); break; } } @@ -80,6 +81,13 @@ class ReversiGameChannel extends Channel { this.reversiService.checkTimeout(this.gameId!); } + @bindThis + private async sendReaction(reaction: string) { + if (this.user == null) return; + + this.reversiService.sendReaction(this.gameId!, this.user, reaction); + } + @bindThis public dispose() { // Unsubscribe events diff --git a/packages/frontend/src/components/MkBalloon.vue b/packages/frontend/src/components/MkBalloon.vue new file mode 100644 index 0000000000..57df4509a4 --- /dev/null +++ b/packages/frontend/src/components/MkBalloon.vue @@ -0,0 +1,77 @@ + + + + + + + diff --git a/packages/frontend/src/pages/reversi/game.board.vue b/packages/frontend/src/pages/reversi/game.board.vue index eb4dbfdee0..14c71a3ee5 100644 --- a/packages/frontend/src/pages/reversi/game.board.vue +++ b/packages/frontend/src/pages/reversi/game.board.vue @@ -8,9 +8,13 @@ SPDX-License-Identifier: AGPL-3.0-only
({{ i18n.ts._reversi.black }}) - +
+ +
vs - +
+ +
({{ i18n.ts._reversi.white }})
@@ -121,6 +125,19 @@ SPDX-License-Identifier: AGPL-3.0-only
+
+
{{ i18n.ts.reaction }}
+
+ + +
+
+
@@ -145,9 +162,12 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, onActivated, onDeactivated, onMounted, onUnmounted, ref, shallowRef, triggerRef, watch } from 'vue'; import * as Misskey from 'misskey-js'; import * as Reversi from 'misskey-reversi'; +import type { UnicodeEmojiDef } from '@/scripts/emojilist.js'; import MkButton from '@/components/MkButton.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkSwitch from '@/components/MkSwitch.vue'; +import MkRippleEffect from '@/components/MkRippleEffect.vue'; +import XEmojiBalloon from './game.emoji-balloon.vue'; import { deepClone } from '@/scripts/clone.js'; import { useInterval } from '@/scripts/use-interval.js'; import { signinRequired } from '@/account.js'; @@ -156,6 +176,8 @@ import { misskeyApi } from '@/scripts/misskey-api.js'; import { userPage } from '@/filters/user.js'; import * as sound from '@/scripts/sound.js'; import * as os from '@/os.js'; +import { defaultStore } from '@/store.js'; +import { reactionPicker } from '@/scripts/reaction-picker.js'; import { confetti } from '@/scripts/confetti.js'; const $i = signinRequired(); @@ -183,6 +205,7 @@ const iAmPlayer = computed(() => { return game.value.user1Id === $i.id || game.value.user2Id === $i.id; }); +// true: 黒, false: 白 const myColor = computed(() => { if (!iAmPlayer.value) return null; if (game.value.user1Id === $i.id && game.value.black === 1) return true; @@ -447,9 +470,109 @@ function share() { }); } +const _reactionEmojis = ref(defaultStore.reactiveState.reactions.value); +const reactionEmojis = computed(() => _reactionEmojis.value.slice(0, 10)); + +const blackUserEl = ref(null); +const whiteUserEl = ref(null); + +const canReact = ref(true); +let canReactFallbackTimer: number | null = null; + +function getKey(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef): string { + return typeof emoji === 'string' ? emoji : 'char' in emoji ? emoji.char : `:${emoji.name}:`; +} + +// 既にでている絵文字をクリックした際の挙動 +function onReactionEmojiClick(emoji: string, ev?: MouseEvent) { + const el = ev && (ev.currentTarget ?? ev.target) 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 key = getKey(emoji); + + sendReaction(key); + + if (!_reactionEmojis.value.includes(key)) { + _reactionEmojis.value.unshift(key); + } +} + +// #region リアクションピッカー +const reactButton = ref(null); + +function onReactionPickerClick() { + reactionPicker.show(reactButton.value ?? null, reaction => { + const key = getKey(reaction); + sendReaction(key); + + if (!_reactionEmojis.value.includes(key)) { + _reactionEmojis.value.unshift(key); + } + }); +} +// #endregion + +function sendReaction(emojiKey: string) { + if (!canReact.value) return; + canReact.value = false; + + // リアクション音は受信時のみ + props.connection!.send('reaction', emojiKey); + + // リアクションを無効にする(受信を確認できて3秒後 or 明らかに遅い場合は解除) + if (canReactFallbackTimer != null) { + window.clearTimeout(canReactFallbackTimer); + } + canReactFallbackTimer = window.setTimeout(() => { + if (!canReact.value) { + canReact.value = true; + } + }, 10000); +} + +function onReacted(payload: Parameters['0']) { + console.log('onReacted', payload); + + const { userId, reaction } = payload; + + sound.playMisskeySfx('reaction'); + + const el = (userId === blackUser.value.id) ? blackUserEl.value : whiteUserEl.value; + + if (el) { + const rect = el.getBoundingClientRect(); + const x = rect.right; + const y = rect.bottom; + os.popup(XEmojiBalloon, { + reaction, + tail: 'left', + x, + y, + }, {}, 'end'); + } + + if (userId === $i.id) { + // リアクションが可能になるまでのタイマー + window.setTimeout(() => { + if (canReactFallbackTimer != null) { + window.clearTimeout(canReactFallbackTimer); + } + if (!canReact.value) { + canReact.value = true; + } + }, 3000); + } +} + onMounted(() => { if (props.connection != null) { props.connection.on('log', onStreamLog); + props.connection.on('reacted', onReacted); props.connection.on('ended', onStreamEnded); } }); @@ -457,6 +580,7 @@ onMounted(() => { onActivated(() => { if (props.connection != null) { props.connection.on('log', onStreamLog); + props.connection.on('reacted', onReacted); props.connection.on('ended', onStreamEnded); } }); @@ -464,6 +588,7 @@ onActivated(() => { onDeactivated(() => { if (props.connection != null) { props.connection.off('log', onStreamLog); + props.connection.off('reacted', onReacted); props.connection.off('ended', onStreamEnded); } }); @@ -471,6 +596,7 @@ onDeactivated(() => { onUnmounted(() => { if (props.connection != null) { props.connection.off('log', onStreamLog); + props.connection.off('reacted', onReacted); props.connection.off('ended', onStreamEnded); } }); @@ -630,4 +756,63 @@ $gap: 4px; height: 100%; border-radius: 100%; } + +.reactionLabel { + font-size: 0.85em; + padding: 0 0 8px 0; + user-select: none; + + &:empty { + display: none; + } +} + +.reactionPickerBar { + display: flex; + flex-wrap: wrap; + padding: 12px; + background: var(--bg); + border-radius: calc(var(--radius) / 2); +} + +.emojisItem { + font-size: 24px; + border-radius: 4px; + width: 40px; + height: 40px; + + &.plus { + font-size: 16px; + + &:active { + background: var(--accentedBg); + } + } + + &:focus-visible { + outline: solid 2px var(--focus); + z-index: 1; + } + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + &:not(:disabled):active { + background: var(--accent); + box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15); + } + + &:disabled { + opacity: 0.7; + } + + > .emoji { + height: 1.25em; + vertical-align: -.25em; + pointer-events: none; + width: 100%; + object-fit: contain; + } +} diff --git a/packages/frontend/src/pages/reversi/game.emoji-balloon.vue b/packages/frontend/src/pages/reversi/game.emoji-balloon.vue new file mode 100644 index 0000000000..8d943f1fdb --- /dev/null +++ b/packages/frontend/src/pages/reversi/game.emoji-balloon.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/packages/misskey-js/src/streaming.types.ts b/packages/misskey-js/src/streaming.types.ts index 0ba5715d68..5657ec326e 100644 --- a/packages/misskey-js/src/streaming.types.ts +++ b/packages/misskey-js/src/streaming.types.ts @@ -208,6 +208,7 @@ export type Channels = { canceled: (payload: { userId: User['id']; }) => void; changeReadyStates: (payload: { user1: boolean; user2: boolean; }) => void; updateSettings: (payload: { userId: User['id']; key: string; value: any; }) => void; + reacted: (payload: { userId: User['id']; reaction: string; }) => void; log: (payload: Record) => void; }; receives: { @@ -222,6 +223,7 @@ export type Channels = { value: any; }; claimTimeIsUp: null | Record; + reaction: string; } } }; From efc272a340be7c1cb62b6a3adeb1ec5784cc9b08 Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih Date: Wed, 31 Jan 2024 17:56:30 +0900 Subject: [PATCH 2/8] run api extractor --- packages/misskey-js/etc/misskey-js.api.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 860d3629d6..7b45912cb8 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -715,6 +715,10 @@ export type Channels = { key: string; value: any; }) => void; + reacted: (payload: { + userId: User['id']; + reaction: string; + }) => void; log: (payload: Record) => void; }; receives: { @@ -729,6 +733,7 @@ export type Channels = { value: any; }; claimTimeIsUp: null | Record; + reaction: string; }; }; }; From 4c50b0a5dd75b8444c50b20acff2b925cf5622d6 Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih Date: Thu, 1 Feb 2024 07:26:05 +0900 Subject: [PATCH 3/8] =?UTF-8?q?=E3=83=AA=E3=82=A2=E3=82=AF=E3=82=B7?= =?UTF-8?q?=E3=83=A7=E3=83=B3=E3=82=92=E8=A1=A8=E7=A4=BA=E3=81=99=E3=82=8B?= =?UTF-8?q?=E3=81=8B=E3=81=A9=E3=81=86=E3=81=8B=E3=82=92=E9=81=B8=E3=81=B9?= =?UTF-8?q?=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../frontend/src/pages/reversi/game.board.vue | 34 ++++++++++--------- .../src/pages/reversi/game.emoji-balloon.vue | 5 ++- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/packages/frontend/src/pages/reversi/game.board.vue b/packages/frontend/src/pages/reversi/game.board.vue index 14c71a3ee5..4dcc5043f4 100644 --- a/packages/frontend/src/pages/reversi/game.board.vue +++ b/packages/frontend/src/pages/reversi/game.board.vue @@ -142,6 +142,7 @@ SPDX-License-Identifier: AGPL-3.0-only
Show labels + Show reactions useAvatarAsStone
@@ -188,6 +189,7 @@ const props = defineProps<{ }>(); const showBoardLabels = ref(false); +const showReactions = ref(true); const useAvatarAsStone = ref(true); const autoplaying = ref(false); // eslint-disable-next-line vue/no-setup-props-destructure @@ -536,24 +538,24 @@ function sendReaction(emojiKey: string) { } function onReacted(payload: Parameters['0']) { - console.log('onReacted', payload); - const { userId, reaction } = payload; - sound.playMisskeySfx('reaction'); + if (showReactions.value || userId === $i.id) { + sound.playMisskeySfx('reaction'); - const el = (userId === blackUser.value.id) ? blackUserEl.value : whiteUserEl.value; + const el = (userId === blackUser.value.id) ? blackUserEl.value : whiteUserEl.value; - if (el) { - const rect = el.getBoundingClientRect(); - const x = rect.right; - const y = rect.bottom; - os.popup(XEmojiBalloon, { - reaction, - tail: 'left', - x, - y, - }, {}, 'end'); + if (el) { + const rect = el.getBoundingClientRect(); + const x = rect.right; + const y = rect.bottom; + os.popup(XEmojiBalloon, { + reaction, + tail: 'left', + x, + y, + }, {}, 'end'); + } } if (userId === $i.id) { @@ -789,12 +791,12 @@ $gap: 4px; } } - &:focus-visible { + &:not(:disabled):focus-visible { outline: solid 2px var(--focus); z-index: 1; } - &:hover { + &:not(:disabled):hover { background: rgba(0, 0, 0, 0.05); } diff --git a/packages/frontend/src/pages/reversi/game.emoji-balloon.vue b/packages/frontend/src/pages/reversi/game.emoji-balloon.vue index 8d943f1fdb..fecb760430 100644 --- a/packages/frontend/src/pages/reversi/game.emoji-balloon.vue +++ b/packages/frontend/src/pages/reversi/game.emoji-balloon.vue @@ -49,7 +49,7 @@ onMounted(() => { active.value = false; setTimeout(() => { emit('end'); - }, 1000); + }, 750); }, 3000); }); @@ -60,7 +60,7 @@ onMounted(() => { } .transition_balloon_leaveActive { - transition: all 1s ease; + transition: all .75s ease; } .transition_balloon_enterFrom { @@ -75,7 +75,6 @@ onMounted(() => { .balloonRoot { position: absolute; filter: drop-shadow(0 2px 8px var(--shadow)); - --balloon-radius: 24px; user-select: none; pointer-events: none; } From 46c61602d4be9d4dbf13ea6491e24a71a2f28143 Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih Date: Thu, 1 Feb 2024 07:32:47 +0900 Subject: [PATCH 4/8] fix --- packages/frontend/src/pages/reversi/game.board.vue | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/frontend/src/pages/reversi/game.board.vue b/packages/frontend/src/pages/reversi/game.board.vue index 4dcc5043f4..50910b8649 100644 --- a/packages/frontend/src/pages/reversi/game.board.vue +++ b/packages/frontend/src/pages/reversi/game.board.vue @@ -142,7 +142,7 @@ SPDX-License-Identifier: AGPL-3.0-only
Show labels - Show reactions + Show reaction useAvatarAsStone
@@ -189,7 +189,7 @@ const props = defineProps<{ }>(); const showBoardLabels = ref(false); -const showReactions = ref(true); +const showReaction = ref(true); const useAvatarAsStone = ref(true); const autoplaying = ref(false); // eslint-disable-next-line vue/no-setup-props-destructure @@ -540,7 +540,7 @@ function sendReaction(emojiKey: string) { function onReacted(payload: Parameters['0']) { const { userId, reaction } = payload; - if (showReactions.value || userId === $i.id) { + if (showReaction.value || userId === $i.id) { sound.playMisskeySfx('reaction'); const el = (userId === blackUser.value.id) ? blackUserEl.value : whiteUserEl.value; From 66a7a9e9d1050d5ffeecbd961f96236afed03bd7 Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih Date: Thu, 1 Feb 2024 07:36:32 +0900 Subject: [PATCH 5/8] spdx --- packages/frontend/src/pages/reversi/game.emoji-balloon.vue | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/frontend/src/pages/reversi/game.emoji-balloon.vue b/packages/frontend/src/pages/reversi/game.emoji-balloon.vue index fecb760430..6beebebd4c 100644 --- a/packages/frontend/src/pages/reversi/game.emoji-balloon.vue +++ b/packages/frontend/src/pages/reversi/game.emoji-balloon.vue @@ -1,3 +1,8 @@ + +