From 6e2fd3020516dc37236421ec463755c9e9734a37 Mon Sep 17 00:00:00 2001 From: feruzm Date: Sat, 24 Oct 2026 09:26:50 +0300 Subject: [PATCH 1/5] Ask Esie from the navbar and chats, mark official chat accounts, show who is typing Esie, the AI helper, answers in chat, so every way in is for logged-in users only: - an Ask Esie button next to the docs icon in the navbar, with Esie's face - an Ask Esie item after FAQ in the sidebar - Esie pinned on top of the chat list, so it can be asked before any conversation exists Messages to Esie do not count toward the DM fan-out limit. The ?dm= link now starts a conversation again when followed a second time, since the chat list stays mounted. Esie and @ecency get an Official label in the chat list, the conversation header, message headers and the profile card. The label comes from the real chat account, never from post props. Someone else's display name that reads as Esie or Ecency (spaced out, digits, Cyrillic or Greek lookalike letters) is shown as their @username instead, in names, group titles, mentions and search. Typing: someone typing who has no message on the loaded page yet, such as Esie answering the first question, was dropped from the indicator; they are now looked up and named. Typing is sent from the first keystroke instead of after a pause, and cleared when the message arrives. --- .../public/assets/esie/avatar.3ef26f1f.svg | 1 + .../_components/profile-card/index.tsx | 2 + .../channels/[channelId]/posts/route.ts | 8 +- .../app/chats/_components/chats-client.tsx | 64 ++++++-- .../chat/components/channel-header.tsx | 9 +- .../chat/components/mention-token.tsx | 7 +- .../chat/components/message-input.tsx | 9 +- .../features/chat/components/message-item.tsx | 7 +- .../chat/components/official-badge.tsx | 30 ++++ apps/web/src/features/chat/format-utils.ts | 29 ++-- .../chat/hooks/use-channel-metadata.ts | 4 +- .../chat/hooks/use-channel-websocket.ts | 45 ++++-- .../features/chat/mattermost-channel-view.tsx | 15 +- .../src/features/chat/official-accounts.ts | 75 +++++++++ apps/web/src/features/i18n/locales/en-US.json | 5 + .../shared/navbar/navbar-ask-esie.tsx | 43 +++++ .../features/shared/navbar/navbar-desktop.tsx | 2 + .../shared/navbar/navbar-main-sidebar.tsx | 8 + .../features/ui/esie/esie-moods.generated.ts | 1 + apps/web/src/server/mattermost.ts | 27 ++++ .../api/mattermost-chat-recipients.spec.ts | 49 ++++++ .../api/mattermost-dm-fanout-ordering.spec.ts | 26 +++ .../chat/chats-client-esie-pinned.spec.tsx | 148 ++++++++++++++++++ .../features/chat/official-accounts.spec.ts | 68 ++++++++ .../use-channel-websocket-typing.spec.tsx | 113 +++++++++++++ .../features/shared/navbar-ask-esie.spec.tsx | 58 +++++++ scripts/esie/build.mjs | 6 +- 27 files changed, 800 insertions(+), 59 deletions(-) create mode 100644 apps/web/public/assets/esie/avatar.3ef26f1f.svg create mode 100644 apps/web/src/features/chat/components/official-badge.tsx create mode 100644 apps/web/src/features/chat/official-accounts.ts create mode 100644 apps/web/src/features/shared/navbar/navbar-ask-esie.tsx create mode 100644 apps/web/src/specs/features/chat/chats-client-esie-pinned.spec.tsx create mode 100644 apps/web/src/specs/features/chat/official-accounts.spec.ts create mode 100644 apps/web/src/specs/features/chat/use-channel-websocket-typing.spec.tsx create mode 100644 apps/web/src/specs/features/shared/navbar-ask-esie.spec.tsx diff --git a/apps/web/public/assets/esie/avatar.3ef26f1f.svg b/apps/web/public/assets/esie/avatar.3ef26f1f.svg new file mode 100644 index 0000000000..deb93cd1ef --- /dev/null +++ b/apps/web/public/assets/esie/avatar.3ef26f1f.svg @@ -0,0 +1 @@ + diff --git a/apps/web/src/app/(dynamicPages)/profile/[username]/_components/profile-card/index.tsx b/apps/web/src/app/(dynamicPages)/profile/[username]/_components/profile-card/index.tsx index 7248244b34..bb3f245e76 100644 --- a/apps/web/src/app/(dynamicPages)/profile/[username]/_components/profile-card/index.tsx +++ b/apps/web/src/app/(dynamicPages)/profile/[username]/_components/profile-card/index.tsx @@ -36,6 +36,7 @@ import { profileText } from "./profile-text"; import { FinalizeCommunityBanner } from "../finalize-community-banner"; import { useActiveAccount } from "@/core/hooks"; import { ProBadge } from "@/features/pro"; +import { OfficialBadge } from "@/features/chat/components/official-badge"; import { ComposeDigestButton, DigestSubscribeButton, NewsletterGate, SenderStatusNotice, SentIssues, SubscriberCount } from "@/features/newsletter"; interface Props { @@ -116,6 +117,7 @@ export function ProfileCard({ account }: Props) {
{displayName ?? account.name} +
@{account.name} diff --git a/apps/web/src/app/api/mattermost/channels/[channelId]/posts/route.ts b/apps/web/src/app/api/mattermost/channels/[channelId]/posts/route.ts index c4d441c335..f73eabcecc 100644 --- a/apps/web/src/app/api/mattermost/channels/[channelId]/posts/route.ts +++ b/apps/web/src/app/api/mattermost/channels/[channelId]/posts/route.ts @@ -7,6 +7,7 @@ import { handleMattermostError, MattermostChannel, getMattermostTokenFromCookies, + getDmFanoutExemptUserIds, getPrivateChannelRecipientIds, mmUserFetch, toPublicChatUserMap, @@ -412,8 +413,13 @@ export async function POST( // // A post into a group reaches every member, so every other member is // counted as a recipient, the same as if each had been messaged directly. + // Esie, the AI helper, is never counted: asking it for help must not use up the budget. if (channel.type === "D" || channel.type === "G") { - const recipients = await getPrivateChannelRecipientIds(channel, currentUser.id, token); + const [exempt, allRecipients] = await Promise.all([ + getDmFanoutExemptUserIds(), + getPrivateChannelRecipientIds(channel, currentUser.id, token) + ]); + const recipients = allRecipients.filter((id) => !exempt.has(id)); const fanout = await checkDmFanout({ userId: currentUser.id, recipients, diff --git a/apps/web/src/app/chats/_components/chats-client.tsx b/apps/web/src/app/chats/_components/chats-client.tsx index 5a6c21d88f..5b2248c650 100644 --- a/apps/web/src/app/chats/_components/chats-client.tsx +++ b/apps/web/src/app/chats/_components/chats-client.tsx @@ -37,6 +37,10 @@ import { ChannelAvatar } from "@/features/chat/components/channel-avatar"; import { NewGroupModal } from "@/features/chat/components/new-group-modal"; import { NewPrivateChannelModal } from "@/features/chat/components/private-channel-modals"; import { sortChannelsByActivity } from "@/features/chat/channel-sort"; +import { OfficialBadge } from "@/features/chat/components/official-badge"; +import { getUserDisplayName } from "@/features/chat/format-utils"; +import { ASK_ESIE_HREF, ESIE_USERNAME, safeChatName } from "@/features/chat/official-accounts"; +import { ESIE_MOODS } from "@ui/esie/esie-moods.generated"; import { getGroupTitle, isConversationChannel, @@ -108,18 +112,7 @@ export function ChatsClient() { return new Map((channels?.channels || []).map((channel) => [channel.id, channel])); }, [channels?.channels]); - const getDirectUserDisplayName = useCallback((user?: MattermostUser | null) => { - if (!user) return undefined; - - const fullName = [user.first_name, user.last_name].filter(Boolean).join(" "); - if (fullName) return fullName; - - if (user.nickname) return user.nickname; - - if (user.username) return `@${user.username}`; - - return undefined; - }, []); + const getDirectUserDisplayName = getUserDisplayName; const filteredChannels = useMemo(() => { if (!channels?.channels) return []; @@ -179,6 +172,15 @@ export function ChatsClient() { () => sortChannelsByActivity(channels?.channels || [], getUnreadCount, channelOrder), [channelOrder, channels?.channels, getUnreadCount] ); + // Esie is pinned on top, so it is reachable before any conversation with it exists + const esieChannel = useMemo( + () => sortedChannels.find((c) => c.type === "D" && c.directUser?.username === ESIE_USERNAME), + [sortedChannels] + ); + const listedChannels = useMemo( + () => (esieChannel ? sortedChannels.filter((c) => c !== esieChannel) : sortedChannels), + [esieChannel, sortedChannels] + ); const searchedChannels = useMemo( () => sortChannelsByActivity(filteredChannels, getUnreadCount, channelOrder), [channelOrder, filteredChannels, getUnreadCount] @@ -196,6 +198,8 @@ export function ChatsClient() { // Auto-start DM when navigating from profile (?dm=username) const dmInitiated = useRef(null); useEffect(() => { + // the list stays mounted across chats, so a later link to the same person must start again + if (!dmTarget) dmInitiated.current = null; if (dmTarget && bootstrap?.ok && isOnScreen() && dmInitiated.current !== dmTarget) { dmInitiated.current = dmTarget; directChannelMutation.mutate(dmTarget, { @@ -294,7 +298,8 @@ export function ChatsClient() { if (isGroupChannel(channel)) { return ( - channel.group_name || getGroupTitle(channel.groupUsers, channel.display_name || channel.name) + safeChatName(channel.group_name) || + getGroupTitle(channel.groupUsers, channel.display_name || channel.name) ); } @@ -429,6 +434,7 @@ export function ChatsClient() {
{getChannelTitle(channel)} + {channel.type === "D" && } {channel.is_favorite && (
- {sortedChannels.map(renderChannelRow)} - {!sortedChannels.length && !channelsLoading && ( + {esieChannel ? ( + renderChannelRow(esieChannel) + ) : ( + + +
+
+ {i18next.t("chat.esie-title")} + +
+
{i18next.t("chat.esie-subtitle")}
+
+ + )} + {listedChannels.map(renderChannelRow)} + {!listedChannels.length && !channelsLoading && (
{i18next.t("chat.no-channels")}
)}
@@ -738,7 +770,7 @@ export function ChatsClient() { {!userSearchLoading && discoverableUsers.map((user) => { const fullName = [user.first_name, user.last_name].filter(Boolean).join(" "); - const secondary = user.nickname || fullName; + const secondary = safeChatName(user.nickname || fullName, user.username); return (
-
{channelTitle}
+
+ {channelTitle} + +
{channelSubtitle} diff --git a/apps/web/src/features/chat/components/mention-token.tsx b/apps/web/src/features/chat/components/mention-token.tsx index 51ece86ad6..4ac95ea7b0 100644 --- a/apps/web/src/features/chat/components/mention-token.tsx +++ b/apps/web/src/features/chat/components/mention-token.tsx @@ -7,6 +7,7 @@ import { } from "@ui/dropdown"; import { blogSvg, mailSvg } from "@ui/svg"; import type { MattermostUser } from "../mattermost-api"; +import { safeChatName } from "../official-accounts"; interface MentionTokenProps { username: string; @@ -21,8 +22,10 @@ export function MentionToken({ currentUsername, onStartDm }: MentionTokenProps) { - const secondary = - [user?.first_name, user?.last_name].filter(Boolean).join(" ") || user?.nickname; + const secondary = safeChatName( + [user?.first_name, user?.last_name].filter(Boolean).join(" ") || user?.nickname, + user?.username + ); const isSelf = currentUsername ? username.toLowerCase() === currentUsername.toLowerCase() : false; diff --git a/apps/web/src/features/chat/components/message-input.tsx b/apps/web/src/features/chat/components/message-input.tsx index 0ba6a229e5..afa3583bd0 100644 --- a/apps/web/src/features/chat/components/message-input.tsx +++ b/apps/web/src/features/chat/components/message-input.tsx @@ -9,7 +9,7 @@ import { GifPicker } from "@ui/gif-picker"; import clsx from "clsx"; import type { MattermostPost, MattermostUser } from "../mattermost-api"; import type { EmojiSuggestion } from "../emoji-utils"; -import { isChannelNoticePost } from "../format-utils"; +import { getUserDisplayName, isChannelNoticePost } from "../format-utils"; type GifPickerStyle = { width: string; @@ -540,11 +540,8 @@ export function MessageInput({
@{user.username} - {(user.nickname || user.first_name || user.last_name) && ( - - {[user.first_name, user.last_name].filter(Boolean).join(" ") || - user.nickname} - + {getUserDisplayName(user) !== `@${user.username}` && ( + {getUserDisplayName(user)} )}
diff --git a/apps/web/src/features/chat/components/message-item.tsx b/apps/web/src/features/chat/components/message-item.tsx index fa3cb19806..5908d5e550 100644 --- a/apps/web/src/features/chat/components/message-item.tsx +++ b/apps/web/src/features/chat/components/message-item.tsx @@ -10,7 +10,8 @@ import { DropdownToggle } from "@ui/dropdown"; import { Popover, PopoverContent } from "@ui/popover"; -import { formatRelativeTime, getAvatarUrl, isChannelNoticePost } from "../format-utils"; +import { formatRelativeTime, getAvatarUrl, isChannelNoticePost, isOfficialPostAuthor } from "../format-utils"; +import { OfficialBadge } from "./official-badge"; import { getNativeEmojiFromShortcode } from "../emoji-utils"; import { resolveReplyPreview } from "./reply-preview"; import { MessageTranslate } from "./message-translate"; @@ -70,11 +71,13 @@ const UsernameActions = memo(function UsernameActions({ username, displayName, currentUsername, + official, onStartDm }: { username: string; displayName: string; currentUsername?: string; + official: boolean; onStartDm: (username: string) => void; }) { const isSelf = currentUsername @@ -90,6 +93,7 @@ const UsernameActions = memo(function UsernameActions({ > {displayName} + {official && } @@ -300,6 +304,7 @@ function MessageItemInner({ username={username} displayName={displayName} currentUsername={activeUser?.username} + official={isOfficialPostAuthor(post, usersById)} onStartDm={startDirectMessage} /> ); diff --git a/apps/web/src/features/chat/components/official-badge.tsx b/apps/web/src/features/chat/components/official-badge.tsx new file mode 100644 index 0000000000..17ab33534c --- /dev/null +++ b/apps/web/src/features/chat/components/official-badge.tsx @@ -0,0 +1,30 @@ +import clsx from "clsx"; +import i18next from "i18next"; +import { isOfficialChatAccount } from "../official-accounts"; + +interface Props { + username?: string | null; + className?: string; +} + +/** + * Marks an account Ecency runs (Esie, @ecency), so people can tell it apart from lookalikes. + * A text label, not a seal, so it never reads as the Pro badge. + */ +export function OfficialBadge({ username, className }: Props) { + if (!isOfficialChatAccount(username)) return null; + + const label = i18next.t("chat.official-badge"); + return ( + + {label} + + ); +} diff --git a/apps/web/src/features/chat/format-utils.ts b/apps/web/src/features/chat/format-utils.ts index f9346340ca..2e60a2f7f4 100644 --- a/apps/web/src/features/chat/format-utils.ts +++ b/apps/web/src/features/chat/format-utils.ts @@ -1,6 +1,7 @@ import i18next from "i18next"; import { dateToFullRelative, dateToFormatted } from "@/utils/parse-date"; import type { MattermostPost, MattermostUser } from "./mattermost-api"; +import { impersonatesOfficialAccount, isOfficialChatAccount } from "./official-accounts"; /** * Format timestamp to short format (e.g., "Jan 1, 12:00 PM") @@ -21,15 +22,14 @@ export function formatRelativeTime(timestamp: number): string { } /** - * Get display name for a user (full name > nickname > @username) + * Get display name for a user (full name > nickname > @username). A name posing as an official + * account (see impersonatesOfficialAccount) is replaced by the @username. */ -export function getUserDisplayName(user?: MattermostUser): string | undefined { +export function getUserDisplayName(user?: MattermostUser | null): string | undefined { if (!user) return undefined; - const fullName = [user.first_name, user.last_name].filter(Boolean).join(" "); - if (fullName) return fullName; - - if (user.nickname) return user.nickname; + const name = [user.first_name, user.last_name].filter(Boolean).join(" ") || user.nickname; + if (name && !impersonatesOfficialAccount(name, user.username)) return name; if (user.username) return `@${user.username}`; @@ -64,12 +64,8 @@ export function getPostDisplayName( const user = usersById[post.user_id]; if (user) { - const fullName = [user.first_name, user.last_name].filter(Boolean).join(" "); - if (fullName) return fullName; - - if (user.nickname) return user.nickname; - - if (user.username) return `@${user.username}`; + const name = getUserDisplayName(user); + if (name) return name; } const fallbackUsername = @@ -81,6 +77,15 @@ export function getPostDisplayName( return post.user_id || "Unknown user"; } +/** + * True only for a post by the official account's own chat user. Never from post props: a webhook + * or bot post can name any user there. + */ +export function isOfficialPostAuthor(post: MattermostPost, usersById: Record): boolean { + if (post.type?.startsWith("system_") || post.props?.override_username || post.props?.from_webhook) return false; + return isOfficialChatAccount(usersById[post.user_id]?.username); +} + /** * Get username from a post (handles bot posts) */ diff --git a/apps/web/src/features/chat/hooks/use-channel-metadata.ts b/apps/web/src/features/chat/hooks/use-channel-metadata.ts index 41deaac2b4..c28cea8330 100644 --- a/apps/web/src/features/chat/hooks/use-channel-metadata.ts +++ b/apps/web/src/features/chat/hooks/use-channel-metadata.ts @@ -3,6 +3,7 @@ import i18next from "i18next"; import type { MattermostPostsResponse, MattermostUser } from "../mattermost-api"; import { getUserDisplayName } from "../format-utils"; import { getGroupTitle } from "../group-utils"; +import { safeChatName } from "../official-accounts"; interface UseChannelMetadataParams { channelId: string; @@ -67,7 +68,8 @@ export function useChannelMetadata({ if (displayName) return displayName; } - if (groupName) return groupName; + const safeGroupName = safeChatName(groupName); + if (safeGroupName) return safeGroupName; if (groupUsers?.length) { return getGroupTitle(groupUsers, i18next.t("chat.group")); diff --git a/apps/web/src/features/chat/hooks/use-channel-websocket.ts b/apps/web/src/features/chat/hooks/use-channel-websocket.ts index 95431139f7..cb08caabbe 100644 --- a/apps/web/src/features/chat/hooks/use-channel-websocket.ts +++ b/apps/web/src/features/chat/hooks/use-channel-websocket.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { QueryClient } from "@tanstack/react-query"; import type { MattermostPost, MattermostPostsResponse } from "../mattermost-api"; import { MattermostWebSocket } from "../mattermost-websocket"; @@ -30,6 +30,7 @@ interface UseChannelWebSocketParams { } const TYPING_TIMEOUT = 5000; +const TYPING_RECONNECT_INTERVAL = 5000; export function useChannelWebSocket({ channelId, @@ -90,6 +91,13 @@ export function useChannelWebSocket({ setTypingUsers(prev => new Map(prev).set(userId, Date.now())); }) .onPosted((post) => { + // their message is here, so they are no longer typing it + setTypingUsers((prev) => { + if (!prev.has(post.user_id)) return prev; + const next = new Map(prev); + next.delete(post.user_id); + return next; + }); const refs = pendingPostRefsRef.current; const currentUserId = getCurrentUserIdRef.current(); const pendingPostId = post.pending_post_id; @@ -162,6 +170,13 @@ export function useChannelWebSocket({ return () => clearInterval(interval); }, []); + // Someone typing who has no message on the loaded page (Esie answering the first question in a + // new DM) is unknown here: the caller looks them up, until then they cannot be named + const unknownTypingUserIds = useMemo( + () => Array.from(typingUsers.keys()).filter((userId) => !usersById[userId]).sort(), + [typingUsers, usersById] + ); + // Compute typing usernames for display const typingUsernames = useMemo(() => { return Array.from(typingUsers.keys()) @@ -171,23 +186,25 @@ export function useChannelWebSocket({ .slice(0, 3); }, [typingUsers, usersById]); - // Debounced typing sender with auto-reconnect - const sendTypingDebounced = useMemo(() => { - let timeoutId: NodeJS.Timeout | null = null; - return () => { - if (timeoutId) clearTimeout(timeoutId); - timeoutId = setTimeout(() => { - const ws = websocketRef.current; - if (ws) { - if (!isWebSocketActive) ws.connect(); - ws.sendTyping(channelId); - } - }, 500); - }; + // Sent at once: sendTyping already sends at most one event every few seconds, while waiting for a + // pause meant someone typing steadily never showed as typing to the other side. + // Typing into a dropped connection retries it, but not on every keystroke. + const lastReconnectKickRef = useRef(0); + const sendTypingDebounced = useCallback(() => { + const ws = websocketRef.current; + if (!ws) return; + if (!isWebSocketActive) { + const now = Date.now(); + if (now - lastReconnectKickRef.current < TYPING_RECONNECT_INTERVAL) return; + lastReconnectKickRef.current = now; + ws.connect(); + } + ws.sendTyping(channelId); }, [channelId, isWebSocketActive]); return { typingUsernames, + unknownTypingUserIds, sendTypingDebounced, websocketRef }; diff --git a/apps/web/src/features/chat/mattermost-channel-view.tsx b/apps/web/src/features/chat/mattermost-channel-view.tsx index 0fa1cbcbed..d93b5f113b 100644 --- a/apps/web/src/features/chat/mattermost-channel-view.tsx +++ b/apps/web/src/features/chat/mattermost-channel-view.tsx @@ -326,16 +326,17 @@ export function MattermostChannelView({ channelId }: Props) { [posts, threadData?.posts, usersById] ); const { data: resolvedUsers } = useMattermostUsersByIds(missingUserIds); - useEffect(() => { - if (!resolvedUsers?.length) return; + const addExtraUsers = useCallback((users: MattermostUser[] | undefined) => { + if (!users?.length) return; setExtraUsers((current) => { const next = { ...current }; - resolvedUsers.forEach((user) => { + users.forEach((user) => { next[user.id] = user; }); return next; }); - }, [resolvedUsers]); + }, []); + useEffect(() => addExtraUsers(resolvedUsers), [addExtraUsers, resolvedUsers]); const channelData = useMemo(() => data?.pages?.[0], [data?.pages]); @@ -423,6 +424,7 @@ export function MattermostChannelView({ channelId }: Props) { const { typingUsernames, + unknownTypingUserIds, sendTypingDebounced } = useChannelWebSocket({ channelId, @@ -457,6 +459,9 @@ export function MattermostChannelView({ channelId }: Props) { setReconnectAttempt, setReconnectDelay }); + // name someone typing before they have a message on the page + const { data: resolvedTypingUsers } = useMattermostUsersByIds(unknownTypingUserIds); + useEffect(() => addExtraUsers(resolvedTypingUsers), [addExtraUsers, resolvedTypingUsers]); const { deletingPostId, @@ -484,6 +489,7 @@ export function MattermostChannelView({ channelId }: Props) { }, [handlePinToggleRaw]); const { + directChannelUser, channelTitle, channelSubtitle, onlineUsers, @@ -1101,6 +1107,7 @@ export function MattermostChannelView({ channelId }: Props) {
= { + а: "a", е: "e", ё: "e", о: "o", с: "c", у: "y", ү: "y", і: "i", ї: "i", ј: "j", ѕ: "s", ӏ: "i", к: "k", + п: "n", р: "p", х: "x", һ: "h", ԁ: "d", + α: "a", ε: "e", ο: "o", ι: "i", ν: "v", κ: "k", ς: "s", ϲ: "c", γ: "y", + ı: "i", ɩ: "i", ɛ: "e", ᴇ: "e", ᴄ: "c", ɴ: "n", ʏ: "y", ꜱ: "s", + "¢": "c", "0": "o", "1": "i", "!": "i", "|": "i", l: "i", "3": "e", "4": "a", "@": "a", "5": "s", $: "s" +}; + +// A letter or digit still unknown after the mapping above matches any one letter +const ANY = "*"; + +/** The name's words in Latin letters. A run of single letters ("E s i e", "E.s.i.e") is one word. */ +function nameWords(text: string): string[] { + // the table first: decomposing can turn a lookalike into something else (lunate ϲ into ς) + const latin = Array.from(text.toLowerCase()) + .flatMap((ch) => LOOKALIKES[ch] ?? Array.from(ch.normalize("NFKD").replace(/\p{M}/gu, ""))) + .map((ch) => LOOKALIKES[ch] ?? ch) + .map((ch) => (/[a-z]/.test(ch) ? ch : /[\p{L}\p{N}]/u.test(ch) ? ANY : " ")) + .join(""); + const words: string[] = []; + let letters = ""; + for (const word of latin.split(" ").filter(Boolean)) { + if (word.length === 1) { + letters += word; + continue; + } + if (letters) words.push(letters); + letters = ""; + words.push(word); + } + if (letters) words.push(letters); + return words; +} + +// A word starting with the official name, allowing one letter we could not read +const startsLike = (word: string, official: string) => { + if (word.length < official.length) return false; + let unknown = 0; + for (let i = 0; i < official.length; i++) { + if (word[i] === ANY) unknown++; + else if (word[i] !== official[i]) return false; + } + return unknown <= 1; +}; + +/** + * True when someone else's display name reads as an official account ("Esie", "Ecency Support", + * "E-s-i-e", "Еcency" with a Cyrillic Е), so chat shows their @username instead and the badge + * stays the only mark of the real one. A word has to start with the name: "Decency" and + * "Jane Siegel" are left alone. + */ +export function impersonatesOfficialAccount(displayName?: string | null, username?: string | null): boolean { + if (!displayName || isOfficialChatAccount(username)) return false; + const words = nameWords(displayName); + return OFFICIAL_CHAT_ACCOUNTS.some((official) => words.some((word) => startsLike(word, official))); +} + +/** A name someone chose, or nothing when it poses as an official account. */ +export function safeChatName(name?: string | null, username?: string | null): string | undefined { + return name && !impersonatesOfficialAccount(name, username) ? name : undefined; +} diff --git a/apps/web/src/features/i18n/locales/en-US.json b/apps/web/src/features/i18n/locales/en-US.json index 13cf1fc8be..38a2b06b97 100644 --- a/apps/web/src/features/i18n/locales/en-US.json +++ b/apps/web/src/features/i18n/locales/en-US.json @@ -368,6 +368,7 @@ "filter-with-reblog": "Include reblog" }, "navbar": { + "ask-esie": "Ask Esie", "discover": "Discover", "decks": "Decks", "raidstead": "Raidstead", @@ -2624,6 +2625,10 @@ "dm-warning-body": "Be cautious with messages from unknown users. Do not click on suspicious links or share sensitive information like passwords or private keys. Verify the sender's identity before taking any action.", "dm-warning-settings": "You can update your DM privacy in Settings.", "no-channels": "No channels assigned yet.", + "official-badge": "Official", + "official-badge-title": "An official Ecency account", + "esie-title": "Esie", + "esie-subtitle": "Ecency AI helper, ask anything", "no-channels-search": "No channels match your search.", "joinable-channels": "Joinable channels", "searching-channels": "Searching…", diff --git a/apps/web/src/features/shared/navbar/navbar-ask-esie.tsx b/apps/web/src/features/shared/navbar/navbar-ask-esie.tsx new file mode 100644 index 0000000000..bf2092b907 --- /dev/null +++ b/apps/web/src/features/shared/navbar/navbar-ask-esie.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useHydrated } from "@/api/queries"; +import { useActiveAccount } from "@/core/hooks/use-active-account"; +import { ASK_ESIE_HREF } from "@/features/chat/official-accounts"; +import { ESIE_MOODS } from "@ui/esie/esie-moods.generated"; +import { Tooltip } from "@ui/tooltip"; +import i18next from "i18next"; +import Link from "next/link"; + +/** + * Opens the chat with Esie, the AI helper. Esie answers in chat, which needs a login, + * so logged-out visitors do not see it. + */ +export function NavbarAskEsie() { + const { activeUser } = useActiveAccount(); + const hydrated = useHydrated(); + + if (!hydrated || !activeUser) return null; + + const label = i18next.t("navbar.ask-esie"); + return ( + + + + {label} + + + ); +} diff --git a/apps/web/src/features/shared/navbar/navbar-desktop.tsx b/apps/web/src/features/shared/navbar/navbar-desktop.tsx index 037d127d48..3484b18d69 100644 --- a/apps/web/src/features/shared/navbar/navbar-desktop.tsx +++ b/apps/web/src/features/shared/navbar/navbar-desktop.tsx @@ -14,6 +14,7 @@ import { classNameObject } from "@ui/util"; import i18next from "i18next"; import { useEffect, useState } from "react"; import { NavbarTextMenu } from "./navbar-text-menu"; +import { NavbarAskEsie } from "./navbar-ask-esie"; import { useHydrated } from "@/api/queries"; import { useMattermostUnread } from "@/features/chat/mattermost-api"; import { useActiveAccount } from "@/core/hooks/use-active-account"; @@ -82,6 +83,7 @@ export function NavbarDesktop({ aria-label={i18next.t("entry-index.faq")} /> + {(step !== 1 || transparentVerify) && ( // Slot is always rendered so it reserves its flex space (no layout // shift when Search mounts). Only the heavy Search itself is gated on diff --git a/apps/web/src/features/shared/navbar/navbar-main-sidebar.tsx b/apps/web/src/features/shared/navbar/navbar-main-sidebar.tsx index 190ee75163..4d0678b12f 100644 --- a/apps/web/src/features/shared/navbar/navbar-main-sidebar.tsx +++ b/apps/web/src/features/shared/navbar/navbar-main-sidebar.tsx @@ -30,6 +30,7 @@ import defaults from "@/defaults"; import { useHydrated } from "@/api/queries"; import { useMattermostUnread } from "@/features/chat/mattermost-api"; import { useActiveAccount } from "@/core/hooks/use-active-account"; +import { ASK_ESIE_HREF } from "@/features/chat/official-accounts"; // The full search experience (suggester + transfer/gallery/bookmarks graph) is // heavy and only appears inside the opened main sidebar, so load it as a @@ -215,6 +216,13 @@ export function NavbarMainSidebar({ show, setShow, setStepOne }: Props) { to="https://docs.ecency.com" onClick={() => setShow(false)} /> + {hydrated && activeUser && ( + setShow(false)} + /> + )} (); +const dmFanoutExemptMissingAt = new Map(); +const DM_FANOUT_EXEMPT_RECHECK_MS = 10 * 60_000; + +/** + * Chat user ids that never count toward the sender's DM fan-out limit: Esie, the AI helper, + * which anyone may ask at any time. A failed lookup is retried on the next call, and until it + * succeeds Esie simply counts like anyone else. + */ +export async function getDmFanoutExemptUserIds(): Promise> { + for (const username of [ESIE_USERNAME]) { + if (dmFanoutExemptIds.has(username)) continue; + if (Date.now() - (dmFanoutExemptMissingAt.get(username) ?? 0) < DM_FANOUT_EXEMPT_RECHECK_MS) continue; + try { + const user = await lookupMattermostUser(username); + if (user?.id) dmFanoutExemptIds.set(username, user.id); + else dmFanoutExemptMissingAt.set(username, Date.now()); + } catch { + // counted normally this time + } + } + return new Set(dmFanoutExemptIds.values()); +} + export async function getMattermostUserWithProps(userId: string, signal?: AbortSignal): Promise { return await mmFetch(`/users/${encodeURIComponent(userId)}`, { headers: getAdminHeaders(), diff --git a/apps/web/src/specs/api/mattermost-chat-recipients.spec.ts b/apps/web/src/specs/api/mattermost-chat-recipients.spec.ts index d6fee997a8..2c10f01e0b 100644 --- a/apps/web/src/specs/api/mattermost-chat-recipients.spec.ts +++ b/apps/web/src/specs/api/mattermost-chat-recipients.spec.ts @@ -131,3 +131,52 @@ describe("collectPostUserIds", () => { expect(ids.sort()).toEqual(["a", "b", "c", "d"]); }); }); + +describe("getDmFanoutExemptUserIds", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("looks Esie up once, and retries after a failed lookup instead of caching it", async () => { + const { getDmFanoutExemptUserIds } = await loadModule(); + fetchMock.mockResolvedValueOnce(resp(503, { message: "down" })); + + expect(await getDmFanoutExemptUserIds()).toEqual(new Set()); + + fetchMock.mockResolvedValueOnce(resp(200, { id: "esie-id", username: "esie" })); + expect(await getDmFanoutExemptUserIds()).toEqual(new Set(["esie-id"])); + expect(await getDmFanoutExemptUserIds()).toEqual(new Set(["esie-id"])); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(String(fetchMock.mock.calls[1][0])).toContain("/users/username/esie"); + }); +}); + +describe("getDmFanoutExemptUserIds on a chat server without Esie", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it("does not look the missing account up on every message", async () => { + vi.useFakeTimers(); + const fetchMock = vi.fn().mockResolvedValue(resp(404, { message: "not found" })); + vi.stubGlobal("fetch", fetchMock); + const { getDmFanoutExemptUserIds } = await loadModule(); + + await getDmFanoutExemptUserIds(); + await getDmFanoutExemptUserIds(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(10 * 60_000 + 1); + await getDmFanoutExemptUserIds(); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/src/specs/api/mattermost-dm-fanout-ordering.spec.ts b/apps/web/src/specs/api/mattermost-dm-fanout-ordering.spec.ts index 701b9955d3..d4ac7f8d34 100644 --- a/apps/web/src/specs/api/mattermost-dm-fanout-ordering.spec.ts +++ b/apps/web/src/specs/api/mattermost-dm-fanout-ordering.spec.ts @@ -9,6 +9,7 @@ const mockMmUserFetch = vi.fn(); const mockCheckDmFanout = vi.fn(); const mockModerationContext = vi.fn(); const mockRecipientIds = vi.fn(); +const mockExemptIds = vi.fn(async () => new Set()); vi.mock("@/server/mattermost", () => ({ CHAT_BAN_PROP: "ecency_chat_banned_until", @@ -17,6 +18,7 @@ vi.mock("@/server/mattermost", () => ({ followMattermostThreadForUser: vi.fn(), getMattermostCommunityModerationContext: (...args: unknown[]) => mockModerationContext(...args), getMattermostTokenFromCookies: () => Promise.resolve("test-token"), + getDmFanoutExemptUserIds: () => mockExemptIds(), getPrivateChannelRecipientIds: (...args: unknown[]) => mockRecipientIds(...args), handleMattermostError: () => ({ status: 500 }), isUserChatBanned: () => null, @@ -134,6 +136,30 @@ describe("posts route — DM fan-out runs last", () => { expect(mockMmUserFetch).toHaveBeenCalledWith("/posts", "test-token", expect.anything()); }); + it("never counts Esie, so asking the AI helper costs no fan-out slot", async () => { + const { POST } = await import("@/app/api/mattermost/channels/[channelId]/posts/route"); + allowFanout(); + mockRecipientIds.mockResolvedValue(["esie-id"]); + mockExemptIds.mockResolvedValueOnce(new Set(["esie-id"])); + + const res = await POST(request("how do I power up?"), params); + + expect(res.status).toBe(200); + expect(mockCheckDmFanout.mock.calls[0][0]).toMatchObject({ recipients: [] }); + }); + + it("still counts everyone else in a group that includes Esie", async () => { + const { POST } = await import("@/app/api/mattermost/channels/[channelId]/posts/route"); + asGroupChannel(); + allowFanout(); + mockRecipientIds.mockResolvedValue(["esie-id", "u-2", "u-3"]); + mockExemptIds.mockResolvedValueOnce(new Set(["esie-id"])); + + await POST(request("hi all"), params); + + expect(mockCheckDmFanout.mock.calls[0][0]).toMatchObject({ recipients: ["u-2", "u-3"] }); + }); + it("rejects with 429 and sends nothing once the cap is reached", async () => { const { POST } = await import("@/app/api/mattermost/channels/[channelId]/posts/route"); mockCheckDmFanout.mockResolvedValue({ diff --git a/apps/web/src/specs/features/chat/chats-client-esie-pinned.spec.tsx b/apps/web/src/specs/features/chat/chats-client-esie-pinned.spec.tsx new file mode 100644 index 0000000000..5f4de2207c --- /dev/null +++ b/apps/web/src/specs/features/chat/chats-client-esie-pinned.spec.tsx @@ -0,0 +1,148 @@ +import { vi } from "vitest"; +import React from "react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { setupModalContainers } from "@/specs/test-utils"; + +const push = vi.fn(); +const replace = vi.fn(); +let routeParams: { id?: string } = {}; +let search = new URLSearchParams(); +let onScreen = true; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push, replace }), + useParams: () => routeParams, + usePathname: () => "/chats", + useSearchParams: () => search +})); +vi.mock("next/link", () => ({ + default: ({ href, children, ...rest }: any) => ( + + {children} + + ) +})); +vi.mock("@/api/queries", () => ({ useHydrated: () => true })); +vi.mock("@/core/hooks/use-active-account", () => ({ + useActiveAccount: () => ({ activeUser: { username: "me" } }) +})); +vi.mock("@/features/shared", () => ({ LoginRequired: () => null })); +vi.mock("@/features/shared/user-avatar", () => ({ UserAvatar: () => null })); +vi.mock("@/features/chat/components/channel-avatar", () => ({ ChannelAvatar: () => null })); +vi.mock("@/features/chat/components/new-group-modal", () => ({ NewGroupModal: () => null })); +vi.mock("@/features/chat/components/private-channel-modals", () => ({ + NewPrivateChannelModal: () => null +})); +vi.mock("@/features/chat/components/chat-ban-screen", () => ({ ChatBanScreen: () => null })); +vi.mock("@/features/chat/chat-ban-notice", () => ({ getChatBanInfo: () => null })); + +const directMutation = { + mutate: vi.fn(), + reset: vi.fn(), + error: null as Error | null, + isPending: false +}; +const joinMutation = { mutate: vi.fn(), reset: vi.fn(), error: null as Error | null, isPending: false }; +const idle = { mutate: vi.fn(), reset: vi.fn(), error: null, isPending: false }; +let bootstrapResult: { data: unknown; isLoading: boolean; error: Error | null } = { + data: { ok: true }, + isLoading: false, + error: null +}; +let channelList: Array> = []; +let foundUsers: Array> = []; + +vi.mock("@/features/chat/mattermost-api", () => ({ + useMattermostBootstrap: () => ({ ...bootstrapResult, refetch: vi.fn() }), + useMattermostChannels: () => ({ + data: { channels: channelList }, + isLoading: false, + error: null + }), + useMattermostChannelSearch: () => ({ data: { channels: [] }, isFetching: false, error: null }), + useMattermostMessageSearch: () => ({ data: { posts: [] }, isFetching: false, error: null }), + useMattermostUserSearch: () => ({ data: { users: foundUsers }, isFetching: false, error: null }), + useMattermostDirectChannel: () => directMutation, + useMattermostJoinChannel: () => joinMutation, + useMattermostFavoriteChannel: () => idle, + useMattermostLeaveChannel: () => idle, + useMattermostMuteChannel: () => idle, + useMattermostMarkChannelViewed: () => idle, + useMattermostUnread: () => ({ data: undefined }) +})); + +import { ChatsClient } from "@/app/chats/_components/chats-client"; + +const dm = (id: string, username: string, last_post_at = 1) => ({ + id, + name: `me__${id}`, + display_name: "", + type: "D", + last_post_at, + directUser: { id: `${username}-id`, username, nickname: "", first_name: "", last_name: "" } +}); + +const rowTitles = (container: HTMLElement) => + Array.from(container.querySelectorAll(".grid.grid-cols-1.gap-1 > *")).map((row) => row.textContent); + +describe("ChatsClient pinned Esie", () => { + beforeEach(() => { + setupModalContainers(); + vi.clearAllMocks(); + routeParams = {}; + search = new URLSearchParams(); + onScreen = true; + bootstrapResult = { data: { ok: true }, isLoading: false, error: null }; + channelList = []; + foundUsers = []; + vi.spyOn(Element.prototype, "getClientRects").mockImplementation( + () => ({ length: onScreen ? 1 : 0 }) as DOMRectList + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("pins a row that starts the conversation before any exists", () => { + channelList = [dm("c-alice", "alice")]; + const { container } = render(); + + const pinned = container.querySelector("[data-chat-esie-pinned]")!; + expect(pinned).toHaveAttribute("href", "/chats?dm=esie"); + expect(pinned).toHaveTextContent("chat.esie-title"); + expect(pinned.querySelector("[data-official-badge]")).toBeInTheDocument(); + expect(rowTitles(container)[0]).toContain("chat.esie-title"); + expect(screen.queryByText("chat.no-channels")).not.toBeInTheDocument(); + }); + + it("keeps the existing Esie conversation on top, listed once", () => { + channelList = [dm("c-alice", "alice", 50), dm("c-esie", "esie", 1)]; + const { container } = render(); + + expect(container.querySelector("[data-chat-esie-pinned]")).not.toBeInTheDocument(); + const links = Array.from(container.querySelectorAll('a[href^="/chats/"]')).map((a) => a.getAttribute("href")); + expect(links).toEqual(["/chats/c-esie", "/chats/c-alice"]); + expect(container.querySelectorAll("[data-official-badge]")).toHaveLength(1); + }); + + it("shows the empty notice under the pinned row when there is nothing else", () => { + render(); + expect(screen.getByText("chat.no-channels")).toBeInTheDocument(); + }); + + it("starts the conversation again on a later link, as the list stays mounted between chats", () => { + search = new URLSearchParams("dm=esie"); + const { rerender } = render(); + expect(directMutation.mutate).toHaveBeenCalledTimes(1); + + search = new URLSearchParams(); + rerender(); + search = new URLSearchParams("dm=esie"); + rerender(); + + expect(directMutation.mutate).toHaveBeenCalledTimes(2); + expect(directMutation.mutate).toHaveBeenLastCalledWith("esie", expect.anything()); + }); +}); diff --git a/apps/web/src/specs/features/chat/official-accounts.spec.ts b/apps/web/src/specs/features/chat/official-accounts.spec.ts new file mode 100644 index 0000000000..897bba0da7 --- /dev/null +++ b/apps/web/src/specs/features/chat/official-accounts.spec.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { + ASK_ESIE_HREF, + impersonatesOfficialAccount, + isOfficialChatAccount +} from "@/features/chat/official-accounts"; +import { getUserDisplayName, isOfficialPostAuthor } from "@/features/chat/format-utils"; +import type { MattermostPost, MattermostUser } from "@/features/chat/mattermost-api"; + +const user = (username: string, nickname = "") => + ({ id: `${username}-id`, username, nickname, first_name: "", last_name: "" }) as MattermostUser; + +describe("official chat accounts", () => { + it("are exactly esie and ecency", () => { + expect(isOfficialChatAccount("esie")).toBe(true); + expect(isOfficialChatAccount("@Ecency")).toBe(true); + for (const name of ["esie1", "esie-help", "ecency.help", "", null, undefined]) { + expect(isOfficialChatAccount(name)).toBe(false); + } + expect(ASK_ESIE_HREF).toBe("/chats?dm=esie"); + }); + + it.each([ + "Esie", "ESIE support", "E-s-i-e", "E.s.i.e", "3sie", "E$ie", "Еsie", "Esıe", "Ɛsie", "Ecency Team", + "Ecen¢y", "Εcency", "Eϲency", "Ecenсү", "e c e n c y", "Support: Esie", "EsieBot" + ])( + "treats the display name %j on another account as posing as an official one", + (name) => { + expect(impersonatesOfficialAccount(name, "scammer")).toBe(true); + } + ); + + it.each([ + "Jessie", "Alice", "Desiree", "Ecosystem", "Agency", "Jane Siegel", "Jose Sierra", "Cassie Sievers", + "Kate Sie", "Agnes Iem", "Mike 51e", "Decency", "Recency Bias", "Сергей", "王小明", "王小明華", "Δημήτρης", "2024" + ])("leaves %j alone", (name) => { + expect(impersonatesOfficialAccount(name, "someone")).toBe(false); + }); + + it("lets the official accounts use their own names", () => { + expect(impersonatesOfficialAccount("Esie", "esie")).toBe(false); + expect(impersonatesOfficialAccount("Ecency", "ecency")).toBe(false); + }); + + it("shows the @username for a lookalike name, wherever chat names someone", () => { + expect(getUserDisplayName(user("scammer", "Esie Support"))).toBe("@scammer"); + expect(getUserDisplayName(user("esie", "Esie"))).toBe("Esie"); + expect(getUserDisplayName(user("alice", "Alice"))).toBe("Alice"); + }); +}); + +describe("isOfficialPostAuthor", () => { + const usersById = { "esie-id": user("esie"), "x-id": user("x") }; + const post = (fields: Partial) => + ({ id: "p", user_id: "esie-id", message: "hi", create_at: 1, type: "", props: {}, ...fields }) as MattermostPost; + + it("is true for a post by the official account's own chat user", () => { + expect(isOfficialPostAuthor(post({}), usersById)).toBe(true); + }); + + it("never trusts a name carried in post props", () => { + expect(isOfficialPostAuthor(post({ props: { override_username: "esie" } }), usersById)).toBe(false); + expect(isOfficialPostAuthor(post({ props: { from_webhook: "true" } }), usersById)).toBe(false); + expect(isOfficialPostAuthor(post({ user_id: "x-id", props: { username: "esie" } }), usersById)).toBe(false); + expect(isOfficialPostAuthor(post({ user_id: "unknown" }), usersById)).toBe(false); + expect(isOfficialPostAuthor(post({ type: "system_add_to_channel" }), usersById)).toBe(false); + }); +}); diff --git a/apps/web/src/specs/features/chat/use-channel-websocket-typing.spec.tsx b/apps/web/src/specs/features/chat/use-channel-websocket-typing.spec.tsx new file mode 100644 index 0000000000..5519681629 --- /dev/null +++ b/apps/web/src/specs/features/chat/use-channel-websocket-typing.spec.tsx @@ -0,0 +1,113 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { QueryClient } from "@tanstack/react-query"; +import type { MattermostUser } from "@/features/chat/mattermost-api"; + +let onTyping: ((userId: string) => void) | undefined; +let onPosted: ((post: { user_id: string }) => void) | undefined; +const sendTyping = vi.fn(); +const connect = vi.fn(() => Promise.resolve()); + +vi.mock("@/features/chat/mattermost-websocket", () => ({ + MattermostWebSocket: class { + withChannel() { return this; } + withQueryClient() { return this; } + onConnectionChange() { return this; } + onReconnecting() { return this; } + onTyping(cb: (userId: string) => void) { onTyping = cb; return this; } + onPosted(cb: (post: { user_id: string }) => void) { onPosted = cb; return this; } + connect = connect; + disconnect() {} + sendTyping = sendTyping; + } +})); + +import { useChannelWebSocket } from "@/features/chat/hooks/use-channel-websocket"; + +const esie = { id: "esie-id", username: "esie", nickname: "", first_name: "", last_name: "" } as MattermostUser; +const ref = (current: T) => ({ current }); + +function setup(usersById: Record, isWebSocketActive = true) { + return renderHook( + ({ users }) => + useChannelWebSocket({ + channelId: "c1", + queryClient: new QueryClient(), + getCurrentUserId: () => "me-id", + onPostedConfirm: vi.fn(), + pendingPostRefs: { + lastSentPendingIdRef: ref(null), + lastSentMessageRef: ref(null), + lastSentRootIdRef: ref(null), + lastSentAtRef: ref(0), + confirmedPendingPostIdsRef: ref(new Set()), + pendingAbortControllerRef: ref(null), + sendMutationRef: ref({ reset: vi.fn() }) + }, + usersById: users, + setIsWebSocketActive: vi.fn(), + setReconnectAttempt: vi.fn(), + setReconnectDelay: vi.fn(), + isWebSocketActive + }), + { initialProps: { users: usersById } } + ); +} + +describe("useChannelWebSocket typing", () => { + beforeEach(() => { + onTyping = undefined; + sendTyping.mockClear(); + connect.mockClear(); + }); + + // Esie answering the first question in a new DM has no message on the page yet + it("asks for someone typing who is not loaded yet, then names them", () => { + const { result, rerender } = setup({}); + + act(() => onTyping!("esie-id")); + expect(result.current.unknownTypingUserIds).toEqual(["esie-id"]); + expect(result.current.typingUsernames).toEqual([]); + + rerender({ users: { "esie-id": esie } }); + expect(result.current.unknownTypingUserIds).toEqual([]); + expect(result.current.typingUsernames).toEqual(["@esie"]); + }); + + it("ignores the viewer's own typing", () => { + const { result } = setup({}); + act(() => onTyping!("me-id")); + expect(result.current.unknownTypingUserIds).toEqual([]); + }); + + it("sends typing on the first keystroke, not after a pause", () => { + const { result } = setup({}); + result.current.sendTypingDebounced(); + expect(sendTyping).toHaveBeenCalledWith("c1"); + }); + + it("clears someone's typing as soon as their message arrives", () => { + const { result } = setup({ "esie-id": esie }); + act(() => onTyping!("esie-id")); + expect(result.current.typingUsernames).toEqual(["@esie"]); + + act(() => onPosted!({ user_id: "esie-id" })); + expect(result.current.typingUsernames).toEqual([]); + }); + + it("retries a dropped connection from typing at most once every few seconds", () => { + vi.useFakeTimers(); + try { + const { result } = setup({}, false); + connect.mockClear(); + for (let i = 0; i < 20; i++) result.current.sendTypingDebounced(); + expect(connect).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(5001); + result.current.sendTypingDebounced(); + expect(connect).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/apps/web/src/specs/features/shared/navbar-ask-esie.spec.tsx b/apps/web/src/specs/features/shared/navbar-ask-esie.spec.tsx new file mode 100644 index 0000000000..831a3cbd63 --- /dev/null +++ b/apps/web/src/specs/features/shared/navbar-ask-esie.spec.tsx @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import type { ReactNode } from "react"; + +let activeUser: { username: string } | null = null; +let hydrated = true; + +vi.mock("@/core/hooks/use-active-account", () => ({ useActiveAccount: () => ({ activeUser }) })); +vi.mock("@/api/queries", () => ({ useHydrated: () => hydrated })); +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) })); +vi.mock("next/image", () => ({ default: () => null })); +vi.mock("next/dynamic", () => ({ default: () => () => null })); +vi.mock("@/features/chat/mattermost-api", () => ({ useMattermostUnread: () => ({ data: undefined }) })); +vi.mock("@/features/shared/navbar/sidebar", () => ({ NavbarSideThemeSwitcher: () => null })); +vi.mock("@/features/shared/switch-lang", () => ({ SwitchLang: () => null })); +vi.mock("@ui/modal/modal-sidebar", () => ({ + ModalSidebar: ({ children }: { children: ReactNode }) =>
{children}
+})); + +import { NavbarAskEsie } from "@/features/shared/navbar/navbar-ask-esie"; +import { NavbarMainSidebar } from "@/features/shared/navbar/navbar-main-sidebar"; +import { ESIE_MOODS } from "@ui/esie/esie-moods.generated"; + +// Esie answers in chat, which needs a login: logged-out visitors see no way in +describe("Ask Esie entry points", () => { + beforeEach(() => { + activeUser = null; + hydrated = true; + }); + + it("navbar button opens the chat with Esie for a logged-in user", () => { + activeUser = { username: "alice" }; + const { container } = render(); + + const link = screen.getByRole("link", { name: "navbar.ask-esie" }); + expect(link).toHaveAttribute("href", "/chats?dm=esie"); + expect(container.querySelector("img")).toHaveAttribute("src", ESIE_MOODS.avatar.src); + }); + + it("navbar button is not rendered when logged out, or before hydration", () => { + expect(render().container).toBeEmptyDOMElement(); + + activeUser = { username: "alice" }; + hydrated = false; + expect(render().container).toBeEmptyDOMElement(); + }); + + it("sidebar lists Ask Esie only for a logged-in user", () => { + const { unmount } = render(); + expect(screen.queryByRole("link", { name: "navbar.ask-esie" })).toBeNull(); + unmount(); + + activeUser = { username: "alice" }; + render(); + expect(screen.getByRole("link", { name: "navbar.ask-esie" })).toHaveAttribute("href", "/chats?dm=esie"); + }); +}); diff --git a/scripts/esie/build.mjs b/scripts/esie/build.mjs index 8d54775cfb..5f05afe5c9 100644 --- a/scripts/esie/build.mjs +++ b/scripts/esie/build.mjs @@ -35,7 +35,9 @@ const VIEWPORTS = { happy: [211.02596, 174.8251, 1835.5774, 1772.3641], sad: [598.8225, 268.0, 1834.0144, 1775.829], thinking: [568.2747, 246.00687, 1856.6112, 1771.6663], - surprised: [597.98737, 168.43448, 1835.5767, 1772.472] + surprised: [597.98737, 168.43448, 1835.5767, 1772.472], + // the face, square, for the navbar button and the chat list (not a native viewport) + avatar: [580, 110, 1920, 1450] }; // Loops run this many times (at least 30 s), so they end on their first frame and hold. @@ -110,6 +112,8 @@ ${mouth("mouth_hmm")} #thought_1{animation:${loop("dot", 1800, "ease-in-out", "both")}} #thought_2{animation:dot 1800ms ease-in-out 300ms both ${times(1800)}} #thought_3{animation:dot 1800ms ease-in-out 600ms both ${times(1800)}}`, + // still apart from the blink: it sits in the navbar on every page + avatar: `#fin_front,#fin_back{display:none!important}#fin_rest{display:inline!important}#tail_tip{animation:none}`, surprised: `#esie{animation:jolt 450ms cubic-bezier(.2,1.8,.4,1) 1,breathe 3200ms ease-in-out 450ms ${times(3200)}}${REST_FIN} #brows_surprised{display:inline!important} ${mouth("mouth_o")} From 73a6c175411697d45bedf85b7cfcc0c81a27a55a Mon Sep 17 00:00:00 2001 From: feruzm Date: Sat, 24 Oct 2026 09:47:06 +0300 Subject: [PATCH 2/5] Close lookalike and typing gaps found in review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Invisible characters (zero-width space, joiners, soft hyphen, direction marks) are removed before checking a display name, so "Es​ie" no longer slips past as "Esie" - Esie no longer counts toward the fan-out limit when a group is created with it, matching the posts route; it is still added to the group - Concurrent first posts share one Esie lookup - Typing clears only when the person posts in the open chat, not in another one - Typing from while the connection was still opening is sent once it is up - "No channels" no longer shows under a list that holds only the Esie conversation - Tests for the badge on the profile card, and a typed Link mock --- .../web/src/app/api/mattermost/group/route.ts | 8 +++-- .../app/chats/_components/chats-client.tsx | 2 +- .../chat/hooks/use-channel-websocket.ts | 25 ++++++++++---- .../src/features/chat/official-accounts.ts | 4 ++- apps/web/src/server/mattermost.ts | 34 +++++++++++-------- .../api/mattermost-chat-recipients.spec.ts | 15 ++++++++ .../src/specs/api/mattermost-group.spec.ts | 13 +++++++ .../chat/chats-client-esie-pinned.spec.tsx | 8 ++++- .../features/chat/official-accounts.spec.ts | 3 +- .../features/chat/official-badge.spec.tsx | 26 ++++++++++++++ .../use-channel-websocket-typing.spec.tsx | 27 ++++++++++++--- 11 files changed, 133 insertions(+), 32 deletions(-) create mode 100644 apps/web/src/specs/features/chat/official-badge.spec.tsx diff --git a/apps/web/src/app/api/mattermost/group/route.ts b/apps/web/src/app/api/mattermost/group/route.ts index dbeb90ee6b..e7c3106e6e 100644 --- a/apps/web/src/app/api/mattermost/group/route.ts +++ b/apps/web/src/app/api/mattermost/group/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { callRPC } from "@ecency/sdk/hive"; import { + getDmFanoutExemptUserIds, getGroupOwnerId, getMattermostTeamId, lookupMattermostUser, @@ -126,8 +127,11 @@ export async function POST(req: NextRequest) { } const recipients = members.map((member) => member.id); + // Esie, the AI helper, is a member like anyone else but never counts toward the limit + const exempt = await getDmFanoutExemptUserIds(); + const counted = recipients.filter((id) => !exempt.has(id)); const limit = dmFanoutLimitFor(currentUser.create_at, Date.now()); - if (recipients.length > limit) { + if (counted.length > limit) { return NextResponse.json( { error: `You can start a group with up to ${limit} other people for now.`, @@ -141,7 +145,7 @@ export async function POST(req: NextRequest) { // after this can reject the request except Mattermost itself. const fanout = await checkDmFanout({ userId: currentUser.id, - recipients, + recipients: counted, accountCreatedAt: currentUser.create_at }); diff --git a/apps/web/src/app/chats/_components/chats-client.tsx b/apps/web/src/app/chats/_components/chats-client.tsx index 5b2248c650..3645b386f7 100644 --- a/apps/web/src/app/chats/_components/chats-client.tsx +++ b/apps/web/src/app/chats/_components/chats-client.tsx @@ -704,7 +704,7 @@ export function ChatsClient() { )} {listedChannels.map(renderChannelRow)} - {!listedChannels.length && !channelsLoading && ( + {!sortedChannels.length && !channelsLoading && (
{i18next.t("chat.no-channels")}
)}
diff --git a/apps/web/src/features/chat/hooks/use-channel-websocket.ts b/apps/web/src/features/chat/hooks/use-channel-websocket.ts index cb08caabbe..000cf6d7e3 100644 --- a/apps/web/src/features/chat/hooks/use-channel-websocket.ts +++ b/apps/web/src/features/chat/hooks/use-channel-websocket.ts @@ -51,6 +51,7 @@ export function useChannelWebSocket({ }) { const [typingUsers, setTypingUsers] = useState>(new Map()); const websocketRef = useRef(null); + const typingWhileOfflineAtRef = useRef(0); // Ref bridges to avoid recreating WebSocket on callback changes const onPostedConfirmRef = useRef(onPostedConfirm); @@ -76,6 +77,11 @@ export function useChannelWebSocket({ .withQueryClient(queryClient) .onConnectionChange((active) => { setIsWebSocketActiveRef.current(active); + if (active && Date.now() - typingWhileOfflineAtRef.current < TYPING_TIMEOUT) { + // typed while the connection was opening: still typing, so tell the other side now + typingWhileOfflineAtRef.current = 0; + ws.sendTyping(channelId); + } if (active) { setReconnectAttemptRef.current(null); setReconnectDelayRef.current(null); @@ -91,13 +97,15 @@ export function useChannelWebSocket({ setTypingUsers(prev => new Map(prev).set(userId, Date.now())); }) .onPosted((post) => { - // their message is here, so they are no longer typing it - setTypingUsers((prev) => { - if (!prev.has(post.user_id)) return prev; - const next = new Map(prev); - next.delete(post.user_id); - return next; - }); + // their message is here, so they are no longer typing it (posts from every channel arrive here) + if (post.channel_id === channelId) { + setTypingUsers((prev) => { + if (!prev.has(post.user_id)) return prev; + const next = new Map(prev); + next.delete(post.user_id); + return next; + }); + } const refs = pendingPostRefsRef.current; const currentUserId = getCurrentUserIdRef.current(); const pendingPostId = post.pending_post_id; @@ -194,10 +202,13 @@ export function useChannelWebSocket({ const ws = websocketRef.current; if (!ws) return; if (!isWebSocketActive) { + // sent once the connection opens + typingWhileOfflineAtRef.current = Date.now(); const now = Date.now(); if (now - lastReconnectKickRef.current < TYPING_RECONNECT_INTERVAL) return; lastReconnectKickRef.current = now; ws.connect(); + return; } ws.sendTyping(channelId); }, [channelId, isWebSocketActive]); diff --git a/apps/web/src/features/chat/official-accounts.ts b/apps/web/src/features/chat/official-accounts.ts index 63fb291341..e34543e31f 100644 --- a/apps/web/src/features/chat/official-accounts.ts +++ b/apps/web/src/features/chat/official-accounts.ts @@ -26,7 +26,9 @@ const ANY = "*"; /** The name's words in Latin letters. A run of single letters ("E s i e", "E.s.i.e") is one word. */ function nameWords(text: string): string[] { // the table first: decomposing can turn a lookalike into something else (lunate ϲ into ς) - const latin = Array.from(text.toLowerCase()) + // invisible characters (zero-width space, joiners, soft hyphen, direction marks) display nothing, + // so they must not split "Es\u200bie" into two words + const latin = Array.from(text.replace(/[\p{Default_Ignorable_Code_Point}\p{Cf}]/gu, "").toLowerCase()) .flatMap((ch) => LOOKALIKES[ch] ?? Array.from(ch.normalize("NFKD").replace(/\p{M}/gu, ""))) .map((ch) => LOOKALIKES[ch] ?? ch) .map((ch) => (/[a-z]/.test(ch) ? ch : /[\p{L}\p{N}]/u.test(ch) ? ANY : " ")) diff --git a/apps/web/src/server/mattermost.ts b/apps/web/src/server/mattermost.ts index ea3afe0813..91817f7be8 100644 --- a/apps/web/src/server/mattermost.ts +++ b/apps/web/src/server/mattermost.ts @@ -671,10 +671,11 @@ export async function lookupMattermostUser(username: string): Promise(); -const dmFanoutExemptMissingAt = new Map(); +// Chat user ids are permanent, so Esie's id is kept for the life of the process once found. A chat +// server without Esie is asked again after a while; concurrent first posts share one lookup. +let esieUserId: string | undefined; +let esieMissingAt = 0; +let esieLookup: Promise | null = null; const DM_FANOUT_EXEMPT_RECHECK_MS = 10 * 60_000; /** @@ -683,18 +684,21 @@ const DM_FANOUT_EXEMPT_RECHECK_MS = 10 * 60_000; * succeeds Esie simply counts like anyone else. */ export async function getDmFanoutExemptUserIds(): Promise> { - for (const username of [ESIE_USERNAME]) { - if (dmFanoutExemptIds.has(username)) continue; - if (Date.now() - (dmFanoutExemptMissingAt.get(username) ?? 0) < DM_FANOUT_EXEMPT_RECHECK_MS) continue; - try { - const user = await lookupMattermostUser(username); - if (user?.id) dmFanoutExemptIds.set(username, user.id); - else dmFanoutExemptMissingAt.set(username, Date.now()); - } catch { - // counted normally this time - } + if (!esieUserId && Date.now() - esieMissingAt >= DM_FANOUT_EXEMPT_RECHECK_MS) { + esieLookup ??= lookupMattermostUser(ESIE_USERNAME) + .then((user) => { + if (user?.id) esieUserId = user.id; + else esieMissingAt = Date.now(); + }) + .catch(() => { + // counted normally this time + }) + .finally(() => { + esieLookup = null; + }); + await esieLookup; } - return new Set(dmFanoutExemptIds.values()); + return new Set(esieUserId ? [esieUserId] : []); } export async function getMattermostUserWithProps(userId: string, signal?: AbortSignal): Promise { diff --git a/apps/web/src/specs/api/mattermost-chat-recipients.spec.ts b/apps/web/src/specs/api/mattermost-chat-recipients.spec.ts index 2c10f01e0b..a341efad07 100644 --- a/apps/web/src/specs/api/mattermost-chat-recipients.spec.ts +++ b/apps/web/src/specs/api/mattermost-chat-recipients.spec.ts @@ -180,3 +180,18 @@ describe("getDmFanoutExemptUserIds on a chat server without Esie", () => { expect(fetchMock).toHaveBeenCalledTimes(2); }); }); + +describe("getDmFanoutExemptUserIds under concurrent first posts", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("shares one lookup", async () => { + const fetchMock = vi.fn().mockResolvedValue(resp(200, { id: "esie-id", username: "esie" })); + vi.stubGlobal("fetch", fetchMock); + const { getDmFanoutExemptUserIds } = await loadModule(); + + const results = await Promise.all([getDmFanoutExemptUserIds(), getDmFanoutExemptUserIds(), getDmFanoutExemptUserIds()]); + + expect(results).toEqual([new Set(["esie-id"]), new Set(["esie-id"]), new Set(["esie-id"])]); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/specs/api/mattermost-group.spec.ts b/apps/web/src/specs/api/mattermost-group.spec.ts index 1d0fce0fe1..fb0211e726 100644 --- a/apps/web/src/specs/api/mattermost-group.spec.ts +++ b/apps/web/src/specs/api/mattermost-group.spec.ts @@ -18,7 +18,10 @@ vi.mock("@ecency/sdk/hive", () => ({ callRPC: (...args: unknown[]) => mockCallRPC(...args) })); +const mockExemptIds = vi.fn(async () => new Set()); + vi.mock("@/server/mattermost", () => ({ + getDmFanoutExemptUserIds: () => mockExemptIds(), getGroupOwnerId: (...args: unknown[]) => mockGetOwner(...args), setGroupOwnerId: (...args: unknown[]) => mockSetOwner(...args), getMattermostTeamId: () => "team-1", @@ -215,6 +218,16 @@ describe("POST /api/mattermost/group", () => { }); }); + it("adds Esie to the group without counting it against the fan-out limit", async () => { + const { POST } = await import("@/app/api/mattermost/group/route"); + mockExemptIds.mockResolvedValueOnce(new Set(["id-esie"])); + + await POST(request({ usernames: ["bob", "esie", "carol"] })); + + expect(mockCheckDmFanout.mock.calls[0][0]).toMatchObject({ recipients: ["id-bob", "id-carol"] }); + expect(JSON.parse(createCalls()[0][2].body)).toEqual(["me", "id-bob", "id-esie", "id-carol"]); + }); + it("rejects fewer than two or more than seven other people", async () => { const { POST } = await import("@/app/api/mattermost/group/route"); diff --git a/apps/web/src/specs/features/chat/chats-client-esie-pinned.spec.tsx b/apps/web/src/specs/features/chat/chats-client-esie-pinned.spec.tsx index 5f4de2207c..1f561e807a 100644 --- a/apps/web/src/specs/features/chat/chats-client-esie-pinned.spec.tsx +++ b/apps/web/src/specs/features/chat/chats-client-esie-pinned.spec.tsx @@ -17,7 +17,7 @@ vi.mock("next/navigation", () => ({ useSearchParams: () => search })); vi.mock("next/link", () => ({ - default: ({ href, children, ...rest }: any) => ( + default: ({ href, children, ...rest }: React.AnchorHTMLAttributes) => ( {children} @@ -127,6 +127,12 @@ describe("ChatsClient pinned Esie", () => { expect(container.querySelectorAll("[data-official-badge]")).toHaveLength(1); }); + it("does not call a list with only the Esie conversation empty", () => { + channelList = [dm("c-esie", "esie")]; + render(); + expect(screen.queryByText("chat.no-channels")).not.toBeInTheDocument(); + }); + it("shows the empty notice under the pinned row when there is nothing else", () => { render(); expect(screen.getByText("chat.no-channels")).toBeInTheDocument(); diff --git a/apps/web/src/specs/features/chat/official-accounts.spec.ts b/apps/web/src/specs/features/chat/official-accounts.spec.ts index 897bba0da7..3ae9c86332 100644 --- a/apps/web/src/specs/features/chat/official-accounts.spec.ts +++ b/apps/web/src/specs/features/chat/official-accounts.spec.ts @@ -22,7 +22,8 @@ describe("official chat accounts", () => { it.each([ "Esie", "ESIE support", "E-s-i-e", "E.s.i.e", "3sie", "E$ie", "Еsie", "Esıe", "Ɛsie", "Ecency Team", - "Ecen¢y", "Εcency", "Eϲency", "Ecenсү", "e c e n c y", "Support: Esie", "EsieBot" + "Ecen¢y", "Εcency", "Eϲency", "Ecenсү", "e c e n c y", "Support: Esie", "EsieBot", + "Es\u200bie", "E\u2060s\u200di\u00ade", "Ec\u202eency", "\u200bE\u200bs\u200bi\u200be" ])( "treats the display name %j on another account as posing as an official one", (name) => { diff --git a/apps/web/src/specs/features/chat/official-badge.spec.tsx b/apps/web/src/specs/features/chat/official-badge.spec.tsx new file mode 100644 index 0000000000..20a967a4e1 --- /dev/null +++ b/apps/web/src/specs/features/chat/official-badge.spec.tsx @@ -0,0 +1,26 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { render } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { OfficialBadge } from "@/features/chat/components/official-badge"; + +describe("OfficialBadge", () => { + it.each(["esie", "ecency", "@Esie"])("marks %s", (username) => { + const { container } = render(); + expect(container.querySelector("[data-official-badge]")).toHaveTextContent("chat.official-badge"); + }); + + it.each(["esie1", "ecency-support", "alice", undefined])("renders nothing for %s", (username) => { + expect(render().container).toBeEmptyDOMElement(); + }); + + // the card is too heavy to render here; this pins the badge to the account the page is about + it("is on the profile card, for the profile's account", () => { + const card = readFileSync( + join(__dirname, "../../../app/(dynamicPages)/profile/[username]/_components/profile-card/index.tsx"), + "utf8" + ); + expect(card).toContain(""); + }); +}); diff --git a/apps/web/src/specs/features/chat/use-channel-websocket-typing.spec.tsx b/apps/web/src/specs/features/chat/use-channel-websocket-typing.spec.tsx index 5519681629..2b43bf7288 100644 --- a/apps/web/src/specs/features/chat/use-channel-websocket-typing.spec.tsx +++ b/apps/web/src/specs/features/chat/use-channel-websocket-typing.spec.tsx @@ -4,7 +4,8 @@ import { QueryClient } from "@tanstack/react-query"; import type { MattermostUser } from "@/features/chat/mattermost-api"; let onTyping: ((userId: string) => void) | undefined; -let onPosted: ((post: { user_id: string }) => void) | undefined; +let onPosted: ((post: { user_id: string; channel_id: string }) => void) | undefined; +let onConnectionChange: ((active: boolean) => void) | undefined; const sendTyping = vi.fn(); const connect = vi.fn(() => Promise.resolve()); @@ -12,10 +13,10 @@ vi.mock("@/features/chat/mattermost-websocket", () => ({ MattermostWebSocket: class { withChannel() { return this; } withQueryClient() { return this; } - onConnectionChange() { return this; } + onConnectionChange(cb: (active: boolean) => void) { onConnectionChange = cb; return this; } onReconnecting() { return this; } onTyping(cb: (userId: string) => void) { onTyping = cb; return this; } - onPosted(cb: (post: { user_id: string }) => void) { onPosted = cb; return this; } + onPosted(cb: (post: { user_id: string; channel_id: string }) => void) { onPosted = cb; return this; } connect = connect; disconnect() {} sendTyping = sendTyping; @@ -57,6 +58,7 @@ function setup(usersById: Record, isWebSocketActive = tr describe("useChannelWebSocket typing", () => { beforeEach(() => { onTyping = undefined; + onConnectionChange = undefined; sendTyping.mockClear(); connect.mockClear(); }); @@ -91,10 +93,27 @@ describe("useChannelWebSocket typing", () => { act(() => onTyping!("esie-id")); expect(result.current.typingUsernames).toEqual(["@esie"]); - act(() => onPosted!({ user_id: "esie-id" })); + act(() => onPosted!({ user_id: "esie-id", channel_id: "other" })); + expect(result.current.typingUsernames).toEqual(["@esie"]); + + act(() => onPosted!({ user_id: "esie-id", channel_id: "c1" })); expect(result.current.typingUsernames).toEqual([]); }); + it("sends typing from while the connection was opening once it is up", () => { + const { result } = setup({}, false); + result.current.sendTypingDebounced(); + expect(sendTyping).not.toHaveBeenCalled(); + + act(() => onConnectionChange!(true)); + expect(sendTyping).toHaveBeenCalledTimes(1); + expect(sendTyping).toHaveBeenCalledWith("c1"); + + // only once, and not for typing long gone + act(() => onConnectionChange!(true)); + expect(sendTyping).toHaveBeenCalledTimes(1); + }); + it("retries a dropped connection from typing at most once every few seconds", () => { vi.useFakeTimers(); try { From 893f8f8f8e59291721c9cbc5c490a9dcede2e51f Mon Sep 17 00:00:00 2001 From: feruzm Date: Sat, 24 Oct 2026 10:16:44 +0300 Subject: [PATCH 3/5] Do not announce typing for a message already sent while offline --- .../web/src/features/chat/hooks/use-channel-websocket.ts | 8 +++++++- apps/web/src/features/chat/mattermost-channel-view.tsx | 2 ++ .../features/chat/use-channel-websocket-typing.spec.tsx | 9 +++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/apps/web/src/features/chat/hooks/use-channel-websocket.ts b/apps/web/src/features/chat/hooks/use-channel-websocket.ts index 000cf6d7e3..0f194b1c6a 100644 --- a/apps/web/src/features/chat/hooks/use-channel-websocket.ts +++ b/apps/web/src/features/chat/hooks/use-channel-websocket.ts @@ -78,7 +78,7 @@ export function useChannelWebSocket({ .onConnectionChange((active) => { setIsWebSocketActiveRef.current(active); if (active && Date.now() - typingWhileOfflineAtRef.current < TYPING_TIMEOUT) { - // typed while the connection was opening: still typing, so tell the other side now + // typed while the connection was opening and not sent yet: still typing, so say so now typingWhileOfflineAtRef.current = 0; ws.sendTyping(channelId); } @@ -213,8 +213,14 @@ export function useChannelWebSocket({ ws.sendTyping(channelId); }, [channelId, isWebSocketActive]); + // A sent message ends the typing; nothing queued should announce it afterwards + const clearQueuedTyping = useCallback(() => { + typingWhileOfflineAtRef.current = 0; + }, []); + return { typingUsernames, + clearQueuedTyping, unknownTypingUserIds, sendTypingDebounced, websocketRef diff --git a/apps/web/src/features/chat/mattermost-channel-view.tsx b/apps/web/src/features/chat/mattermost-channel-view.tsx index d93b5f113b..ed1dd88e9b 100644 --- a/apps/web/src/features/chat/mattermost-channel-view.tsx +++ b/apps/web/src/features/chat/mattermost-channel-view.tsx @@ -425,6 +425,7 @@ export function MattermostChannelView({ channelId }: Props) { const { typingUsernames, unknownTypingUserIds, + clearQueuedTyping, sendTypingDebounced } = useChannelWebSocket({ channelId, @@ -947,6 +948,7 @@ export function MattermostChannelView({ channelId }: Props) { lastSentMessageRef.current = finalMessage; lastSentRootIdRef.current = rootId; lastSentAtRef.current = Date.now(); + clearQueuedTyping(); pendingAbortControllerRef.current = new AbortController(); sendMutation.mutate( diff --git a/apps/web/src/specs/features/chat/use-channel-websocket-typing.spec.tsx b/apps/web/src/specs/features/chat/use-channel-websocket-typing.spec.tsx index 2b43bf7288..ac00900f44 100644 --- a/apps/web/src/specs/features/chat/use-channel-websocket-typing.spec.tsx +++ b/apps/web/src/specs/features/chat/use-channel-websocket-typing.spec.tsx @@ -114,6 +114,15 @@ describe("useChannelWebSocket typing", () => { expect(sendTyping).toHaveBeenCalledTimes(1); }); + it("does not announce typing for a message already sent while offline", () => { + const { result } = setup({}, false); + result.current.sendTypingDebounced(); + result.current.clearQueuedTyping(); + + act(() => onConnectionChange!(true)); + expect(sendTyping).not.toHaveBeenCalled(); + }); + it("retries a dropped connection from typing at most once every few seconds", () => { vi.useFakeTimers(); try { From f0871574b0d90dad30648b8e71c2a76d6da07320 Mon Sep 17 00:00:00 2001 From: feruzm Date: Sat, 24 Oct 2026 10:23:01 +0300 Subject: [PATCH 4/5] Drop queued typing when the box is emptied or an edit is saved --- .../chat/hooks/use-channel-websocket.ts | 9 ++++- .../features/chat/mattermost-channel-view.tsx | 9 ++--- .../use-channel-websocket-typing.spec.tsx | 37 ++++++++++++++++--- 3 files changed, 43 insertions(+), 12 deletions(-) diff --git a/apps/web/src/features/chat/hooks/use-channel-websocket.ts b/apps/web/src/features/chat/hooks/use-channel-websocket.ts index 0f194b1c6a..f24a63ba85 100644 --- a/apps/web/src/features/chat/hooks/use-channel-websocket.ts +++ b/apps/web/src/features/chat/hooks/use-channel-websocket.ts @@ -198,7 +198,12 @@ export function useChannelWebSocket({ // pause meant someone typing steadily never showed as typing to the other side. // Typing into a dropped connection retries it, but not on every keystroke. const lastReconnectKickRef = useRef(0); - const sendTypingDebounced = useCallback(() => { + const onComposerTextChange = useCallback((text: string) => { + // an emptied box ends the typing; nothing queued should announce it afterwards + if (!text) { + typingWhileOfflineAtRef.current = 0; + return; + } const ws = websocketRef.current; if (!ws) return; if (!isWebSocketActive) { @@ -222,7 +227,7 @@ export function useChannelWebSocket({ typingUsernames, clearQueuedTyping, unknownTypingUserIds, - sendTypingDebounced, + onComposerTextChange, websocketRef }; } diff --git a/apps/web/src/features/chat/mattermost-channel-view.tsx b/apps/web/src/features/chat/mattermost-channel-view.tsx index ed1dd88e9b..99b8248cad 100644 --- a/apps/web/src/features/chat/mattermost-channel-view.tsx +++ b/apps/web/src/features/chat/mattermost-channel-view.tsx @@ -426,7 +426,7 @@ export function MattermostChannelView({ channelId }: Props) { typingUsernames, unknownTypingUserIds, clearQueuedTyping, - sendTypingDebounced + onComposerTextChange } = useChannelWebSocket({ channelId, queryClient, @@ -865,9 +865,7 @@ export function MattermostChannelView({ channelId }: Props) { updateMentionState(value, cursor); updateEmojiState(value, cursor); - if (value.length > 0) { - sendTypingDebounced(); - } + onComposerTextChange(value); autoResize(); }; @@ -902,6 +900,8 @@ export function MattermostChannelView({ channelId }: Props) { } setMessageError(null); + // sending or saving an edit ends the typing; nothing queued should announce it afterwards + clearQueuedTyping(); if (editingPost) { updateMutation.mutate( @@ -948,7 +948,6 @@ export function MattermostChannelView({ channelId }: Props) { lastSentMessageRef.current = finalMessage; lastSentRootIdRef.current = rootId; lastSentAtRef.current = Date.now(); - clearQueuedTyping(); pendingAbortControllerRef.current = new AbortController(); sendMutation.mutate( diff --git a/apps/web/src/specs/features/chat/use-channel-websocket-typing.spec.tsx b/apps/web/src/specs/features/chat/use-channel-websocket-typing.spec.tsx index ac00900f44..38ef40aad2 100644 --- a/apps/web/src/specs/features/chat/use-channel-websocket-typing.spec.tsx +++ b/apps/web/src/specs/features/chat/use-channel-websocket-typing.spec.tsx @@ -1,3 +1,5 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { describe, expect, it, vi, beforeEach } from "vitest"; import { act, renderHook } from "@testing-library/react"; import { QueryClient } from "@tanstack/react-query"; @@ -84,7 +86,7 @@ describe("useChannelWebSocket typing", () => { it("sends typing on the first keystroke, not after a pause", () => { const { result } = setup({}); - result.current.sendTypingDebounced(); + result.current.onComposerTextChange("h"); expect(sendTyping).toHaveBeenCalledWith("c1"); }); @@ -102,7 +104,7 @@ describe("useChannelWebSocket typing", () => { it("sends typing from while the connection was opening once it is up", () => { const { result } = setup({}, false); - result.current.sendTypingDebounced(); + result.current.onComposerTextChange("h"); expect(sendTyping).not.toHaveBeenCalled(); act(() => onConnectionChange!(true)); @@ -116,26 +118,51 @@ describe("useChannelWebSocket typing", () => { it("does not announce typing for a message already sent while offline", () => { const { result } = setup({}, false); - result.current.sendTypingDebounced(); + result.current.onComposerTextChange("h"); result.current.clearQueuedTyping(); act(() => onConnectionChange!(true)); expect(sendTyping).not.toHaveBeenCalled(); }); + it("does not announce typing after the box was emptied while offline", () => { + const { result } = setup({}, false); + result.current.onComposerTextChange("h"); + result.current.onComposerTextChange(""); + + act(() => onConnectionChange!(true)); + expect(sendTyping).not.toHaveBeenCalled(); + }); + + it("sends nothing for an empty box", () => { + const { result } = setup({}); + result.current.onComposerTextChange(""); + expect(sendTyping).not.toHaveBeenCalled(); + }); + it("retries a dropped connection from typing at most once every few seconds", () => { vi.useFakeTimers(); try { const { result } = setup({}, false); connect.mockClear(); - for (let i = 0; i < 20; i++) result.current.sendTypingDebounced(); + for (let i = 0; i < 20; i++) result.current.onComposerTextChange("h"); expect(connect).toHaveBeenCalledTimes(1); vi.advanceTimersByTime(5001); - result.current.sendTypingDebounced(); + result.current.onComposerTextChange("h"); expect(connect).toHaveBeenCalledTimes(2); } finally { vi.useRealTimers(); } }); + + // the channel view is too heavy to render here: pin that sending a message and saving an edit + // both drop queued typing, by clearing it before the two paths split + it("is cleared by the composer on every submit, edits included", () => { + const view = readFileSync(join(__dirname, "../../../features/chat/mattermost-channel-view.tsx"), "utf8"); + const submit = view.slice(view.indexOf("const submitMessage = () => {")); + const clear = submit.indexOf("clearQueuedTyping();"); + expect(clear).toBeGreaterThan(-1); + expect(clear).toBeLessThan(submit.indexOf("if (editingPost) {")); + }); }); From 2955a9b64d38eea9f1c7773250aa246325c22d42 Mon Sep 17 00:00:00 2001 From: feruzm Date: Sat, 24 Oct 2026 10:27:21 +0300 Subject: [PATCH 5/5] Drop queued typing whenever the composer empties, edit cancel included --- .../features/chat/hooks/use-channel-websocket.ts | 14 +++++++++----- .../src/features/chat/mattermost-channel-view.tsx | 1 + .../chat/use-channel-websocket-typing.spec.tsx | 14 +++++++++----- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/apps/web/src/features/chat/hooks/use-channel-websocket.ts b/apps/web/src/features/chat/hooks/use-channel-websocket.ts index f24a63ba85..db0830e016 100644 --- a/apps/web/src/features/chat/hooks/use-channel-websocket.ts +++ b/apps/web/src/features/chat/hooks/use-channel-websocket.ts @@ -27,6 +27,8 @@ interface UseChannelWebSocketParams { sendMutationRef: React.MutableRefObject<{ reset: () => void }>; }; usersById: Record; + /** What the composer holds now. Empty, however it got there (sent, canceled, cleared), ends the typing */ + composerText: string; } const TYPING_TIMEOUT = 5000; @@ -39,6 +41,7 @@ export function useChannelWebSocket({ onPostedConfirm, pendingPostRefs, usersById, + composerText, setIsWebSocketActive, setReconnectAttempt, setReconnectDelay, @@ -198,12 +201,13 @@ export function useChannelWebSocket({ // pause meant someone typing steadily never showed as typing to the other side. // Typing into a dropped connection retries it, but not on every keystroke. const lastReconnectKickRef = useRef(0); + // An empty composer ends the typing, so nothing queued may announce it afterwards + useEffect(() => { + if (!composerText) typingWhileOfflineAtRef.current = 0; + }, [composerText]); + const onComposerTextChange = useCallback((text: string) => { - // an emptied box ends the typing; nothing queued should announce it afterwards - if (!text) { - typingWhileOfflineAtRef.current = 0; - return; - } + if (!text) return; const ws = websocketRef.current; if (!ws) return; if (!isWebSocketActive) { diff --git a/apps/web/src/features/chat/mattermost-channel-view.tsx b/apps/web/src/features/chat/mattermost-channel-view.tsx index 99b8248cad..39cb4b4ae5 100644 --- a/apps/web/src/features/chat/mattermost-channel-view.tsx +++ b/apps/web/src/features/chat/mattermost-channel-view.tsx @@ -455,6 +455,7 @@ export function MattermostChannelView({ channelId }: Props) { sendMutationRef }, usersById, + composerText: message, isWebSocketActive, setIsWebSocketActive, setReconnectAttempt, diff --git a/apps/web/src/specs/features/chat/use-channel-websocket-typing.spec.tsx b/apps/web/src/specs/features/chat/use-channel-websocket-typing.spec.tsx index 38ef40aad2..d3bb058fd0 100644 --- a/apps/web/src/specs/features/chat/use-channel-websocket-typing.spec.tsx +++ b/apps/web/src/specs/features/chat/use-channel-websocket-typing.spec.tsx @@ -32,7 +32,7 @@ const ref = (current: T) => ({ current }); function setup(usersById: Record, isWebSocketActive = true) { return renderHook( - ({ users }) => + ({ users, text }: { users: Record; text: string }) => useChannelWebSocket({ channelId: "c1", queryClient: new QueryClient(), @@ -48,12 +48,13 @@ function setup(usersById: Record, isWebSocketActive = tr sendMutationRef: ref({ reset: vi.fn() }) }, usersById: users, + composerText: text, setIsWebSocketActive: vi.fn(), setReconnectAttempt: vi.fn(), setReconnectDelay: vi.fn(), isWebSocketActive }), - { initialProps: { users: usersById } } + { initialProps: { users: usersById, text: "h" } } ); } @@ -73,7 +74,7 @@ describe("useChannelWebSocket typing", () => { expect(result.current.unknownTypingUserIds).toEqual(["esie-id"]); expect(result.current.typingUsernames).toEqual([]); - rerender({ users: { "esie-id": esie } }); + rerender({ users: { "esie-id": esie }, text: "h" }); expect(result.current.unknownTypingUserIds).toEqual([]); expect(result.current.typingUsernames).toEqual(["@esie"]); }); @@ -125,10 +126,11 @@ describe("useChannelWebSocket typing", () => { expect(sendTyping).not.toHaveBeenCalled(); }); + // however it empties: cleared by hand, edit canceled (button or Escape), sent it("does not announce typing after the box was emptied while offline", () => { - const { result } = setup({}, false); + const { result, rerender } = setup({}, false); result.current.onComposerTextChange("h"); - result.current.onComposerTextChange(""); + rerender({ users: {}, text: "" }); act(() => onConnectionChange!(true)); expect(sendTyping).not.toHaveBeenCalled(); @@ -164,5 +166,7 @@ describe("useChannelWebSocket typing", () => { const clear = submit.indexOf("clearQueuedTyping();"); expect(clear).toBeGreaterThan(-1); expect(clear).toBeLessThan(submit.indexOf("if (editingPost) {")); + // and hands the hook the live composer text, so canceling an edit or clearing the box drops it too + expect(view).toContain("composerText: message,"); }); });