From eab1970d5ce822360b2661530560c51908df2dac Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 18:39:23 -0400 Subject: [PATCH 1/4] feature: chat message reactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anyone who can post in a room can react to any line in it, website or game, with one of six reactions: 👍 ❤️ 😂 🔥 😮 😢. Reactions show as pills under the message with a count, in the api's fixed order, and the ones you hold are picked out in amber. Clicking a pill toggles it, and hovering it names who reacted from what this browser already knows (the room's participants and whoever has spoken), counting the rest. The message menu gains Add Reaction above Edit, which opens a picker of all six hung off the trigger. The menu, the picker and the confirm all hold the right hub open. A gagged player in a group room can only take back what they hold: the picker disables the rest and says why. The socket sends lobby:react through the chat request plumbing and listens for the room's `reaction` event, whose whole-state payload replaces the message's reactions and never creates or revives one. A history snapshot keeps reactions that changed live after the room was joined, since the api sends no version to compare. Refusals toast through chatErrors, with copy for rate_limited and gagged. --- components/chat/ChatLobby.vue | 2 + components/chat/ChatMessage.vue | 28 ++- components/chat/ChatMessageActions.vue | 199 ++++++++++++--- components/chat/ChatMessageReactions.vue | 178 ++++++++++++++ components/chat/ChatMessages.vue | 5 + constants/chat.ts | 13 + i18n/locales/ar_SA.json | 11 + i18n/locales/da_DK.json | 11 + i18n/locales/de_DE.json | 11 + i18n/locales/en.json | 11 + i18n/locales/es_ES.json | 11 + i18n/locales/fr_FR.json | 11 + i18n/locales/it_IT.json | 11 + i18n/locales/ja_JP.json | 11 + i18n/locales/ko_KR.json | 11 + i18n/locales/pl_PL.json | 11 + i18n/locales/pt_BR.json | 11 + i18n/locales/ru_RU.json | 11 + i18n/locales/sv_SE.json | 11 + i18n/locales/tr_TR.json | 11 + i18n/locales/uk_UA.json | 11 + i18n/locales/zh_Hans.json | 11 + i18n/locales/zh_Hant.json | 11 + tests/components/ChatLobby.spec.ts | 29 ++- tests/components/ChatMessage.spec.ts | 139 +++++++++++ tests/components/ChatMessageActions.spec.ts | 180 ++++++++++++++ tests/components/ChatMessageReactions.spec.ts | 178 ++++++++++++++ tests/utilities/chatErrors.spec.ts | 30 +++ tests/utilities/chatLobbyMessages.spec.ts | 109 +++++++++ tests/utilities/chatMessageActions.spec.ts | 185 ++++++++++++-- tests/web-sockets/SocketChatError.spec.ts | 227 ++++++++++++++++++ utilities/chatErrors.ts | 17 +- utilities/chatLobbyMessages.ts | 74 +++++- utilities/chatMessageActions.ts | 53 +++- web-sockets/Socket.ts | 66 +++++ 35 files changed, 1834 insertions(+), 65 deletions(-) create mode 100644 components/chat/ChatMessageReactions.vue create mode 100644 tests/components/ChatMessageReactions.spec.ts diff --git a/components/chat/ChatLobby.vue b/components/chat/ChatLobby.vue index b0175e4b..5396f062 100644 --- a/components/chat/ChatLobby.vue +++ b/components/chat/ChatLobby.vue @@ -110,6 +110,7 @@ import Empty from "~/components/ui/empty/Empty.vue"; :last-read-count="lastReadMessageCount" :message-room="messageRoom" :can-moderate="canModerate" + :can-post="canSend" @bottom-state-change="handleBottomStateChange" /> @@ -282,6 +283,7 @@ import Empty from "~/components/ui/empty/Empty.vue"; :last-read-count="tracksReadPosition ? lastReadMessageCount : 0" :message-room="messageRoom" :can-moderate="canModerate" + :can-post="canSend" @bottom-state-change="handleBottomStateChange" /> diff --git a/components/chat/ChatMessage.vue b/components/chat/ChatMessage.vue index debc247d..8176babc 100644 --- a/components/chat/ChatMessage.vue +++ b/components/chat/ChatMessage.vue @@ -4,6 +4,7 @@ import TimeAgo from "~/components/TimeAgo.vue"; import PlayerDisplay from "~/components/PlayerDisplay.vue"; import ChatMessageActions from "~/components/chat/ChatMessageActions.vue"; import ChatMessageEditor from "~/components/chat/ChatMessageEditor.vue"; +import ChatMessageReactions from "~/components/chat/ChatMessageReactions.vue"; import FiveStackToolTip from "~/components/FiveStackToolTip.vue"; @@ -105,6 +106,13 @@ import FiveStackToolTip from "~/components/FiveStackToolTip.vue"; {{ $t("chat.edited_at", { time: editedAtLabel }) }}

+ + + + - - - - + + + + + + + {{ $t("chat.add_reaction") }} + + + + {{ $t("chat.edit_message") }} + + + + + {{ $t("chat.delete_message") }} + + + + + + - - - {{ $t("chat.edit_message") }} - - - + + +

- - {{ $t("chat.delete_message") }} - - - + {{ $t("chat.react_gagged") }} +

+
+
diff --git a/components/chat/ChatMessageReactions.vue b/components/chat/ChatMessageReactions.vue new file mode 100644 index 00000000..1a41715b --- /dev/null +++ b/components/chat/ChatMessageReactions.vue @@ -0,0 +1,178 @@ + + + diff --git a/components/chat/ChatMessages.vue b/components/chat/ChatMessages.vue index ee0e8b8b..316acdc8 100644 --- a/components/chat/ChatMessages.vue +++ b/components/chat/ChatMessages.vue @@ -54,6 +54,7 @@ :next-message="messages[index + 1]" :room="messageRoom ? messageRoom(message) : null" :can-moderate="canModerate" + :can-post="canPost" :editing="!!message.id && message.id === editingId" @edit="editingId = message.id" @edit-end="stopEditing(message.id)" @@ -111,6 +112,10 @@ export default { type: Boolean, default: false, }, + canPost: { + type: Boolean, + default: false, + }, }, emits: ["bottom-state-change"], data() { diff --git a/constants/chat.ts b/constants/chat.ts index 3146c2d2..6296e9b8 100644 --- a/constants/chat.ts +++ b/constants/chat.ts @@ -3,3 +3,16 @@ export const CHAT_MESSAGE_MAX_LENGTH = 2000; export const CHAT_REMAINING_HINT_AT = 200; + +// Must match the api's ChatService.REACTIONS, which refuses anything else. The +// api only sends ids; the glyphs are ours. +export const CHAT_REACTIONS = [ + { id: "thumbsup", glyph: "👍" }, + { id: "heart", glyph: "❤️" }, + { id: "laugh", glyph: "😂" }, + { id: "fire", glyph: "🔥" }, + { id: "wow", glyph: "😮" }, + { id: "sad", glyph: "😢" }, +] as const; + +export type ChatReaction = (typeof CHAT_REACTIONS)[number]["id"]; diff --git a/i18n/locales/ar_SA.json b/i18n/locales/ar_SA.json index 8bd73300..1fe34bcb 100644 --- a/i18n/locales/ar_SA.json +++ b/i18n/locales/ar_SA.json @@ -8494,6 +8494,17 @@ "edit_failed": "تعذّر تعديل الرسالة", "edit_timeout": "لم يستجب الخادم. قد يُحفظ تعديلك لاحقًا.", "edit_window_closed": "يمكنك تعديل الرسالة خلال 10 دقائق فقط.", + "react": "تفاعل", + "add_reaction": "إضافة تفاعل", + "react_with": "تفاعل بـ {emoji}", + "react_with_count": "تفاعل بـ {emoji} ({count})", + "reacted_by": "تفاعل {names} بـ {emoji}", + "reacted_by_count": "تفاعل {count} لاعب بـ {emoji} | تفاعل {count} لاعبين بـ {emoji}", + "reacted_you": "أنت", + "reacted_others": "{count} آخر | {count} آخرون", + "react_failed": "تعذّر التفاعل", + "react_rate_limited": "أنت تتفاعل بسرعة كبيرة.", + "react_gagged": "أنت ممنوع من الكتابة ولا يمكنك إضافة تفاعلات.", "direct": { "message": "رسالة", "empty": "لا رسائل بعد. ألقِ التحية." diff --git a/i18n/locales/da_DK.json b/i18n/locales/da_DK.json index bf273d94..cbfaf577 100644 --- a/i18n/locales/da_DK.json +++ b/i18n/locales/da_DK.json @@ -8494,6 +8494,17 @@ "edit_failed": "Beskeden kunne ikke redigeres", "edit_timeout": "Serveren svarede ikke. Din ændring kan stadig blive gemt.", "edit_window_closed": "Du kan kun redigere en besked i 10 minutter.", + "react": "Reagér", + "add_reaction": "Tilføj reaktion", + "react_with": "Reagér med {emoji}", + "react_with_count": "Reagér med {emoji} ({count})", + "reacted_by": "{names} reagerede med {emoji}", + "reacted_by_count": "{count} spiller reagerede med {emoji} | {count} spillere reagerede med {emoji}", + "reacted_you": "Du", + "reacted_others": "{count} anden | {count} andre", + "react_failed": "Kunne ikke reagere", + "react_rate_limited": "Du reagerer for hurtigt.", + "react_gagged": "Din chat er lukket, så du kan ikke tilføje reaktioner.", "direct": { "message": "Besked", "empty": "Ingen beskeder endnu. Sig hej." diff --git a/i18n/locales/de_DE.json b/i18n/locales/de_DE.json index d718f1f5..2b867658 100644 --- a/i18n/locales/de_DE.json +++ b/i18n/locales/de_DE.json @@ -8494,6 +8494,17 @@ "edit_failed": "Nachricht konnte nicht bearbeitet werden", "edit_timeout": "Der Server hat nicht geantwortet. Deine Änderung wird eventuell trotzdem gespeichert.", "edit_window_closed": "Du kannst eine Nachricht nur 10 Minuten lang bearbeiten.", + "react": "Reagieren", + "add_reaction": "Reaktion hinzufügen", + "react_with": "Mit {emoji} reagieren", + "react_with_count": "Mit {emoji} reagieren ({count})", + "reacted_by": "Reaktion {emoji} von {names}", + "reacted_by_count": "Reaktion {emoji} von {count} Spieler | Reaktion {emoji} von {count} Spielern", + "reacted_you": "dir", + "reacted_others": "{count} weiteren Person | {count} weiteren Personen", + "react_failed": "Reaktion fehlgeschlagen", + "react_rate_limited": "Du reagierst zu schnell.", + "react_gagged": "Du bist im Chat gesperrt und kannst keine Reaktionen hinzufügen.", "direct": { "message": "Nachricht", "empty": "Noch keine Nachrichten. Sag Hallo." diff --git a/i18n/locales/en.json b/i18n/locales/en.json index c2a895d7..1dbc8694 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -8494,6 +8494,17 @@ "edit_failed": "Failed to edit message", "edit_timeout": "The server didn't answer. Your edit may still be saved.", "edit_window_closed": "You can only edit a message for 10 minutes.", + "react": "React", + "add_reaction": "Add Reaction", + "react_with": "React with {emoji}", + "react_with_count": "React with {emoji} ({count})", + "reacted_by": "{names} reacted with {emoji}", + "reacted_by_count": "{count} player reacted with {emoji} | {count} players reacted with {emoji}", + "reacted_you": "You", + "reacted_others": "{count} other | {count} others", + "react_failed": "Failed to react", + "react_rate_limited": "You're reacting too fast.", + "react_gagged": "You're gagged and can't add reactions.", "direct": { "message": "Message", "empty": "No messages yet. Say hello." diff --git a/i18n/locales/es_ES.json b/i18n/locales/es_ES.json index e9a701bd..481db0c5 100644 --- a/i18n/locales/es_ES.json +++ b/i18n/locales/es_ES.json @@ -8494,6 +8494,17 @@ "edit_failed": "No se ha podido editar el mensaje", "edit_timeout": "El servidor no ha respondido. Puede que tu edición se guarde de todos modos.", "edit_window_closed": "Solo puedes editar un mensaje durante 10 minutos.", + "react": "Reaccionar", + "add_reaction": "Añadir reacción", + "react_with": "Reaccionar con {emoji}", + "react_with_count": "Reaccionar con {emoji} ({count})", + "reacted_by": "Reacción {emoji} de {names}", + "reacted_by_count": "Reacción {emoji} de {count} jugador | Reacción {emoji} de {count} jugadores", + "reacted_you": "ti", + "reacted_others": "{count} persona más | {count} personas más", + "react_failed": "No se ha podido reaccionar", + "react_rate_limited": "Estás reaccionando demasiado rápido.", + "react_gagged": "Estás silenciado en el chat y no puedes añadir reacciones.", "direct": { "message": "Mensaje", "empty": "Aún no hay mensajes. Saluda." diff --git a/i18n/locales/fr_FR.json b/i18n/locales/fr_FR.json index 302b27d5..881e6a70 100644 --- a/i18n/locales/fr_FR.json +++ b/i18n/locales/fr_FR.json @@ -8494,6 +8494,17 @@ "edit_failed": "Impossible de modifier le message", "edit_timeout": "Le serveur n'a pas répondu. Votre modification peut tout de même être enregistrée.", "edit_window_closed": "Vous ne pouvez modifier un message que pendant 10 minutes.", + "react": "Réagir", + "add_reaction": "Ajouter une réaction", + "react_with": "Réagir avec {emoji}", + "react_with_count": "Réagir avec {emoji} ({count})", + "reacted_by": "Réaction {emoji} de {names}", + "reacted_by_count": "Réaction {emoji} de {count} joueur | Réaction {emoji} de {count} joueurs", + "reacted_you": "vous", + "reacted_others": "{count} autre | {count} autres", + "react_failed": "Impossible de réagir", + "react_rate_limited": "Vous réagissez trop vite.", + "react_gagged": "Vous êtes bâillonné et ne pouvez pas ajouter de réactions.", "direct": { "message": "Message", "empty": "Aucun message pour l'instant. Dites bonjour." diff --git a/i18n/locales/it_IT.json b/i18n/locales/it_IT.json index 3ed5c62f..186e8a57 100644 --- a/i18n/locales/it_IT.json +++ b/i18n/locales/it_IT.json @@ -8494,6 +8494,17 @@ "edit_failed": "Impossibile modificare il messaggio", "edit_timeout": "Il server non ha risposto. La modifica potrebbe comunque essere salvata.", "edit_window_closed": "Puoi modificare un messaggio solo per 10 minuti.", + "react": "Reagisci", + "add_reaction": "Aggiungi reazione", + "react_with": "Reagisci con {emoji}", + "react_with_count": "Reagisci con {emoji} ({count})", + "reacted_by": "Reazione {emoji} di {names}", + "reacted_by_count": "Reazione {emoji} di {count} giocatore | Reazione {emoji} di {count} giocatori", + "reacted_you": "te", + "reacted_others": "{count} altro | {count} altri", + "react_failed": "Impossibile reagire", + "react_rate_limited": "Stai reagendo troppo velocemente.", + "react_gagged": "Sei silenziato in chat e non puoi aggiungere reazioni.", "direct": { "message": "Messaggio", "empty": "Ancora nessun messaggio. Fatti sentire." diff --git a/i18n/locales/ja_JP.json b/i18n/locales/ja_JP.json index f635d05a..766eaf5b 100644 --- a/i18n/locales/ja_JP.json +++ b/i18n/locales/ja_JP.json @@ -8494,6 +8494,17 @@ "edit_failed": "メッセージを編集できませんでした", "edit_timeout": "サーバーから応答がありませんでした。編集は後で保存される可能性があります。", "edit_window_closed": "メッセージを編集できるのは送信後10分間だけです。", + "react": "リアクション", + "add_reaction": "リアクションを追加", + "react_with": "{emoji} でリアクション", + "react_with_count": "{emoji} でリアクション ({count})", + "reacted_by": "{names} が {emoji} でリアクションしました", + "reacted_by_count": "{count} 人が {emoji} でリアクションしました | {count} 人が {emoji} でリアクションしました", + "reacted_you": "あなた", + "reacted_others": "他 {count} 人 | 他 {count} 人", + "react_failed": "リアクションできませんでした", + "react_rate_limited": "リアクションの操作が速すぎます。", + "react_gagged": "発言禁止中のため、リアクションを追加できません。", "direct": { "message": "メッセージ", "empty": "まだメッセージはありません。あいさつしてみましょう。" diff --git a/i18n/locales/ko_KR.json b/i18n/locales/ko_KR.json index 0aea4c9d..17b273f7 100644 --- a/i18n/locales/ko_KR.json +++ b/i18n/locales/ko_KR.json @@ -8494,6 +8494,17 @@ "edit_failed": "메시지를 편집하지 못했습니다", "edit_timeout": "서버가 응답하지 않았습니다. 편집 내용이 나중에 저장될 수도 있습니다.", "edit_window_closed": "메시지는 보낸 후 10분 동안만 편집할 수 있습니다.", + "react": "반응", + "add_reaction": "반응 추가", + "react_with": "{emoji}(으)로 반응", + "react_with_count": "{emoji}(으)로 반응 ({count})", + "reacted_by": "{emoji} 반응: {names}", + "reacted_by_count": "{emoji} 반응: {count}명 | {emoji} 반응: {count}명", + "reacted_you": "나", + "reacted_others": "다른 {count}명 | 다른 {count}명", + "react_failed": "반응하지 못했습니다", + "react_rate_limited": "반응을 너무 빠르게 하고 있습니다.", + "react_gagged": "채팅이 금지되어 반응을 추가할 수 없습니다.", "direct": { "message": "메시지", "empty": "아직 메시지가 없습니다. 인사를 건네보세요." diff --git a/i18n/locales/pl_PL.json b/i18n/locales/pl_PL.json index ce9c1efa..d8a94eaa 100644 --- a/i18n/locales/pl_PL.json +++ b/i18n/locales/pl_PL.json @@ -8494,6 +8494,17 @@ "edit_failed": "Nie udało się edytować wiadomości", "edit_timeout": "Serwer nie odpowiedział. Twoja zmiana może mimo to zostać zapisana.", "edit_window_closed": "Wiadomość możesz edytować tylko przez 10 minut.", + "react": "Zareaguj", + "add_reaction": "Dodaj reakcję", + "react_with": "Zareaguj: {emoji}", + "react_with_count": "Zareaguj: {emoji} ({count})", + "reacted_by": "Reakcja {emoji}: {names}", + "reacted_by_count": "Reakcja {emoji} – liczba graczy: {count} | Reakcja {emoji} – liczba graczy: {count}", + "reacted_you": "Ty", + "reacted_others": "jeszcze {count} | jeszcze {count}", + "react_failed": "Nie udało się zareagować", + "react_rate_limited": "Reagujesz zbyt szybko.", + "react_gagged": "Masz zablokowany czat i nie możesz dodawać reakcji.", "direct": { "message": "Wiadomość", "empty": "Brak wiadomości. Przywitaj się." diff --git a/i18n/locales/pt_BR.json b/i18n/locales/pt_BR.json index 45bcb6a8..7a1c3d08 100644 --- a/i18n/locales/pt_BR.json +++ b/i18n/locales/pt_BR.json @@ -8494,6 +8494,17 @@ "edit_failed": "Não foi possível editar a mensagem", "edit_timeout": "O servidor não respondeu. Sua edição ainda pode ser salva.", "edit_window_closed": "Você só pode editar uma mensagem por 10 minutos.", + "react": "Reagir", + "add_reaction": "Adicionar Reação", + "react_with": "Reagir com {emoji}", + "react_with_count": "Reagir com {emoji} ({count})", + "reacted_by": "Reação {emoji} de {names}", + "reacted_by_count": "Reação {emoji} de {count} jogador | Reação {emoji} de {count} jogadores", + "reacted_you": "você", + "reacted_others": "mais {count} pessoa | mais {count} pessoas", + "react_failed": "Não foi possível reagir", + "react_rate_limited": "Você está reagindo rápido demais.", + "react_gagged": "Você está censurado no chat e não pode adicionar reações.", "direct": { "message": "Mensagem", "empty": "Nenhuma mensagem ainda. Diga Olá." diff --git a/i18n/locales/ru_RU.json b/i18n/locales/ru_RU.json index 59b5f39a..999d903c 100644 --- a/i18n/locales/ru_RU.json +++ b/i18n/locales/ru_RU.json @@ -8494,6 +8494,17 @@ "edit_failed": "Не удалось изменить сообщение", "edit_timeout": "Сервер не ответил. Изменение всё ещё может быть сохранено.", "edit_window_closed": "Изменить сообщение можно только в течение 10 минут.", + "react": "Реакция", + "add_reaction": "Добавить реакцию", + "react_with": "Реакция {emoji}", + "react_with_count": "Реакция {emoji} ({count})", + "reacted_by": "Реакция {emoji}: {names}", + "reacted_by_count": "Реакция {emoji}: игроков — {count} | Реакция {emoji}: игроков — {count}", + "reacted_you": "вы", + "reacted_others": "ещё {count} | ещё {count}", + "react_failed": "Не удалось поставить реакцию", + "react_rate_limited": "Вы ставите реакции слишком быстро.", + "react_gagged": "Вам отключили чат, и вы не можете добавлять реакции.", "direct": { "message": "Сообщение", "empty": "Сообщений пока нет. Поздоровайтесь." diff --git a/i18n/locales/sv_SE.json b/i18n/locales/sv_SE.json index c9e41be5..e06b5e1d 100644 --- a/i18n/locales/sv_SE.json +++ b/i18n/locales/sv_SE.json @@ -8494,6 +8494,17 @@ "edit_failed": "Meddelandet kunde inte redigeras", "edit_timeout": "Servern svarade inte. Din ändring kan fortfarande sparas.", "edit_window_closed": "Du kan bara redigera ett meddelande i 10 minuter.", + "react": "Reagera", + "add_reaction": "Lägg till reaktion", + "react_with": "Reagera med {emoji}", + "react_with_count": "Reagera med {emoji} ({count})", + "reacted_by": "{names} reagerade med {emoji}", + "reacted_by_count": "{count} spelare reagerade med {emoji} | {count} spelare reagerade med {emoji}", + "reacted_you": "Du", + "reacted_others": "{count} annan | {count} andra", + "react_failed": "Kunde inte reagera", + "react_rate_limited": "Du reagerar för snabbt.", + "react_gagged": "Din chatt är avstängd och du kan inte lägga till reaktioner.", "direct": { "message": "Meddelande", "empty": "Inga meddelanden än. Säg hej." diff --git a/i18n/locales/tr_TR.json b/i18n/locales/tr_TR.json index 22170f0e..5b8aaa25 100644 --- a/i18n/locales/tr_TR.json +++ b/i18n/locales/tr_TR.json @@ -8494,6 +8494,17 @@ "edit_failed": "Mesaj düzenlenemedi", "edit_timeout": "Sunucu yanıt vermedi. Düzenlemen yine de kaydedilebilir.", "edit_window_closed": "Bir mesajı yalnızca 10 dakika boyunca düzenleyebilirsin.", + "react": "Tepki ver", + "add_reaction": "Tepki ekle", + "react_with": "{emoji} ile tepki ver", + "react_with_count": "{emoji} ile tepki ver ({count})", + "reacted_by": "{emoji} tepkisi: {names}", + "reacted_by_count": "{emoji} tepkisi: {count} oyuncu | {emoji} tepkisi: {count} oyuncu", + "reacted_you": "Sen", + "reacted_others": "{count} kişi daha | {count} kişi daha", + "react_failed": "Tepki verilemedi", + "react_rate_limited": "Çok hızlı tepki veriyorsun.", + "react_gagged": "Sohbetin kapatıldı, tepki ekleyemezsin.", "direct": { "message": "Mesaj", "empty": "Henüz mesaj yok. Merhaba de." diff --git a/i18n/locales/uk_UA.json b/i18n/locales/uk_UA.json index 6db6d157..1b094c63 100644 --- a/i18n/locales/uk_UA.json +++ b/i18n/locales/uk_UA.json @@ -8494,6 +8494,17 @@ "edit_failed": "Не вдалося змінити повідомлення", "edit_timeout": "Сервер не відповів. Зміну ще може бути збережено.", "edit_window_closed": "Редагувати повідомлення можна лише протягом 10 хвилин.", + "react": "Реакція", + "add_reaction": "Додати реакцію", + "react_with": "Реакція {emoji}", + "react_with_count": "Реакція {emoji} ({count})", + "reacted_by": "Реакція {emoji}: {names}", + "reacted_by_count": "Реакція {emoji}: гравців — {count} | Реакція {emoji}: гравців — {count}", + "reacted_you": "ви", + "reacted_others": "ще {count} | ще {count}", + "react_failed": "Не вдалося поставити реакцію", + "react_rate_limited": "Ви ставите реакції надто швидко.", + "react_gagged": "Вам вимкнули чат, тож ви не можете додавати реакції.", "direct": { "message": "Повідомлення", "empty": "Повідомлень поки немає. Привітайтеся." diff --git a/i18n/locales/zh_Hans.json b/i18n/locales/zh_Hans.json index fde32490..2315f900 100644 --- a/i18n/locales/zh_Hans.json +++ b/i18n/locales/zh_Hans.json @@ -8494,6 +8494,17 @@ "edit_failed": "消息编辑失败", "edit_timeout": "服务器没有响应。你的修改仍可能被保存。", "edit_window_closed": "你只能在发送后 10 分钟内编辑消息。", + "react": "表情回应", + "add_reaction": "添加表情回应", + "react_with": "用 {emoji} 回应", + "react_with_count": "用 {emoji} 回应({count})", + "reacted_by": "{names} 用 {emoji} 回应了", + "reacted_by_count": "{count} 名玩家用 {emoji} 回应了 | {count} 名玩家用 {emoji} 回应了", + "reacted_you": "你", + "reacted_others": "其他 {count} 人 | 其他 {count} 人", + "react_failed": "回应失败", + "react_rate_limited": "你的回应操作过快。", + "react_gagged": "你已被禁言,无法添加表情回应。", "direct": { "message": "消息", "empty": "还没有消息,先打个招呼吧。" diff --git a/i18n/locales/zh_Hant.json b/i18n/locales/zh_Hant.json index dd9bb1aa..53f0c645 100644 --- a/i18n/locales/zh_Hant.json +++ b/i18n/locales/zh_Hant.json @@ -8494,6 +8494,17 @@ "edit_failed": "訊息編輯失敗", "edit_timeout": "伺服器沒有回應。你的修改仍可能被儲存。", "edit_window_closed": "你只能在傳送後 10 分鐘內編輯訊息。", + "react": "表情回應", + "add_reaction": "新增表情回應", + "react_with": "用 {emoji} 回應", + "react_with_count": "用 {emoji} 回應({count})", + "reacted_by": "{names} 用 {emoji} 回應了", + "reacted_by_count": "{count} 名玩家用 {emoji} 回應了 | {count} 名玩家用 {emoji} 回應了", + "reacted_you": "你", + "reacted_others": "其他 {count} 人 | 其他 {count} 人", + "react_failed": "回應失敗", + "react_rate_limited": "你的回應操作過快。", + "react_gagged": "你已被禁言,無法新增表情回應。", "direct": { "message": "訊息", "empty": "還沒有訊息,先打個招呼吧。" diff --git a/tests/components/ChatLobby.spec.ts b/tests/components/ChatLobby.spec.ts index 4aa819e5..6caea846 100644 --- a/tests/components/ChatLobby.spec.ts +++ b/tests/components/ChatLobby.spec.ts @@ -123,7 +123,16 @@ describe("ChatLobby moderation", () => { expect(wrapper.find(TRIGGER).exists()).toBe(true); }); - it("offers a moderator nothing in a direct conversation", async () => { + async function menuItems(wrapper: Awaited>) { + await wrapper.get(TRIGGER).trigger("keydown", { key: "Enter" }); + await flushPromises(); + + return Array.from( + document.body.querySelectorAll('[role="menuitem"]'), + ).map((item) => item.textContent?.trim()); + } + + it("offers a moderator no delete in a direct conversation", async () => { signIn({ role: "administrator" }); const wrapper = await mountWithHistory("direct", `${ME}:${OTHER}`, [ @@ -131,16 +140,30 @@ describe("ChatLobby moderation", () => { ]); expect(wrapper.text()).toContain("line a"); - expect(wrapper.find(TRIGGER).exists()).toBe(false); + expect(await menuItems(wrapper)).toEqual(["Add Reaction"]); }); - it("offers a streamer nothing", async () => { + it("offers a streamer a reaction and nothing else", async () => { signIn({ role: "streamer" }); const wrapper = await mountWithHistory("match", "streamed-match", [ line("a", 0), ]); + expect(await menuItems(wrapper)).toEqual(["Add Reaction"]); + }); + + it("offers nothing where the room is read-only", async () => { + signIn({ role: "user" }); + + const wrapper = await mountWithHistory( + "match", + "read-only-match", + [line("a", 0)], + { canSend: false }, + ); + + expect(wrapper.text()).toContain("line a"); expect(wrapper.find(TRIGGER).exists()).toBe(false); }); diff --git a/tests/components/ChatMessage.spec.ts b/tests/components/ChatMessage.spec.ts index c2330e1a..8f03b5f5 100644 --- a/tests/components/ChatMessage.spec.ts +++ b/tests/components/ChatMessage.spec.ts @@ -415,3 +415,142 @@ describe("ChatMessage editing", () => { expect(wrapper.find("[data-chat-edited]").exists()).toBe(false); }); }); + +describe("ChatMessage reactions", () => { + const ME = "76561198000000001"; + const ROOM = { type: "match_team", id: "match-1:lineup-1" }; + const TRIGGER = 'button[aria-label="Message actions"]'; + + const theirs = (overrides: Record = {}) => ({ + id: "7f1d0c2e-8b1a-4c6e-9f00-000000000002", + message: "nice", + source: "game", + timestamp: new Date(Date.UTC(2026, 8, 28, 12, 0)).toISOString(), + from: { steam_id: "76561198000000002", name: "Dana" }, + reactions: { thumbsup: ["76561198000000002"], heart: [ME] }, + ...overrides, + }); + + let unmount: (() => void) | undefined; + + beforeEach(() => { + useAuthStore().me = { steam_id: ME, role: "user" } as any; + while (useRightSidebar().hoverCloseSuspended.value) { + useRightSidebar().resumeHoverClose(); + } + }); + + afterEach(() => { + unmount?.(); + unmount = undefined; + useAuthStore().me = undefined; + vi.restoreAllMocks(); + toast.mockClear(); + }); + + async function mountMessage(props: Record = {}) { + const wrapper = await mountSuspended(ChatMessage, { + props: { message: theirs(), room: ROOM, canPost: true, ...props }, + attachTo: document.body, + }); + unmount = () => wrapper.unmount(); + await flushPromises(); + return wrapper; + } + + const pill = ( + wrapper: Awaited>, + reaction: string, + ) => wrapper.get(`button[data-reaction="${reaction}"]`); + + it("toggles a pill's reaction in the room the line lives in", async () => { + const react = vi.spyOn(socket, "react").mockResolvedValue(undefined); + const wrapper = await mountMessage(); + + await pill(wrapper, "thumbsup").trigger("click"); + await pill(wrapper, "heart").trigger("click"); + + expect(react.mock.calls).toEqual([ + ["match_team", "match-1:lineup-1", theirs().id, "thumbsup"], + ["match_team", "match-1:lineup-1", theirs().id, "heart"], + ]); + }); + + it("says why a reaction was refused", async () => { + vi.spyOn(socket, "react").mockRejectedValue({ + code: "rate_limited", + action: "react", + }); + const wrapper = await mountMessage(); + + await pill(wrapper, "thumbsup").trigger("click"); + await flushPromises(); + + expect(toast).toHaveBeenCalledWith({ + title: "Failed to react", + description: "You're reacting too fast.", + variant: "destructive", + }); + }); + + it("reacts from the menu's picker", async () => { + const react = vi.spyOn(socket, "react").mockResolvedValue(undefined); + const wrapper = await mountMessage(); + + await wrapper.get(TRIGGER).trigger("keydown", { key: "Enter" }); + await flushPromises(); + Array.from( + document.body.querySelectorAll('[role="menuitem"]'), + ) + .find((item) => item.textContent?.trim() === "Add Reaction")! + .click(); + await flushPromises(); + await new Promise((resolve) => setTimeout(resolve, 20)); + await flushPromises(); + + document.body + .querySelector('[role="dialog"] [data-reaction="laugh"]')! + .click(); + await flushPromises(); + + expect(react).toHaveBeenCalledWith( + "match_team", + "match-1:lineup-1", + theirs().id, + "laugh", + ); + }); + + it("keeps the trigger for reactions once the author's window has closed", async () => { + const wrapper = await mountMessage({ + message: theirs({ + source: "web", + timestamp: new Date(Date.now() - 60_000).toISOString(), + from: { steam_id: ME, name: "Me" }, + }), + }); + const now = Date.now; + vi.spyOn(Date, "now").mockImplementation(() => now() + 10 * 60_000); + + await wrapper.get(TRIGGER).trigger("keydown", { key: "Enter" }); + await flushPromises(); + + expect( + Array.from( + document.body.querySelectorAll('[role="menuitem"]'), + ).map((item) => item.textContent?.trim()), + ).toEqual(["Add Reaction"]); + expect(toast).not.toHaveBeenCalled(); + }); + + it("shows the pills but offers no trigger where the viewer can't post", async () => { + const react = vi.spyOn(socket, "react"); + const wrapper = await mountMessage({ canPost: false }); + + await pill(wrapper, "thumbsup").trigger("click"); + + expect(wrapper.find(TRIGGER).exists()).toBe(false); + expect(wrapper.findAll("button[data-reaction]")).toHaveLength(2); + expect(react).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/components/ChatMessageActions.spec.ts b/tests/components/ChatMessageActions.spec.ts index 6c426fc1..9c0d6a34 100644 --- a/tests/components/ChatMessageActions.spec.ts +++ b/tests/components/ChatMessageActions.spec.ts @@ -330,3 +330,183 @@ describe("ChatMessageActions for the author", () => { expect(wrapper.emitted("expired")).toHaveLength(1); }); }); + +describe("ChatMessageActions reactions", () => { + const ME = "76561198000000001"; + const everything = { + canDelete: true, + canEdit: true, + canReact: true, + canAddReaction: true, + }; + + const hubHeld = () => useRightSidebar().hoverCloseSuspended.value; + + const menuItems = () => + Array.from( + document.body.querySelectorAll('[role="menuitem"]'), + ).map((item) => item.textContent?.trim()); + + const menuItem = (label: string) => + Array.from( + document.body.querySelectorAll('[role="menuitem"]'), + ).find((item) => item.textContent?.trim() === label); + + const picker = () => + document.body.querySelector('[role="dialog"]'); + + const choices = () => + Array.from( + picker()?.querySelectorAll("button[data-reaction]") ?? + [], + ); + + const choice = (reaction: string) => + choices().find((button) => button.dataset.reaction === reaction)!; + + beforeEach(() => { + while (useRightSidebar().hoverCloseSuspended.value) { + useRightSidebar().resumeHoverClose(); + } + }); + + async function openPicker( + permissions = everything, + reactions: Record = {}, + ) { + const wrapper = await mountSuspended(ChatMessageActions, { + props: { + message: { ...MESSAGE, reactions }, + room: { type: "match", id: "match-1" }, + permissions, + viewerSteamId: ME, + }, + attachTo: document.body, + }); + unmount = () => wrapper.unmount(); + + const trigger = wrapper.get(TRIGGER); + (trigger.element as HTMLElement).focus(); + await trigger.trigger("keydown", { key: "Enter" }); + await flushPromises(); + + menuItem("Add Reaction")!.click(); + await flushPromises(); + await new Promise((resolve) => setTimeout(resolve, 20)); + await flushPromises(); + + return wrapper; + } + + it("puts Add Reaction above Edit, and Delete after a separator", async () => { + const wrapper = await mountSuspended(ChatMessageActions, { + props: { + message: MESSAGE, + room: { type: "match", id: "match-1" }, + permissions: everything, + viewerSteamId: ME, + }, + attachTo: document.body, + }); + unmount = () => wrapper.unmount(); + + await wrapper.get(TRIGGER).trigger("keydown", { key: "Enter" }); + await flushPromises(); + + expect(menuItems()).toEqual([ + "Add Reaction", + "Edit Message", + "Delete Message", + ]); + expect( + document.body.querySelectorAll('[role="menu"] [role="separator"]'), + ).toHaveLength(1); + }); + + it("opens the picker from the menu, with focus in it, holding the hub", async () => { + await openPicker(); + + expect(document.body.querySelector('[role="menu"]')).toBeNull(); + expect(picker()?.hasAttribute("data-right-hub-interactive")).toBe(true); + expect(choices().map((button) => button.textContent?.trim())).toEqual([ + "👍", + "❤️", + "😂", + "🔥", + "😮", + "😢", + ]); + expect(choice("thumbsup").getAttribute("aria-label")).toBe( + "React with 👍", + ); + expect(picker()?.contains(document.activeElement)).toBe(true); + expect(hubHeld()).toBe(true); + }); + + it("reacts with the pick, closes, and hands focus back", async () => { + const wrapper = await openPicker(); + + choice("fire").click(); + await flushPromises(); + await new Promise((resolve) => setTimeout(resolve, 20)); + await flushPromises(); + + expect(wrapper.emitted("react")).toEqual([["fire"]]); + expect(picker()).toBeNull(); + expect(hubHeld()).toBe(false); + expect(document.activeElement).toBe(wrapper.get(TRIGGER).element); + }); + + it("marks what the viewer already holds, to take it back", async () => { + await openPicker(everything, { heart: [ME], fire: ["76561198000000002"] }); + + expect(choice("heart").getAttribute("aria-pressed")).toBe("true"); + expect(choice("fire").getAttribute("aria-pressed")).toBe("false"); + }); + + it("lets a gagged viewer take a reaction back but not add one", async () => { + const wrapper = await openPicker( + { ...everything, canAddReaction: false }, + { heart: [ME] }, + ); + + expect( + choices() + .filter((button) => !button.disabled) + .map((button) => button.dataset.reaction), + ).toEqual(["heart"]); + expect(picker()?.textContent).toContain( + "You're gagged and can't add reactions.", + ); + + choice("fire").click(); + choice("heart").click(); + await flushPromises(); + + expect(wrapper.emitted("react")).toEqual([["heart"]]); + }); + + it("closes on Escape and lets go of the hub", async () => { + const wrapper = await openPicker(); + + document.activeElement?.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + await flushPromises(); + await new Promise((resolve) => setTimeout(resolve, 20)); + await flushPromises(); + + expect(picker()).toBeNull(); + expect(wrapper.emitted("react")).toBeUndefined(); + expect(hubHeld()).toBe(false); + }); + + it("lets go of the hub when the message goes away mid-pick", async () => { + await openPicker(); + + unmount?.(); + unmount = undefined; + + expect(hubHeld()).toBe(false); + }); +}); diff --git a/tests/components/ChatMessageReactions.spec.ts b/tests/components/ChatMessageReactions.spec.ts new file mode 100644 index 00000000..73711aad --- /dev/null +++ b/tests/components/ChatMessageReactions.spec.ts @@ -0,0 +1,178 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { flushPromises } from "@vue/test-utils"; +import { mountSuspended } from "@nuxt/test-utils/runtime"; +import ChatMessageReactions from "~/components/chat/ChatMessageReactions.vue"; +import { useMatchLobbyStore } from "~/stores/MatchLobbyStore"; + +const ME = "76561198000000001"; +const DANA = "76561198000000002"; +const ELI = "76561198000000003"; +const ROOM = { type: "match", id: "reactions-match" }; + +const message = (reactions?: Record) => ({ + id: "7f1d0c2e-8b1a-4c6e-9f00-000000000001", + message: "gg", + source: "web", + timestamp: new Date(Date.UTC(2026, 8, 28, 12, 0)).toISOString(), + from: { steam_id: DANA, name: "Dana" }, + reactions, +}); + +const everything = { canReact: true, canAddReaction: true }; + +let unmount: (() => void) | undefined; + +afterEach(() => { + unmount?.(); + unmount = undefined; + delete useMatchLobbyStore().lobbyChat[`${ROOM.type}:${ROOM.id}`]; +}); + +async function mountPills( + reactions: Record | undefined, + props: Record = {}, +) { + const wrapper = await mountSuspended(ChatMessageReactions, { + props: { + message: message(reactions), + room: ROOM, + permissions: everything, + viewerSteamId: ME, + ...props, + }, + attachTo: document.body, + }); + unmount = () => wrapper.unmount(); + return wrapper; +} + +const pills = (wrapper: Awaited>) => + wrapper.findAll("button[data-reaction]"); + +describe("ChatMessageReactions", () => { + it("renders nothing for a message nobody has reacted to", async () => { + for (const reactions of [undefined, {}]) { + const wrapper = await mountPills(reactions); + + expect(pills(wrapper)).toHaveLength(0); + expect(wrapper.find("div").exists()).toBe(false); + + unmount?.(); + unmount = undefined; + } + }); + + it("shows each reaction's glyph and count in the list's order", async () => { + const wrapper = await mountPills({ + sad: [ELI], + thumbsup: [DANA, ELI], + party: [DANA], + laugh: [], + }); + + expect( + pills(wrapper).map((pill) => pill.attributes("data-reaction")), + ).toEqual(["thumbsup", "sad"]); + expect(pills(wrapper).map((pill) => pill.text())).toEqual(["👍2", "😢1"]); + expect(pills(wrapper)[0].attributes("aria-label")).toBe( + "React with 👍 (2)", + ); + }); + + it("marks the reactions the viewer holds", async () => { + const wrapper = await mountPills({ + thumbsup: [DANA], + heart: [DANA, ME], + }); + + const [thumbsup, heart] = pills(wrapper); + + expect(thumbsup.attributes("aria-pressed")).toBe("false"); + expect(heart.attributes("aria-pressed")).toBe("true"); + expect(heart.classes()).toContain("!text-[hsl(var(--tac-amber))]"); + expect(thumbsup.classes()).not.toContain("!text-[hsl(var(--tac-amber))]"); + }); + + it("toggles the reaction it shows", async () => { + const wrapper = await mountPills({ + thumbsup: [DANA], + heart: [ME], + }); + + await pills(wrapper)[0].trigger("click"); + await pills(wrapper)[1].trigger("click"); + + expect(wrapper.emitted("toggle")).toEqual([["thumbsup"], ["heart"]]); + }); + + it("only lets a gagged viewer take back what they hold", async () => { + const wrapper = await mountPills( + { thumbsup: [DANA], heart: [ME] }, + { permissions: { canReact: true, canAddReaction: false } }, + ); + + const [thumbsup, heart] = pills(wrapper); + + expect(thumbsup.attributes("aria-disabled")).toBe("true"); + expect(heart.attributes("aria-disabled")).toBeUndefined(); + + await thumbsup.trigger("click"); + await heart.trigger("click"); + + expect(wrapper.emitted("toggle")).toEqual([["heart"]]); + }); + + it("shows reactions to a viewer who can't react, without toggling", async () => { + const wrapper = await mountPills( + { thumbsup: [DANA] }, + { permissions: { canReact: false, canAddReaction: false } }, + ); + + await pills(wrapper)[0].trigger("click"); + + expect(pills(wrapper)).toHaveLength(1); + expect(pills(wrapper)[0].attributes("aria-disabled")).toBe("true"); + expect(wrapper.emitted("toggle")).toBeUndefined(); + }); + + describe("who reacted", () => { + const tooltip = async (wrapper: Awaited>) => { + const pill = pills(wrapper)[0]; + await pill.trigger("pointermove", { pointerType: "mouse" }); + await new Promise((resolve) => setTimeout(resolve, 200)); + await flushPromises(); + + return document.body + .querySelector('[role="tooltip"]') + ?.textContent?.trim(); + }; + + it("names the viewer first, then who else it knows", async () => { + useMatchLobbyStore().set(`${ROOM.type}:${ROOM.id}`, [ + { steam_id: ELI, name: "Eli", avatar_url: "" }, + ]); + + const wrapper = await mountPills({ heart: [ELI, ME] }); + + expect(await tooltip(wrapper)).toBe("You and Eli reacted with ❤️"); + }); + + it("counts who it can't name", async () => { + useMatchLobbyStore().set(`${ROOM.type}:${ROOM.id}`, [ + { steam_id: ELI, name: "Eli", avatar_url: "" }, + ]); + + const wrapper = await mountPills({ + fire: [ELI, "76561198000000008", "76561198000000009"], + }); + + expect(await tooltip(wrapper)).toBe("Eli and 2 others reacted with 🔥"); + }); + + it("falls back to a count when it can name nobody", async () => { + const wrapper = await mountPills({ sad: [DANA] }); + + expect(await tooltip(wrapper)).toBe("1 player reacted with 😢"); + }); + }); +}); diff --git a/tests/utilities/chatErrors.spec.ts b/tests/utilities/chatErrors.spec.ts index 704b2981..b2babb40 100644 --- a/tests/utilities/chatErrors.spec.ts +++ b/tests/utilities/chatErrors.spec.ts @@ -129,3 +129,33 @@ describe("chat edit errors", () => { }, ); }); + +describe("chat react errors", () => { + it("titles every react error as a failed reaction", () => { + for (const code of ["rate_limited", "gagged", "not_found", "invalid"]) { + const error = { code, action: "react" as const }; + + expect(chatErrorTitle(error, t)).toBe("chat.react_failed"); + expect(chatErrorFailed(error)).toBe(true); + } + }); + + it.each([ + ["rate_limited", "chat.react_rate_limited"], + ["gagged", "chat.react_gagged"], + ["not_found", "chat.message_already_gone"], + ])("describes %s", (code, description) => { + expect(chatErrorDescription({ code, action: "react" }, t)).toBe( + description, + ); + }); + + it.each(["not_allowed", "invalid", "offline", "timeout"])( + "adds nothing to a failed reaction for %s", + (code) => { + expect( + chatErrorDescription({ code, action: "react" }, t), + ).toBeUndefined(); + }, + ); +}); diff --git a/tests/utilities/chatLobbyMessages.spec.ts b/tests/utilities/chatLobbyMessages.spec.ts index 5845e866..d621d513 100644 --- a/tests/utilities/chatLobbyMessages.spec.ts +++ b/tests/utilities/chatLobbyMessages.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { applyChatMessageEdit, + applyChatMessageReactions, chatMessageKey, insertChatMessage, isChatMessageDeleted, @@ -187,6 +188,57 @@ describe("mergeChatSnapshot", () => { expect(mergeChatSnapshot([local], [server], new Set())).toEqual([server]); }); + it("keeps reactions that changed live after the snapshot was asked for", () => { + const live = { + ...line("a", 1), + reactions: { fire: ["76561198000000003"] }, + }; + + const merged = mergeChatSnapshot( + [live, line("b", 2)], + [ + { ...line("a", 1), reactions: {} }, + { ...line("b", 2), reactions: { sad: ["76561198000000003"] } }, + ], + new Set(), + new Set(["a"]), + ); + + expect(merged.map((message) => message.reactions)).toEqual([ + { fire: ["76561198000000003"] }, + { sad: ["76561198000000003"] }, + ]); + }); + + it("takes the snapshot's reactions for a message nothing changed live", () => { + const merged = mergeChatSnapshot( + [{ ...line("a", 1), reactions: { fire: ["76561198000000003"] } }], + [{ ...line("a", 1), reactions: {} }], + new Set(), + new Set(), + ); + + expect(merged[0].reactions).toEqual({}); + }); + + it("keeps a live edit and live reactions on the same message", () => { + const held = { + ...line("a", 1), + message: "fixed", + edited_at: "2026-09-28T12:05:00.000Z", + reactions: { heart: ["76561198000000003"] }, + }; + + const merged = mergeChatSnapshot( + [held], + [{ ...line("a", 1), reactions: {} }], + new Set(), + new Set(["a"]), + ); + + expect(merged).toEqual([held]); + }); + it("treats a missing snapshot as an empty room", () => { expect(mergeChatSnapshot([line("a", 1)], null, new Set())).toEqual([ line("a", 1), @@ -306,6 +358,63 @@ describe("applyChatMessageEdit", () => { }); }); +describe("applyChatMessageReactions", () => { + const update = { + id: "b", + reactions: { thumbsup: ["76561198000000003"], sad: ["76561198000000004"] }, + }; + + it("replaces the message's reactions with the whole new state", () => { + const messages = [ + line("a", 0), + { ...line("b", 1), reactions: { heart: ["76561198000000003"] } }, + ]; + + const reacted = applyChatMessageReactions(messages, update, new Set()); + + expect(reacted).not.toBeNull(); + expect(reacted![1]).toEqual({ + ...line("b", 1), + reactions: update.reactions, + }); + expect(reacted![0]).toBe(messages[0]); + expect(messages[1].reactions).toEqual({ heart: ["76561198000000003"] }); + }); + + it("clears the reactions when nobody holds one any more", () => { + const reacted = applyChatMessageReactions( + [{ ...line("b", 1), reactions: { heart: ["76561198000000003"] } }], + { id: "b", reactions: {} }, + new Set(), + ); + + expect(reacted![0].reactions).toEqual({}); + }); + + it("ignores reactions for a line it doesn't hold rather than creating it", () => { + expect( + applyChatMessageReactions([line("a", 0)], update, new Set()), + ).toBeNull(); + }); + + it("ignores reactions that land after the line was deleted", () => { + expect( + applyChatMessageReactions([line("b", 1)], update, new Set(["b"])), + ).toBeNull(); + }); + + it.each([ + ["no id", { reactions: {} }], + ["an empty id", { id: "", reactions: {} }], + ["no reactions", { id: "b" }], + ["reactions that aren't an object", { id: "b", reactions: [] as any }], + ])("ignores an update with %s", (_, malformed) => { + expect( + applyChatMessageReactions([line("b", 1)], malformed, new Set()), + ).toBeNull(); + }); +}); + describe("newestMessageIdsFrom", () => { const ME = "76561198000000009"; diff --git a/tests/utilities/chatMessageActions.spec.ts b/tests/utilities/chatMessageActions.spec.ts index 8d64c865..01870172 100644 --- a/tests/utilities/chatMessageActions.spec.ts +++ b/tests/utilities/chatMessageActions.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { chatMessagePermissions, hasChatMessageActions, + heldChatReactions, SELF_SERVICE_WINDOW_MS, } from "~/utilities/chatMessageActions"; import type { LobbyMessage } from "~/web-sockets/Socket"; @@ -31,7 +32,12 @@ describe("chatMessagePermissions", () => { canModerate: true, roomType, }), - ).toEqual({ canDelete: true, canEdit: false }); + ).toEqual({ + canDelete: true, + canEdit: false, + canReact: false, + canAddReaction: false, + }); }); it("never offers a delete in a direct conversation", () => { @@ -95,6 +101,8 @@ describe("chatMessagePermissions for the author", () => { expect(permissions({}, { roomType })).toEqual({ canDelete: true, canEdit: true, + canReact: false, + canAddReaction: false, }); }, ); @@ -103,10 +111,14 @@ describe("chatMessagePermissions for the author", () => { expect(permissions({}, { age: SELF_SERVICE_WINDOW_MS - 1 })).toEqual({ canDelete: true, canEdit: true, + canReact: false, + canAddReaction: false, }); expect(permissions({}, { age: SELF_SERVICE_WINDOW_MS })).toEqual({ canDelete: false, canEdit: false, + canReact: false, + canAddReaction: false, }); }); @@ -116,7 +128,12 @@ describe("chatMessagePermissions for the author", () => { {}, { canModerate: true, age: SELF_SERVICE_WINDOW_MS + 60_000 }, ), - ).toEqual({ canDelete: true, canEdit: false }); + ).toEqual({ + canDelete: true, + canEdit: false, + canReact: false, + canAddReaction: false, + }); }); it("gives nobody a delete in a conversation once the window closes", () => { @@ -129,7 +146,12 @@ describe("chatMessagePermissions for the author", () => { age: SELF_SERVICE_WINDOW_MS, }, ), - ).toEqual({ canDelete: false, canEdit: false }); + ).toEqual({ + canDelete: false, + canEdit: false, + canReact: false, + canAddReaction: false, + }); }); it("keeps a gagged author's delete in a group room but not the edit", () => { @@ -142,7 +164,12 @@ describe("chatMessagePermissions for the author", () => { roomType: "match", now: sentAt, }), - ).toEqual({ canDelete: true, canEdit: false }); + ).toEqual({ + canDelete: true, + canEdit: false, + canReact: false, + canAddReaction: false, + }); }); it("lets a gagged author edit in a direct conversation", () => { @@ -155,7 +182,12 @@ describe("chatMessagePermissions for the author", () => { roomType: "direct", now: sentAt, }), - ).toEqual({ canDelete: true, canEdit: true }); + ).toEqual({ + canDelete: true, + canEdit: true, + canReact: false, + canAddReaction: false, + }); }); it("never lets a moderator edit someone else's message", () => { @@ -164,7 +196,12 @@ describe("chatMessagePermissions for the author", () => { { from: { steam_id: "76561198000000002" } }, { canModerate: true }, ), - ).toEqual({ canDelete: true, canEdit: false }); + ).toEqual({ + canDelete: true, + canEdit: false, + canReact: false, + canAddReaction: false, + }); }); it.each([ @@ -176,6 +213,8 @@ describe("chatMessagePermissions for the author", () => { expect(permissions(overrides)).toEqual({ canDelete: false, canEdit: false, + canReact: false, + canAddReaction: false, }); }); @@ -188,20 +227,134 @@ describe("chatMessagePermissions for the author", () => { roomType: "match", now: sentAt, }), - ).toEqual({ canDelete: false, canEdit: false }); + ).toEqual({ + canDelete: false, + canEdit: false, + canReact: false, + canAddReaction: false, + }); + }); +}); + +describe("chatMessagePermissions for reactions", () => { + const react = ( + overrides: Partial = {}, + options: { + roomType?: string; + canPost?: boolean; + viewerGagged?: boolean; + viewer?: string | null; + } = {}, + ) => { + const { canReact, canAddReaction } = chatMessagePermissions({ + message: { ...message, ...overrides }, + viewerSteamId: + options.viewer === undefined ? viewerSteamId : options.viewer, + viewerGagged: options.viewerGagged, + canModerate: false, + canPost: options.canPost ?? true, + roomType: options.roomType ?? "match", + }); + + return { canReact, canAddReaction }; + }; + + const both = { canReact: true, canAddReaction: true }; + const neither = { canReact: false, canAddReaction: false }; + + it.each([ + "match", + "match_team", + "matchmaking", + "tournament", + "draft", + "organizers", + "direct", + ])("lets anyone who can post react in a %s room", (roomType) => { + expect(react({}, { roomType })).toEqual(both); + }); + + it.each([ + ["relayed from the game", { source: "game" as const }], + ["stored before sources were recorded", { source: undefined }], + ["of their own", { from: { steam_id: viewerSteamId } }], + ["long past the edit window", { timestamp: "2020-01-01T00:00:00.000Z" }], + ])("allows a reaction on a line %s", (_, overrides) => { + expect(react(overrides)).toEqual(both); + }); + + it("offers nothing where the viewer can't post", () => { + expect(react({}, { canPost: false })).toEqual(neither); + }); + + it("offers nothing for a line the api can't address", () => { + expect(react({ id: undefined })).toEqual(neither); + }); + + it("offers nothing to a signed-out viewer", () => { + expect(react({}, { viewer: null })).toEqual(neither); + }); + + it("offers a gagged player nothing to add in a group room", () => { + expect(react({}, { viewerGagged: true })).toEqual(neither); + }); + + it("lets a gagged player take back what they already hold", () => { + expect( + react( + { reactions: { heart: ["76561198000000003", viewerSteamId] } }, + { viewerGagged: true }, + ), + ).toEqual({ canReact: true, canAddReaction: false }); + }); + + it("ignores a reaction the viewer holds that isn't on the list", () => { + expect( + react({ reactions: { party: [viewerSteamId] } }, { viewerGagged: true }), + ).toEqual(neither); + }); + + it("doesn't hold a gag against a direct conversation", () => { + expect(react({}, { viewerGagged: true, roomType: "direct" })).toEqual( + both, + ); + }); +}); + +describe("heldChatReactions", () => { + it("names the reactions the viewer holds", () => { + expect( + heldChatReactions( + { + ...message, + reactions: { + thumbsup: ["76561198000000003"], + fire: [viewerSteamId], + sad: [viewerSteamId, "76561198000000003"], + }, + }, + viewerSteamId, + ), + ).toEqual(new Set(["fire", "sad"])); + }); + + it("holds nothing on a message from an api without reactions", () => { + expect(heldChatReactions(message, viewerSteamId)).toEqual(new Set()); }); }); describe("hasChatMessageActions", () => { + const none = { + canDelete: false, + canEdit: false, + canReact: false, + canAddReaction: false, + }; + it("is true only when something is permitted", () => { - expect(hasChatMessageActions({ canDelete: true, canEdit: false })).toBe( - true, - ); - expect(hasChatMessageActions({ canDelete: false, canEdit: true })).toBe( - true, - ); - expect(hasChatMessageActions({ canDelete: false, canEdit: false })).toBe( - false, - ); + expect(hasChatMessageActions({ ...none, canDelete: true })).toBe(true); + expect(hasChatMessageActions({ ...none, canEdit: true })).toBe(true); + expect(hasChatMessageActions({ ...none, canReact: true })).toBe(true); + expect(hasChatMessageActions(none)).toBe(false); }); }); diff --git a/tests/web-sockets/SocketChatError.spec.ts b/tests/web-sockets/SocketChatError.spec.ts index d49dc822..aed8c299 100644 --- a/tests/web-sockets/SocketChatError.spec.ts +++ b/tests/web-sockets/SocketChatError.spec.ts @@ -44,6 +44,13 @@ function sentEdits(send: ReturnType) { .map(({ data }) => data); } +function sentReacts(send: ReturnType) { + return send.mock.calls + .map(([payload]) => JSON.parse(payload)) + .filter(({ event }) => event === "lobby:react") + .map(({ data }) => data); +} + async function settled(promise: Promise) { let state = "pending"; promise.then( @@ -644,3 +651,223 @@ describe("Socket lobby edits", () => { expect(text()).toEqual(["line a", "line b"]); }); }); + +describe("Socket react", () => { + let send: ReturnType; + + beforeEach(() => { + toast.mockClear(); + send = connect(); + }); + + afterEach(() => { + vi.useRealTimers(); + disconnect(); + }); + + it("asks the api to toggle the reaction with a request id", () => { + void socket.react("match", "match-1", "message-1", "fire").catch(() => {}); + + const [request] = sentReacts(send); + expect(request).toMatchObject({ + type: "match", + id: "match-1", + messageId: "message-1", + reaction: "fire", + }); + expect(typeof request.requestId).toBe("string"); + }); + + it("resolves only on the react ack for its own request", async () => { + const pending = socket.react("match", "match-1", "message-1", "heart"); + const [{ requestId }] = sentReacts(send); + + socket.emit("chat:ack", { requestId, action: "edit" }); + expect(await settled(pending)).toBe("pending"); + + socket.emit("chat:ack", { requestId: "someone-else", action: "react" }); + expect(await settled(pending)).toBe("pending"); + + socket.emit("chat:ack", { + requestId, + messageId: "message-1", + action: "react", + }); + await expect(pending).resolves.toBeUndefined(); + }); + + it("rejects on its own error without a global toast", async () => { + const pending = socket.react("match", "match-1", "message-1", "heart"); + const [{ requestId }] = sentReacts(send); + + socket.emit("chat:error", { + code: "rate_limited", + action: "react", + requestId, + }); + + await expect(pending).rejects.toMatchObject({ + code: "rate_limited", + action: "react", + }); + expect(toast).not.toHaveBeenCalled(); + }); + + it("gives up when the api never answers", async () => { + vi.useFakeTimers(); + const pending = socket.react("match", "match-1", "message-1", "heart"); + + vi.advanceTimersByTime(7999); + expect(await settled(pending)).toBe("pending"); + + vi.advanceTimersByTime(1); + await expect(pending).rejects.toMatchObject({ + code: "timeout", + action: "react", + }); + }); + + it("refuses straight away while offline and sends nothing", async () => { + disconnect(); + + await expect( + socket.react("match", "match-1", "message-1", "heart"), + ).rejects.toMatchObject({ code: "offline", action: "react" }); + expect(sentReacts(send)).toEqual([]); + expect((socket as any).offlineQueue).not.toContainEqual( + expect.objectContaining({ event: "lobby:react" }), + ); + }); + + it("titles a react error nobody is waiting on as a failed reaction", () => { + socket.emit("chat:error", { + code: "rate_limited", + action: "react", + requestId: "nobody-asked", + }); + + expect(toast).toHaveBeenCalledWith({ + title: "Failed to react", + description: "You're reacting too fast.", + variant: "destructive", + }); + }); +}); + +describe("Socket lobby reactions", () => { + let lobby: ReturnType; + const DANA = "76561198000000003"; + const ELI = "76561198000000004"; + + beforeEach(() => { + vi.spyOn(console, "info").mockImplementation(() => {}); + lobby = socket.joinLobby("socket-spec", "direct", "1:2"); + socket.emit("lobby:direct:1:2:messages", { + messages: [line("a", 0), { ...line("b", 1), reactions: {} }], + }); + }); + + afterEach(() => { + vi.useRealTimers(); + lobby.leave(); + disconnect(); + vi.restoreAllMocks(); + }); + + const reactions = () => lobby.messages.map((message) => message.reactions); + + it("replaces the message's reactions with each broadcast", () => { + socket.emit("lobby:direct:1:2:reaction", { + id: "b", + reactions: { heart: [DANA], fire: [ELI] }, + }); + socket.emit("lobby:direct:1:2:reaction", { + id: "b", + reactions: { fire: [ELI, DANA] }, + }); + + expect(reactions()).toEqual([undefined, { fire: [ELI, DANA] }]); + }); + + it("never creates a message from reactions for an id it doesn't hold", () => { + socket.emit("lobby:direct:1:2:reaction", { + id: "c", + reactions: { heart: [DANA] }, + }); + + expect(ids(lobby.messages)).toEqual(["a", "b"]); + }); + + it("ignores reactions that arrive after the delete they raced", () => { + socket.emit("lobby:direct:1:2:deleted", { id: "b" }); + socket.emit("lobby:direct:1:2:reaction", { + id: "b", + reactions: { heart: [DANA] }, + }); + + expect(ids(lobby.messages)).toEqual(["a"]); + }); + + it("keeps live reactions through a snapshot built before them", () => { + socket.emit("lobby:direct:1:2:reaction", { + id: "b", + reactions: { heart: [DANA] }, + }); + socket.emit("lobby:direct:1:2:messages", { + messages: [line("a", 0), { ...line("b", 1), reactions: {} }], + }); + + expect(reactions()).toEqual([undefined, { heart: [DANA] }]); + }); + + it("takes the snapshot that answers a later join", () => { + socket.emit("lobby:direct:1:2:reaction", { + id: "b", + reactions: { heart: [DANA] }, + }); + + const send = connect(); + socket.join("lobby", { type: "direct", id: "1:2" }); + expect( + send.mock.calls.some( + ([payload]) => JSON.parse(payload).event === "lobby:join", + ), + ).toBe(true); + + socket.emit("lobby:direct:1:2:messages", { + messages: [line("a", 0), { ...line("b", 1), reactions: { sad: [ELI] } }], + }); + + expect(reactions()).toEqual([undefined, { sad: [ELI] }]); + }); + + it("drops the line when the api says it is already gone", async () => { + const send = connect(); + const pending = socket.react("direct", "1:2", "b", "heart"); + const [{ requestId }] = sentReacts(send); + + socket.emit("chat:error", { + code: "not_found", + action: "react", + requestId, + }); + + await expect(pending).rejects.toMatchObject({ code: "not_found" }); + expect(ids(lobby.messages)).toEqual(["a"]); + }); + + it("leaves the line alone when the reaction is refused", async () => { + const send = connect(); + const pending = socket.react("direct", "1:2", "b", "heart"); + const [{ requestId }] = sentReacts(send); + + socket.emit("chat:error", { + code: "gagged", + action: "react", + requestId, + }); + + await expect(pending).rejects.toMatchObject({ code: "gagged" }); + expect(ids(lobby.messages)).toEqual(["a", "b"]); + }); +}); diff --git a/utilities/chatErrors.ts b/utilities/chatErrors.ts index 28084906..a19e44c1 100644 --- a/utilities/chatErrors.ts +++ b/utilities/chatErrors.ts @@ -3,7 +3,7 @@ import { CHAT_MESSAGE_MAX_LENGTH } from "~/constants/chat"; // Echoed by the api on `chat:ack` and `chat:error`. An api that predates it // sends none, and only ever answered sends. -export type ChatAction = "send" | "delete" | "edit"; +export type ChatAction = "send" | "delete" | "edit" | "react"; export interface ChatError { code: string; @@ -33,6 +33,8 @@ export function chatErrorTitle(error: ChatError, t: Translate): string { return t("chat.delete_failed"); case "edit": return t("chat.edit_failed"); + case "react": + return t("chat.react_failed"); case "send": default: return t("chat.send_failed"); @@ -75,6 +77,19 @@ export function chatErrorDescription( } } + if (chatErrorAction(error) === "react") { + switch (error?.code) { + case "rate_limited": + return t("chat.react_rate_limited"); + case "gagged": + return t("chat.react_gagged"); + case "not_found": + return t("chat.message_already_gone"); + default: + return undefined; + } + } + switch (error?.code) { case "too_long": return t("chat.message_too_long", { diff --git a/utilities/chatLobbyMessages.ts b/utilities/chatLobbyMessages.ts index 57c4c277..6cb22e6c 100644 --- a/utilities/chatLobbyMessages.ts +++ b/utilities/chatLobbyMessages.ts @@ -1,4 +1,4 @@ -import type { LobbyMessage } from "~/web-sockets/Socket"; +import type { ChatReactions, LobbyMessage } from "~/web-sockets/Socket"; // The live `chat` event and the history snapshot sent on every (re)join can // carry the same message, so a message needs an identity the client can compare @@ -39,21 +39,30 @@ export function isChatMessageDeleted( // // The same goes for an edit: a message is never un-edited, so a snapshot copy // older than the edit this client already applied keeps the edit. +// +// Reactions carry no version to compare. `reacted` names the messages whose +// reactions changed live after this snapshot was asked for, and for those the +// live state wins: the snapshot may have been read before it. Were the snapshot +// the newer one after all, the event for the toggle it saw is still on its way +// and replaces the state again. export function mergeChatSnapshot( current: LobbyMessage[], snapshot: LobbyMessage[] | null | undefined, deleted: ReadonlySet, + reacted: ReadonlySet = new Set(), ) { - const edited = new Map(); + const held = new Map(); for (const message of current) { - if (message?.id && message.edited_at) { - edited.set(message.id, message); + if (message?.id && (message.edited_at || reacted.has(message.id))) { + held.set(message.id, message); } } - const history = (snapshot || []).map((message) => - keepNewerEdit(message, message?.id ? edited.get(message.id) : undefined), - ); + const history = (snapshot || []).map((message) => { + const mine = message?.id ? held.get(message.id) : undefined; + + return keepLiveReactions(keepNewerEdit(message, mine), mine, reacted); + }); const snapshotKeys = new Set(history.map(chatMessageKey)); const newest = history.reduce( @@ -114,6 +123,18 @@ function keepNewerEdit( return kept; } +function keepLiveReactions( + snapshot: LobbyMessage, + held: LobbyMessage | undefined, + reacted: ReadonlySet, +) { + if (!held?.id || !reacted.has(held.id)) { + return snapshot; + } + + return { ...snapshot, reactions: held.reactions }; +} + export function insertChatMessage( current: LobbyMessage[], message: LobbyMessage, @@ -225,3 +246,42 @@ export function applyChatMessageEdit( return messages; } + +export interface ChatMessageReactionsUpdate { + id?: string; + reactions?: ChatReactions; +} + +// Each update is the message's whole reaction state, so it replaces what is +// held. Like an edit, it never brings back a message this client doesn't hold. +export function applyChatMessageReactions( + current: LobbyMessage[], + update: ChatMessageReactionsUpdate | null | undefined, + deleted: ReadonlySet, +): LobbyMessage[] | null { + const id = update?.id; + const reactions = update?.reactions; + + if (typeof id !== "string" || !id || deleted.has(id)) { + return null; + } + + if ( + !reactions || + typeof reactions !== "object" || + Array.isArray(reactions) + ) { + return null; + } + + const index = current.findIndex((message) => message?.id === id); + + if (index === -1) { + return null; + } + + const messages = current.slice(); + messages[index] = { ...messages[index], reactions }; + + return messages; +} diff --git a/utilities/chatMessageActions.ts b/utilities/chatMessageActions.ts index ba70576c..464cb44a 100644 --- a/utilities/chatMessageActions.ts +++ b/utilities/chatMessageActions.ts @@ -1,3 +1,4 @@ +import { CHAT_REACTIONS, type ChatReaction } from "~/constants/chat"; import type { ChatType, LobbyMessage } from "~/web-sockets/Socket"; // Must match the api's ChatService.SELF_SERVICE_WINDOW_MS. @@ -6,6 +7,8 @@ export const SELF_SERVICE_WINDOW_MS = 600_000; export interface ChatMessagePermissions { canDelete: boolean; canEdit: boolean; + canReact: boolean; + canAddReaction: boolean; } export interface ChatMessagePermissionInput { @@ -13,6 +16,8 @@ export interface ChatMessagePermissionInput { viewerSteamId?: string | null; viewerGagged?: boolean; canModerate: boolean; + // Whether the viewer may send in this room at all. + canPost?: boolean; roomType: ChatType | string; now?: number; } @@ -30,16 +35,45 @@ export function isOwnChatMessage( return String(author) === String(viewerSteamId); } +export function heldChatReactions( + message: LobbyMessage | null | undefined, + viewerSteamId?: string | null, +): Set { + const held = new Set(); + + if (!viewerSteamId) { + return held; + } + + for (const { id } of CHAT_REACTIONS) { + const steamIds = message?.reactions?.[id]; + + if ( + Array.isArray(steamIds) && + steamIds.some((steamId) => String(steamId) === String(viewerSteamId)) + ) { + held.add(id); + } + } + + return held; +} + // Mirrors the api's ChatService.canDelete and selfServiceRefusal, so the menu // never offers what the server would refuse. The api only addresses a message // by its id; lines from before it stamped one can't be targeted at all, and // lines stored before it recorded a source are nobody's to change. A gag stops // an edit in a group room, like a send, but never the author's own delete. +// +// Reacting follows sending instead, on any line of any source. A gag in a group +// room stops adding a reaction but not taking one back, so a gagged player is +// only offered the reactions they already hold. export function chatMessagePermissions({ message, viewerSteamId, viewerGagged = false, canModerate, + canPost = false, roomType, now = Date.now(), }: ChatMessagePermissionInput): ChatMessagePermissions { @@ -54,13 +88,30 @@ export function chatMessagePermissions({ Number.isFinite(sentAt) && now - sentAt < SELF_SERVICE_WINDOW_MS; + const gaggedHere = viewerGagged && roomType !== "direct"; + + const reactor = addressable && canPost && !!viewerSteamId; + + const canAddReaction = reactor && !gaggedHere; + return { canDelete: addressable && ((canModerate && roomType !== "direct") || selfService), - canEdit: selfService && !(viewerGagged && roomType !== "direct"), + canEdit: selfService && !gaggedHere, + canReact: + canAddReaction || + (reactor && heldChatReactions(message, viewerSteamId).size > 0), + canAddReaction, }; } +export function canToggleChatReaction( + permissions: Pick, + held: boolean, +) { + return held ? permissions.canReact : permissions.canAddReaction; +} + export function hasChatMessageActions(permissions: ChatMessagePermissions) { return Object.values(permissions).some(Boolean); } diff --git a/web-sockets/Socket.ts b/web-sockets/Socket.ts index 65f594fe..74c8762d 100644 --- a/web-sockets/Socket.ts +++ b/web-sockets/Socket.ts @@ -10,24 +10,32 @@ import { } from "~/utilities/chatErrors"; import { applyChatMessageEdit, + applyChatMessageReactions, chatMessageKey, insertChatMessage, isChatMessageDeleted, mergeChatSnapshot, removeChatMessage, type ChatMessageEdit, + type ChatMessageReactionsUpdate, type RemovedChatMessage, } from "~/utilities/chatLobbyMessages"; +import type { ChatReaction } from "~/constants/chat"; import guid from "~/utilities/uuid"; export { chatMessageKey, chatMessageTime } from "~/utilities/chatLobbyMessages"; +export type ChatReactions = Record; + export interface LobbyMessage { id?: string; message: string; timestamp: string; source?: "web" | "game"; edited_at?: string; + // Reaction id to the steam ids holding it, oldest first. Missing from an api + // that predates reactions. + reactions?: ChatReactions; from?: { role?: string; name?: string; @@ -59,6 +67,9 @@ interface LobbyState { // Ids the room has deleted. A history snapshot built before the delete can // still arrive after it, and must not bring the message back. deleted: Set; + // Ids whose reactions changed live since the room's history was last asked + // for. See mergeChatSnapshot. + reacted: Set; instances: Set; callbacks: Map void>; listeners: ReturnType[]; @@ -356,6 +367,10 @@ export class Socket extends EventEmitter { this.event(`${room}:join`, data); + if (room === "lobby") { + this.lobbies.get(`${data.type}:${data.id}`)?.reacted.clear(); + } + // Our lobbies expire server-side after 24 hours, so we need to // periodically re-join to ensure we stay in the room for long-lived sessions. const existingTimer = this.rejoinTimers.get(roomKey); @@ -489,6 +504,30 @@ export class Socket extends EventEmitter { ); } + // Sending a reaction the player already holds takes it back. Nothing changes + // here until the room's `reaction` broadcast, which carries the whole state. + public react( + type: ChatType, + id: string, + messageId: string, + reaction: ChatReaction, + ): Promise { + const lobbyId = `${type}:${id}`; + + return this.chatRequest( + "react", + { id, type, messageId, reaction }, + { + resolved: () => {}, + rejected: (error) => { + if (error?.code === "not_found") { + this.removeMessageFromLobby(lobbyId, messageId); + } + }, + }, + ); + } + // Never queued: someone told a delete or an edit failed must not have it // carried out behind their back once the connection comes back. private chatRequest( @@ -659,6 +698,7 @@ export class Socket extends EventEmitter { messages: shallowRef([]), seen: new Set(), deleted: new Set(), + reacted: new Set(), callbacks: new Map(), listeners: [], }; @@ -709,6 +749,15 @@ export class Socket extends EventEmitter { }), ); + lobby.listeners.push( + this.listen( + `lobby:${lobbyId}:reaction`, + (update: ChatMessageReactionsUpdate) => { + this.reactLobbyMessage(lobby, update); + }, + ), + ); + this.join(`lobby`, { id: _id, type, @@ -722,6 +771,7 @@ export class Socket extends EventEmitter { lobby.messages.value, messages, lobby.deleted, + lobby.reacted, ); lobby.seen.clear(); @@ -763,6 +813,22 @@ export class Socket extends EventEmitter { } } + private reactLobbyMessage( + lobby: LobbyState, + update: ChatMessageReactionsUpdate, + ) { + const messages = applyChatMessageReactions( + lobby.messages.value, + update, + lobby.deleted, + ); + + if (messages) { + lobby.reacted.add(update.id as string); + lobby.messages.value = messages; + } + } + private removeMessageFromLobby(lobbyId: string, messageId: string) { const lobby = this.lobbies.get(lobbyId); if (lobby) { From e63932a13d420f55e89166e989414778abe7fede Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 19:13:49 -0400 Subject: [PATCH 2/4] bug: chat reactions review fixes Picking Add Reaction now holds the right hub from the pick itself: the menu's exit animation outlasts the hub's hover-close delay, so a hover-peeking hub used to collapse before the picker opened. Closing the picker by clicking elsewhere no longer pulls focus back to the trigger, since reka emits its close focus event twice. The picker is labelled for screen readers. A double click no longer adds a reaction and takes it straight back: the picker ignores a pick once it is closing, and a toggle already on its way for the same message and reaction isn't sent again. A reaction the server never answered says it may still go through. A gagged player's row keeps the trigger's room when their last reaction goes, so it doesn't rewrap, and a held pill that can't be toggled no longer looks clickable. --- components/chat/ChatMessage.vue | 15 +++++- components/chat/ChatMessageActions.vue | 34 +++++++----- components/chat/ChatMessageReactions.vue | 23 +++++--- i18n/locales/ar_SA.json | 1 + i18n/locales/da_DK.json | 1 + i18n/locales/de_DE.json | 1 + i18n/locales/en.json | 1 + i18n/locales/es_ES.json | 1 + i18n/locales/fr_FR.json | 1 + i18n/locales/it_IT.json | 1 + i18n/locales/ja_JP.json | 1 + i18n/locales/ko_KR.json | 1 + i18n/locales/pl_PL.json | 1 + i18n/locales/pt_BR.json | 1 + i18n/locales/ru_RU.json | 1 + i18n/locales/sv_SE.json | 1 + i18n/locales/tr_TR.json | 1 + i18n/locales/uk_UA.json | 1 + i18n/locales/zh_Hans.json | 1 + i18n/locales/zh_Hant.json | 1 + tests/components/ChatMessage.spec.ts | 13 +++++ tests/components/ChatMessageActions.spec.ts | 53 +++++++++++++++++++ tests/components/ChatMessageReactions.spec.ts | 12 +++++ tests/utilities/chatErrors.spec.ts | 3 +- tests/web-sockets/SocketChatError.spec.ts | 40 ++++++++++++++ utilities/chatErrors.ts | 2 + utilities/chatMessageActions.ts | 1 - web-sockets/Socket.ts | 20 ++++++- 28 files changed, 208 insertions(+), 25 deletions(-) diff --git a/components/chat/ChatMessage.vue b/components/chat/ChatMessage.vue index 8176babc..9afcf98f 100644 --- a/components/chat/ChatMessage.vue +++ b/components/chat/ChatMessage.vue @@ -254,6 +254,17 @@ export default { hasActions() { return !!this.room && hasChatMessageActions(this.permissions); }, + // A gagged player's trigger comes and goes with the reactions they hold, + // so the room for it is kept wherever reacting is possible at all. + reservesActions() { + return ( + this.hasActions || + (!!this.room && + this.canPost && + !!this.message?.id && + !!this.viewerSteamId) + ); + }, // Stamped by ChatLobby when it merges the match room with a lineup room. // Absent everywhere else, which is what keeps every other chat surface // rendering exactly as before. @@ -307,7 +318,7 @@ export default { // Room for the actions trigger kept whether or not it is showing, so a // hover never rewraps the line under the pointer. - if (this.hasActions) { + if (this.reservesActions) { classes.push("pr-7"); } @@ -319,7 +330,7 @@ export default { } classes.push("bg-[hsl(var(--tac-amber)/0.05)]"); - if (!this.hasActions) { + if (!this.reservesActions) { classes.push("pr-2"); } diff --git a/components/chat/ChatMessageActions.vue b/components/chat/ChatMessageActions.vue index 4fdde3f5..645abaec 100644 --- a/components/chat/ChatMessageActions.vue +++ b/components/chat/ChatMessageActions.vue @@ -51,6 +51,7 @@ let pickerRequested = false; function setMenuOpen(open: boolean) { if (open) { pickerRequested = false; + holdRightHub("picker", pickerOpen.value); emit("open"); } @@ -86,7 +87,13 @@ onBeforeUnmount(() => { // The picker opens once the menu has finished closing. Opened any sooner, the // menu hands focus back to the trigger, which reads as a click outside the -// picker and closes it again. +// picker and closes it again. The hub is held from the pick, since the menu's +// exit animation outlasts the hub's hover-close delay. +function requestPicker() { + pickerRequested = true; + holdRightHub("picker", true); +} + function menuClosedFocus(event: Event) { if (!pickerRequested) { return; @@ -115,15 +122,24 @@ const pickerChoices = computed(() => ); function pickReaction(reaction: ChatReaction) { + if (!pickerOpen.value) { + return; + } + pickerOpen.value = false; emit("react", reaction); } // Back to the trigger unless the picker was closed by clicking somewhere else, -// where that click decides focus. +// where that click decides focus. reka's non-modal popover emits this twice, +// and the second call must not undo the first. let pickerDismissedOutside = false; function pickerClosedFocus(event: Event) { + if (event.defaultPrevented) { + return; + } + event.preventDefault(); if (!pickerDismissedOutside) { @@ -169,10 +185,7 @@ async function deleteMessage() { trigger already belongs to the menu. --> - + {{ $t("chat.add_reaction") }} @@ -230,15 +243,12 @@ async function deleteMessage() { -
+