Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/public/assets/esie/avatar.3ef26f1f.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -116,6 +117,7 @@ export function ProfileCard({ account }: Props) {
<div className="font-semibold flex items-center flex-wrap gap-2">
{displayName ?? account.name}
<ProBadge username={account.name} />
<OfficialBadge username={account.name} />
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
Resolved
</div>
<span className="text-sm text-gray-600 dark:text-gray-400 truncate flex gap-1">
@{account.name}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
handleMattermostError,
MattermostChannel,
getMattermostTokenFromCookies,
getDmFanoutExemptUserIds,
getPrivateChannelRecipientIds,
mmUserFetch,
toPublicChatUserMap,
Expand Down Expand Up @@ -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));
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
Resolved
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
const fanout = await checkDmFanout({
userId: currentUser.id,
recipients,
Expand Down
8 changes: 6 additions & 2 deletions apps/web/src/app/api/mattermost/group/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { callRPC } from "@ecency/sdk/hive";
import {
getDmFanoutExemptUserIds,
getGroupOwnerId,
getMattermostTeamId,
lookupMattermostUser,
Expand Down Expand Up @@ -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.`,
Expand All @@ -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
});

Expand Down
62 changes: 47 additions & 15 deletions apps/web/src/app/chats/_components/chats-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 [];
Expand Down Expand Up @@ -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]
Expand All @@ -196,6 +198,8 @@ export function ChatsClient() {
// Auto-start DM when navigating from profile (?dm=username)
const dmInitiated = useRef<string | null>(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, {
Expand Down Expand Up @@ -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)
);
}

Expand Down Expand Up @@ -429,6 +434,7 @@ export function ChatsClient() {
<div className="flex items-center gap-2 min-w-0">
<div className="flex items-center gap-1 font-semibold min-w-0 flex-1">
<span className="truncate">{getChannelTitle(channel)}</span>
{channel.type === "D" && <OfficialBadge username={channel.directUser?.username} />}
{channel.is_favorite && (
<span
className="text-amber-500 flex-shrink-0 text-sm"
Expand Down Expand Up @@ -671,7 +677,33 @@ export function ChatsClient() {
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden p-3 sm:p-4">
<div className="space-y-6">
<div className="grid grid-cols-1 gap-1">
{sortedChannels.map(renderChannelRow)}
{esieChannel ? (
renderChannelRow(esieChannel)
) : (
<Link
href={ASK_ESIE_HREF}
data-chat-esie-pinned
className="flex min-h-11 min-w-0 items-center gap-2 rounded-lg border-l-2 border-transparent p-2.5 transition hover:bg-gray-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-blue-dark-sky dark:hover:bg-white/5"
>
<img
src={ESIE_MOODS.avatar.src}
width={40}
height={40}
alt=""
aria-hidden
draggable={false}
className="size-10 shrink-0 rounded-full bg-blue-duck-egg dark:bg-dark-default"
/>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex min-w-0 items-center gap-1 font-semibold">
<span className="truncate">{i18next.t("chat.esie-title")}</span>
<OfficialBadge username={ESIE_USERNAME} />
</div>
<div className="truncate text-xs text-[--text-muted]">{i18next.t("chat.esie-subtitle")}</div>
</div>
</Link>
)}
{listedChannels.map(renderChannelRow)}
{!sortedChannels.length && !channelsLoading && (
<div className="text-sm text-[--text-muted]">{i18next.t("chat.no-channels")}</div>
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
Resolved
)}
Expand Down Expand Up @@ -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 (
<button
Expand Down
9 changes: 8 additions & 1 deletion apps/web/src/features/chat/components/channel-header.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { MattermostPost, MattermostUser } from "../mattermost-api";
import { formatTimestamp, getUserDisplayName } from "../format-utils";
import { OfficialBadge } from "./official-badge";
import { Modal, ModalBody } from "@ui/modal";
import { ProfileLink, UserAvatar } from "@/features/shared";
import { useState } from "react";
Expand All @@ -10,6 +11,8 @@ import { ManagePrivateChannelModal } from "./private-channel-modals";

interface ChannelHeaderProps {
channelTitle: string;
/** The other person in a 1:1 conversation, for the official badge. */
directUsername?: string;
channelSubtitle: string;
effectiveLastViewedAt: number;
pinnedPosts: MattermostPost[] | undefined;
Expand Down Expand Up @@ -40,6 +43,7 @@ interface ChannelHeaderProps {

export function ChannelHeader({
channelTitle,
directUsername,
channelSubtitle,
effectiveLastViewedAt,
pinnedPosts,
Expand Down Expand Up @@ -72,7 +76,10 @@ export function ChannelHeader({
<div className="flex items-center justify-between gap-3">
<button type="button" onClick={onClose} className="flex size-10 shrink-0 items-center justify-center rounded-lg text-[--text-muted] hover:bg-[--background-color] hover:text-[--text-color] focus-visible:outline focus-visible:outline-2 focus-visible:outline-blue-dark-sky md:hidden" aria-label={i18next.t("chat.back-to-chats")} title={i18next.t("chat.back-to-chats")}>←</button>
<div className="flex min-w-0 flex-1 flex-col">
<div className="truncate text-base font-semibold">{channelTitle}</div>
<div className="flex min-w-0 items-center gap-1.5">
<span className="truncate text-base font-semibold">{channelTitle}</span>
<OfficialBadge username={directUsername} />
</div>
<div className="flex min-w-0 flex-wrap items-center gap-2 text-xs text-[--text-muted]">
<span className="truncate">{channelSubtitle}</span>

Expand Down
7 changes: 5 additions & 2 deletions apps/web/src/features/chat/components/mention-token.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
9 changes: 3 additions & 6 deletions apps/web/src/features/chat/components/message-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -540,11 +540,8 @@ export function MessageInput({
<UserAvatar username={user.username} size="medium" className="size-8" />
<div className="flex flex-col text-left">
<span className="font-semibold">@{user.username}</span>
{(user.nickname || user.first_name || user.last_name) && (
<span className="text-xs text-[--text-muted]">
{[user.first_name, user.last_name].filter(Boolean).join(" ") ||
user.nickname}
</span>
{getUserDisplayName(user) !== `@${user.username}` && (
<span className="text-xs text-[--text-muted]">{getUserDisplayName(user)}</span>
)}
</div>
</button>
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/features/chat/components/message-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand All @@ -90,6 +93,7 @@ const UsernameActions = memo(function UsernameActions({
>
{displayName}
</span>
{official && <OfficialBadge username={username} className="ml-1" />}
</DropdownToggle>
<DropdownMenu align="left" size="small">
<DropdownItemWithIcon icon={blogSvg} label="View blog" href={`/@${username}`} />
Expand Down Expand Up @@ -300,6 +304,7 @@ function MessageItemInner({
username={username}
displayName={displayName}
currentUsername={activeUser?.username}
official={isOfficialPostAuthor(post, usersById)}
onStartDm={startDirectMessage}
/>
);
Expand Down
30 changes: 30 additions & 0 deletions apps/web/src/features/chat/components/official-badge.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span
title={i18next.t("chat.official-badge-title")}
data-official-badge
className={clsx(
"inline-flex shrink-0 items-center rounded bg-blue-dark-sky px-1 py-px text-[10px] font-semibold leading-tight text-white",
className
)}
>
{label}
</span>
);
}
29 changes: 17 additions & 12 deletions apps/web/src/features/chat/format-utils.ts
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -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}`;

Expand Down Expand Up @@ -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 =
Expand All @@ -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<string, MattermostUser>): 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)
*/
Expand Down
Loading
Loading