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..9afcf98f 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..4b6eace1 --- /dev/null +++ b/components/chat/ChatMessageReactions.vue @@ -0,0 +1,187 @@ + + + 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..4ac1e10e 100644 --- a/i18n/locales/ar_SA.json +++ b/i18n/locales/ar_SA.json @@ -8494,6 +8494,18 @@ "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_timeout": "لم يستجب الخادم. قد يُسجَّل تفاعلك لاحقًا.", + "react_rate_limited": "أنت تتفاعل بسرعة كبيرة.", + "react_gagged": "أنت ممنوع من الكتابة ولا يمكنك إضافة تفاعلات.", "direct": { "message": "رسالة", "empty": "لا رسائل بعد. ألقِ التحية." diff --git a/i18n/locales/da_DK.json b/i18n/locales/da_DK.json index bf273d94..b87416bc 100644 --- a/i18n/locales/da_DK.json +++ b/i18n/locales/da_DK.json @@ -8494,6 +8494,18 @@ "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_timeout": "Serveren svarede ikke. Din reaktion kan stadig blive registreret.", + "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..9bf6a422 100644 --- a/i18n/locales/de_DE.json +++ b/i18n/locales/de_DE.json @@ -8494,6 +8494,18 @@ "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_timeout": "Der Server hat nicht geantwortet. Deine Reaktion kommt eventuell trotzdem an.", + "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..eace8548 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -8494,6 +8494,18 @@ "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_timeout": "The server didn't answer. Your reaction may still go through.", + "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..4717f5d7 100644 --- a/i18n/locales/es_ES.json +++ b/i18n/locales/es_ES.json @@ -8494,6 +8494,18 @@ "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_timeout": "El servidor no ha respondido. Puede que tu reacción se registre de todos modos.", + "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..afb6c5a3 100644 --- a/i18n/locales/fr_FR.json +++ b/i18n/locales/fr_FR.json @@ -8494,6 +8494,18 @@ "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_timeout": "Le serveur n'a pas répondu. Votre réaction peut tout de même être prise en compte.", + "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..5323edc4 100644 --- a/i18n/locales/it_IT.json +++ b/i18n/locales/it_IT.json @@ -8494,6 +8494,18 @@ "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_timeout": "Il server non ha risposto. La reazione potrebbe comunque essere registrata.", + "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..c9abcb1d 100644 --- a/i18n/locales/ja_JP.json +++ b/i18n/locales/ja_JP.json @@ -8494,6 +8494,18 @@ "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_timeout": "サーバーから応答がありませんでした。リアクションは後で反映される可能性があります。", + "react_rate_limited": "リアクションの操作が速すぎます。", + "react_gagged": "発言禁止中のため、リアクションを追加できません。", "direct": { "message": "メッセージ", "empty": "まだメッセージはありません。あいさつしてみましょう。" diff --git a/i18n/locales/ko_KR.json b/i18n/locales/ko_KR.json index 0aea4c9d..956e0148 100644 --- a/i18n/locales/ko_KR.json +++ b/i18n/locales/ko_KR.json @@ -8494,6 +8494,18 @@ "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_timeout": "서버가 응답하지 않았습니다. 반응이 나중에 반영될 수도 있습니다.", + "react_rate_limited": "반응을 너무 빠르게 하고 있습니다.", + "react_gagged": "채팅이 금지되어 반응을 추가할 수 없습니다.", "direct": { "message": "메시지", "empty": "아직 메시지가 없습니다. 인사를 건네보세요." diff --git a/i18n/locales/pl_PL.json b/i18n/locales/pl_PL.json index ce9c1efa..fee9596e 100644 --- a/i18n/locales/pl_PL.json +++ b/i18n/locales/pl_PL.json @@ -8494,6 +8494,18 @@ "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_timeout": "Serwer nie odpowiedział. Twoja reakcja może mimo to zostać zapisana.", + "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..d79d22a4 100644 --- a/i18n/locales/pt_BR.json +++ b/i18n/locales/pt_BR.json @@ -8494,6 +8494,18 @@ "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_timeout": "O servidor não respondeu. Sua reação ainda pode ser registrada.", + "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..67220b02 100644 --- a/i18n/locales/ru_RU.json +++ b/i18n/locales/ru_RU.json @@ -8494,6 +8494,18 @@ "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_timeout": "Сервер не ответил. Реакция всё ещё может быть сохранена.", + "react_rate_limited": "Вы ставите реакции слишком быстро.", + "react_gagged": "Вам отключили чат, и вы не можете добавлять реакции.", "direct": { "message": "Сообщение", "empty": "Сообщений пока нет. Поздоровайтесь." diff --git a/i18n/locales/sv_SE.json b/i18n/locales/sv_SE.json index c9e41be5..760eca1e 100644 --- a/i18n/locales/sv_SE.json +++ b/i18n/locales/sv_SE.json @@ -8494,6 +8494,18 @@ "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_timeout": "Servern svarade inte. Din reaktion kan fortfarande registreras.", + "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..5325e3b3 100644 --- a/i18n/locales/tr_TR.json +++ b/i18n/locales/tr_TR.json @@ -8494,6 +8494,18 @@ "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_timeout": "Sunucu yanıt vermedi. Tepkin yine de kaydedilebilir.", + "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..545ba61d 100644 --- a/i18n/locales/uk_UA.json +++ b/i18n/locales/uk_UA.json @@ -8494,6 +8494,18 @@ "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_timeout": "Сервер не відповів. Реакцію ще може бути збережено.", + "react_rate_limited": "Ви ставите реакції надто швидко.", + "react_gagged": "Вам вимкнули чат, тож ви не можете додавати реакції.", "direct": { "message": "Повідомлення", "empty": "Повідомлень поки немає. Привітайтеся." diff --git a/i18n/locales/zh_Hans.json b/i18n/locales/zh_Hans.json index fde32490..e52cd7c9 100644 --- a/i18n/locales/zh_Hans.json +++ b/i18n/locales/zh_Hans.json @@ -8494,6 +8494,18 @@ "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_timeout": "服务器没有响应。你的表情回应仍可能生效。", + "react_rate_limited": "你的回应操作过快。", + "react_gagged": "你已被禁言,无法添加表情回应。", "direct": { "message": "消息", "empty": "还没有消息,先打个招呼吧。" diff --git a/i18n/locales/zh_Hant.json b/i18n/locales/zh_Hant.json index dd9bb1aa..f0235088 100644 --- a/i18n/locales/zh_Hant.json +++ b/i18n/locales/zh_Hant.json @@ -8494,6 +8494,18 @@ "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_timeout": "伺服器沒有回應。你的表情回應仍可能生效。", + "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..ebe5dbdc 100644 --- a/tests/components/ChatMessage.spec.ts +++ b/tests/components/ChatMessage.spec.ts @@ -415,3 +415,155 @@ 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("keeps a gagged player's row the same width with or without a trigger", async () => { + useAuthStore().me = { steam_id: ME, role: "user", is_gagged: true } as any; + const wrapper = await mountMessage(); + + expect(wrapper.find(TRIGGER).exists()).toBe(true); + expect(wrapper.classes()).toContain("pr-7"); + + await wrapper.setProps({ message: theirs({ reactions: {} }) }); + + expect(wrapper.find(TRIGGER).exists()).toBe(false); + expect(wrapper.classes()).toContain("pr-7"); + }); + + 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..cc32abe0 100644 --- a/tests/components/ChatMessageActions.spec.ts +++ b/tests/components/ChatMessageActions.spec.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { watch } from "vue"; import { flushPromises } from "@vue/test-utils"; import { mountSuspended } from "@nuxt/test-utils/runtime"; import ChatMessage from "~/components/chat/ChatMessage.vue"; @@ -330,3 +331,241 @@ 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("names the picker for screen readers", async () => { + await openPicker(); + + expect(picker()?.getAttribute("aria-label")).toBe("React"); + }); + + it("never lets go of the hub between the menu and the picker", async () => { + const held: boolean[] = []; + const stop = watch( + () => useRightSidebar().hoverCloseSuspended.value, + (value) => held.push(value), + { flush: "sync" }, + ); + + try { + await openPicker(); + } finally { + stop(); + } + + expect(picker()).not.toBeNull(); + expect(held).toEqual([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("reacts once for a double click", async () => { + const wrapper = await openPicker(); + + const fire = choice("fire"); + fire.click(); + fire.click(); + await flushPromises(); + + expect(wrapper.emitted("react")).toEqual([["fire"]]); + }); + + it("leaves focus where a click outside put it", async () => { + const wrapper = await openPicker(); + + const outside = document.createElement("button"); + document.body.appendChild(outside); + + try { + outside.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, button: 0 }), + ); + outside.focus(); + await flushPromises(); + await new Promise((resolve) => setTimeout(resolve, 20)); + await flushPromises(); + + expect(picker()).toBeNull(); + expect(hubHeld()).toBe(false); + expect(document.activeElement).not.toBe(wrapper.get(TRIGGER).element); + } finally { + outside.remove(); + } + }); + + 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..0f783be2 --- /dev/null +++ b/tests/components/ChatMessageReactions.spec.ts @@ -0,0 +1,190 @@ +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(); + }); + + it("keeps a held pill amber without a hover once it can't be toggled", async () => { + const wrapper = await mountPills( + { heart: [ME] }, + { permissions: { canReact: false, canAddReaction: false } }, + ); + + const classes = pills(wrapper)[0].classes(); + + expect(classes).toContain("!text-[hsl(var(--tac-amber))]"); + expect(classes.some((name) => name.startsWith("hover:"))).toBe(false); + }); + + 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..53775d31 100644 --- a/tests/utilities/chatErrors.spec.ts +++ b/tests/utilities/chatErrors.spec.ts @@ -129,3 +129,34 @@ 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"], + ["timeout", "chat.react_timeout"], + ])("describes %s", (code, description) => { + expect(chatErrorDescription({ code, action: "react" }, t)).toBe( + description, + ); + }); + + it.each(["not_allowed", "invalid", "offline"])( + "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..27fc3d4d 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,70 @@ 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("takes only the reactions from an unedited line that changed live", () => { + const merged = mergeChatSnapshot( + [{ ...line("a", 1), reactions: { fire: ["76561198000000003"] } }], + [{ ...line("a", 1), reactions: {} }], + new Set(), + new Set(["a"]), + ); + + expect(merged).toStrictEqual([ + { ...line("a", 1), reactions: { fire: ["76561198000000003"] } }, + ]); + }); + + 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 +371,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..f74568f2 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,263 @@ 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("sends a toggle once while the same one is on its way", async () => { + const first = socket.react("match", "match-1", "message-2", "heart"); + const again = socket.react("match", "match-1", "message-2", "heart"); + void socket.react("match", "match-1", "message-2", "fire").catch(() => {}); + + expect( + sentReacts(send).map(({ messageId, reaction }) => [messageId, reaction]), + ).toEqual([ + ["message-2", "heart"], + ["message-2", "fire"], + ]); + await expect(again).resolves.toBeUndefined(); + + const [{ requestId }] = sentReacts(send); + socket.emit("chat:ack", { + requestId, + messageId: "message-2", + action: "react", + }); + await first; + + void socket.react("match", "match-1", "message-2", "heart").catch(() => {}); + expect(sentReacts(send)).toHaveLength(3); + }); + + it("sends a toggle again once the last one failed", async () => { + const first = socket.react("match", "match-1", "message-2", "sad"); + const [{ requestId }] = sentReacts(send); + + socket.emit("chat:error", { + code: "rate_limited", + action: "react", + requestId, + }); + await expect(first).rejects.toMatchObject({ code: "rate_limited" }); + + void socket.react("match", "match-1", "message-2", "sad").catch(() => {}); + expect(sentReacts(send)).toHaveLength(2); + }); + + 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..aea66ee5 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,21 @@ export function chatErrorDescription( } } + if (chatErrorAction(error) === "react") { + switch (error?.code) { + case "timeout": + return t("chat.react_timeout"); + 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..72c473e5 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,37 @@ 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 live = new Map(); for (const message of current) { if (message?.id && message.edited_at) { edited.set(message.id, message); } + if (message?.id && reacted.has(message.id)) { + live.set(message.id, message); + } } - const history = (snapshot || []).map((message) => - keepNewerEdit(message, message?.id ? edited.get(message.id) : undefined), - ); + const history = (snapshot || []).map((message) => { + const id = message?.id; + + return keepLiveReactions( + keepNewerEdit(message, id ? edited.get(id) : undefined), + id ? live.get(id) : undefined, + ); + }); const snapshotKeys = new Set(history.map(chatMessageKey)); const newest = history.reduce( @@ -114,6 +130,17 @@ function keepNewerEdit( return kept; } +function keepLiveReactions( + snapshot: LobbyMessage, + held: LobbyMessage | undefined, +) { + if (!held) { + return snapshot; + } + + return { ...snapshot, reactions: held.reactions }; +} + export function insertChatMessage( current: LobbyMessage[], message: LobbyMessage, @@ -225,3 +252,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..94680086 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,7 @@ export interface ChatMessagePermissionInput { viewerSteamId?: string | null; viewerGagged?: boolean; canModerate: boolean; + canPost?: boolean; roomType: ChatType | string; now?: number; } @@ -30,16 +34,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 +87,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..5aa34ebc 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[]; @@ -123,6 +134,7 @@ export class Socket extends EventEmitter { private lobbies: Map = new Map(); private instanceCounter = 0; private pendingRequests: Map = new Map(); + private reactionsInFlight = new Set(); // The api answers a request it failed to carry out with nothing at all. private static readonly REQUEST_TIMEOUT_MS = 8000; // How long a request that timed out still recognises its answer, so a slow @@ -356,6 +368,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 +505,45 @@ 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, + // so a second click before then would toggle it straight back: a toggle + // already on its way is not sent again. + public react( + type: ChatType, + id: string, + messageId: string, + reaction: ChatReaction, + ): Promise { + const lobbyId = `${type}:${id}`; + const toggle = `${lobbyId}:${messageId}:${reaction}`; + + if (this.reactionsInFlight.has(toggle)) { + return Promise.resolve(); + } + + const request = this.chatRequest( + "react", + { id, type, messageId, reaction }, + { + resolved: () => {}, + rejected: (error) => { + if (error?.code === "not_found") { + this.removeMessageFromLobby(lobbyId, messageId); + } + }, + }, + ); + + this.reactionsInFlight.add(toggle); + const settle = () => { + this.reactionsInFlight.delete(toggle); + }; + request.then(settle, settle); + + return request; + } + // 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 +714,7 @@ export class Socket extends EventEmitter { messages: shallowRef([]), seen: new Set(), deleted: new Set(), + reacted: new Set(), callbacks: new Map(), listeners: [], }; @@ -709,6 +765,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 +787,7 @@ export class Socket extends EventEmitter { lobby.messages.value, messages, lobby.deleted, + lobby.reacted, ); lobby.seen.clear(); @@ -763,6 +829,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) {