feat(direct): land inline invite cards with spam-filter toggle and retire the /inbox/ tree along with its sidebar tab

This commit is contained in:
heaven 2026-05-03 13:49:33 +03:00
parent bae6761683
commit ed1544dd5e
27 changed files with 630 additions and 2048 deletions

View file

@ -161,6 +161,8 @@
"enable": "Enable",
"notification_sound": "Notification Sound",
"notification_sound_desc": "Play sound when a new message arrives.",
"invite_spam_filter": "Spam Invites Filter",
"invite_spam_filter_desc": "Hide incoming chat invites that look like spam (mass mailings, banned senders, suspicious words). Disable to see every invite as-is.",
"push_notifications": "Background Notifications",
"push_description": "Receive notifications even when Vojo is closed or minimized.",
"push_permission_blocked": "Push notification permission was denied. Please enable it in your device settings.",
@ -365,6 +367,21 @@
"no_direct_messages": "No Direct Messages",
"no_direct_messages_desc": "You do not have any direct messages yet.",
"direct_message": "Direct Message",
"invite_accept": "Accept",
"invite_decline": "Decline",
"invite_kind_direct": "a private chat",
"invite_kind_group": "a group chat",
"invite_kind_space": "a space",
"invite_from_with_kind": "{{sender}} invites you to {{kind}}",
"invite_to_kind": "Invite to {{kind}}",
"invite_badge_direct": "Direct",
"invite_badge_group": "Group",
"invite_badge_space": "Space",
"invite_badge_encrypted": "Encrypted",
"invite_badge_spam": "Spam",
"invite_show_spam_one": "Show {{count}} hidden spam invite",
"invite_show_spam_other": "Show {{count}} hidden spam invites",
"invite_hide_spam": "Hide spam",
"create_chat": "Create Chat",
"create_chat_subtitle": "Start a private, encrypted chat by entering a username.",
"start_first_chat": "Start a chat",
@ -527,47 +544,6 @@
"member_joined_call": "<bold>{{user}}</bold> joined the call"
},
"Inbox": {
"inbox": "Inbox",
"invites": "Invites",
"notifications": "Notifications",
"notification_messages": "Notifications",
"filter": "Filter",
"all_notifications": "All Notifications",
"highlighted": "Highlighted",
"mark_as_read": "Mark as Read",
"open": "Open",
"no_notifications": "No Notifications",
"no_notifications_desc": "You don't have any new notifications to display yet.",
"scroll_to_top": "Scroll to Top",
"encrypted": "Encrypted",
"direct_message": "Direct Message",
"space": "Space",
"decline": "Decline",
"accept": "Accept",
"from": "From: ",
"reason_label": "Reason: ",
"primary": "Primary",
"public": "Public",
"spam": "Spam",
"no_invites": "No Invites",
"no_invites_known_desc": "When someone you share a room with sends you an invite, it'll show up here.",
"no_invites_unknown_desc": "Invites from people outside your rooms will appear here.",
"decline_all": "Decline All",
"spam_invites_count_one": "{{count}} Spam Invite",
"spam_invites_count_other": "{{count}} Spam Invites",
"spam_invites_desc": "Some of the following invites may contain harmful content or have been sent by banned users.",
"report_all": "Report All",
"block_all": "Block All",
"hide_all": "Hide All",
"view_all": "View All",
"no_spam_invites": "No Spam Invites",
"no_spam_invites_desc": "Invites detected as spam appear here.",
"invite_title": "Invite",
"user_id": "User ID",
"user_id_placeholder": "@username:server",

View file

@ -161,6 +161,8 @@
"enable": "Включить",
"notification_sound": "Звук уведомлений",
"notification_sound_desc": "Воспроизводить звук при получении нового сообщения.",
"invite_spam_filter": "Фильтр спам-приглашений",
"invite_spam_filter_desc": "Скрывать входящие приглашения в чаты, похожие на спам (массовые рассылки, заблокированные отправители, подозрительные слова). Выключите, чтобы видеть все приглашения как есть.",
"push_notifications": "Фоновые уведомления",
"push_description": "Получать уведомления даже когда Vojo свёрнут или закрыт.",
"push_permission_blocked": "Разрешение на push-уведомления отклонено. Включите его в настройках устройства.",
@ -365,6 +367,23 @@
"no_direct_messages": "Нет личных сообщений",
"no_direct_messages_desc": "У вас ещё нет личных сообщений.",
"direct_message": "Новый чат",
"invite_accept": "Принять",
"invite_decline": "Отклонить",
"invite_kind_direct": "личный чат",
"invite_kind_group": "групповой чат",
"invite_kind_space": "пространство",
"invite_from_with_kind": "{{sender}} приглашает в {{kind}}",
"invite_to_kind": "Приглашение в {{kind}}",
"invite_badge_direct": "Личные",
"invite_badge_group": "Группа",
"invite_badge_space": "Пространство",
"invite_badge_encrypted": "Шифрование",
"invite_badge_spam": "Спам",
"invite_show_spam_one": "Показать {{count}} скрытое приглашение",
"invite_show_spam_few": "Показать {{count}} скрытых приглашения",
"invite_show_spam_many": "Показать {{count}} скрытых приглашений",
"invite_show_spam_other": "Показать {{count}} скрытых приглашений",
"invite_hide_spam": "Скрыть спам",
"create_chat": "Новый чат",
"create_chat_subtitle": "Начните приватный зашифрованный чат, указав имя пользователя.",
"start_first_chat": "Начать чат",
@ -529,49 +548,6 @@
"member_joined_call": "<bold>{{user}}</bold> теперь в звонке"
},
"Inbox": {
"inbox": "Входящие",
"invites": "Приглашения",
"notifications": "Уведомления",
"notification_messages": "Уведомления",
"filter": "Фильтр",
"all_notifications": "Все уведомления",
"highlighted": "Выделенные",
"mark_as_read": "Отметить прочитанным",
"open": "Открыть",
"no_notifications": "Нет уведомлений",
"no_notifications_desc": "У вас пока нет новых уведомлений.",
"scroll_to_top": "Наверх",
"encrypted": "Зашифровано",
"direct_message": "Личное сообщение",
"space": "Пространство",
"decline": "Отклонить",
"accept": "Принять",
"from": "От: ",
"reason_label": "Причина: ",
"primary": "Основные",
"public": "Публичные",
"spam": "Спам",
"no_invites": "Нет приглашений",
"no_invites_known_desc": "Когда кто-то, с кем вы состоите в одной комнате, отправит вам приглашение, оно появится здесь.",
"no_invites_unknown_desc": "Приглашения от людей за пределами ваших комнат появятся здесь.",
"decline_all": "Отклонить все",
"spam_invites_count_one": "{{count}} спам-приглашение",
"spam_invites_count_few": "{{count}} спам-приглашения",
"spam_invites_count_many": "{{count}} спам-приглашений",
"spam_invites_count_other": "{{count}} спам-приглашений",
"spam_invites_desc": "Некоторые из этих приглашений могут содержать вредоносный контент или были отправлены забаненными пользователями.",
"report_all": "Пожаловаться на все",
"block_all": "Заблокировать всех",
"hide_all": "Скрыть все",
"view_all": "Показать все",
"no_spam_invites": "Нет спам-приглашений",
"no_spam_invites_desc": "Приглашения, распознанные как спам, появятся здесь.",
"invite_title": "Пригласить",
"user_id": "ID пользователя",
"user_id_placeholder": "@username:server",

View file

@ -0,0 +1,282 @@
import React, { useCallback } from 'react';
import { Room } from 'matrix-js-sdk/lib/models/room';
import { MatrixError } from 'matrix-js-sdk';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Avatar, Badge, Box, Button, Spinner, Text, color, toRem } from 'folds';
import { NavItem, NavItemContent } from '../../components/nav';
import { RoomAvatar } from '../../components/room-avatar';
import { nameInitials } from '../../utils/common';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import {
getDirectRoomAvatarUrl,
getMemberDisplayName,
getRoomAvatarUrl,
getStateEvent,
isDirectInvite,
isSpace,
} from '../../utils/room';
import {
addRoomIdToMDirect,
getCanonicalAliasOrRoomId,
getMxIdLocalPart,
guessDmRoomUserId,
} from '../../utils/matrix';
import { getDirectRoomPath } from '../../pages/pathUtils';
import { StateEvent } from '../../../types/matrix/room';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
const ROW_MIN_HEIGHT = toRem(68);
type DirectInviteRowProps = {
room: Room;
selected: boolean;
isSpam?: boolean;
};
export function DirectInviteRow({ room, selected, isSpam }: DirectInviteRowProps) {
const { t } = useTranslation();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const { navigateSpace } = useRoomNavigate();
const navigate = useNavigate();
const userId = mx.getSafeUserId();
const direct = isDirectInvite(room, userId);
const space = isSpace(room);
const encrypted = !!getStateEvent(room, StateEvent.RoomEncryption);
const avatar = direct
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
: getRoomAvatarUrl(mx, room, 96, useAuthentication);
const roomName = room.name || room.getCanonicalAlias() || room.roomId;
const member = room.getMember(userId);
const senderId = member?.events.member?.getSender();
let senderName: string | null = null;
if (senderId) {
senderName = getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
}
const topic =
getStateEvent(room, StateEvent.RoomTopic)?.getContent<{ topic?: string }>()?.topic ?? null;
let kindLabel: string;
if (space) {
kindLabel = t('Direct.invite_kind_space');
} else if (direct) {
kindLabel = t('Direct.invite_kind_direct');
} else {
kindLabel = t('Direct.invite_kind_group');
}
const [joinState, join] = useAsyncCallback<void, MatrixError, []>(
useCallback(async () => {
// m.direct is also written by the global useAutoDirectSync listener on
// the invite→join MyMembership transition; the call here lets the
// navigate() below land on a route that already sees the room as a DM,
// shaving one render. Both writes are idempotent.
const dmUserId = direct ? guessDmRoomUserId(room, userId) : undefined;
await mx.joinRoom(room.roomId);
if (dmUserId) {
await addRoomIdToMDirect(mx, room.roomId, dmUserId);
}
if (space) {
navigateSpace(room.roomId);
return;
}
const alias = getCanonicalAliasOrRoomId(mx, room.roomId);
navigate(getDirectRoomPath(alias));
}, [mx, room, direct, space, userId, navigate, navigateSpace])
);
const [leaveState, leave] = useAsyncCallback<Record<string, never>, MatrixError, []>(
useCallback(() => mx.leave(room.roomId), [mx, room.roomId])
);
const joining =
joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success;
const leaving =
leaveState.status === AsyncStatus.Loading || leaveState.status === AsyncStatus.Success;
const busy = joining || leaving;
let errMessage: string | null = null;
if (joinState.status === AsyncStatus.Error) {
errMessage = joinState.error.message;
} else if (leaveState.status === AsyncStatus.Error) {
errMessage = leaveState.error.message;
}
const inviteLine = senderName
? t('Direct.invite_from_with_kind', { sender: senderName, kind: kindLabel })
: t('Direct.invite_to_kind', { kind: kindLabel });
let kindBadge: React.ReactNode;
if (space) {
kindBadge = (
<Badge variant="Secondary" fill="Soft" radii="300" size="400">
<Text as="span" size="L400">
{t('Direct.invite_badge_space')}
</Text>
</Badge>
);
} else if (direct) {
kindBadge = (
<Badge variant="Primary" fill="Solid" radii="300" size="400">
<Text as="span" size="L400">
{t('Direct.invite_badge_direct')}
</Text>
</Badge>
);
} else {
kindBadge = (
<Badge variant="Secondary" fill="Solid" radii="300" size="400">
<Text as="span" size="L400">
{t('Direct.invite_badge_group')}
</Text>
</Badge>
);
}
const badgeRow = (
<Box as="span" alignItems="Center" gap="100" wrap="Wrap">
{kindBadge}
{encrypted && (
<Badge variant="Success" fill="Solid" radii="300" size="400">
<Text as="span" size="L400">
{t('Direct.invite_badge_encrypted')}
</Text>
</Badge>
)}
{isSpam && (
<Badge variant="Critical" fill="Solid" radii="300" size="400">
<Text as="span" size="L400">
{t('Direct.invite_badge_spam')}
</Text>
</Badge>
)}
</Box>
);
return (
<NavItem
variant="Background"
radii="400"
highlight={!isSpam}
aria-selected={selected}
style={{
minHeight: ROW_MIN_HEIGHT,
boxSizing: 'border-box',
opacity: isSpam ? 0.7 : 1,
}}
>
<NavItemContent>
<Box
as="span"
grow="Yes"
alignItems="Start"
gap="300"
style={{
minHeight: ROW_MIN_HEIGHT,
boxSizing: 'border-box',
padding: `${toRem(8)} 0`,
}}
>
<Avatar size="300" radii="400">
<RoomAvatar
roomId={room.roomId}
src={isSpam ? undefined : avatar}
alt={roomName}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(isSpam ? undefined : roomName)}
</Text>
)}
/>
</Avatar>
<Box
as="span"
direction="Column"
grow="Yes"
gap="100"
style={{ minWidth: 0, overflow: 'hidden' }}
>
<Box as="span" alignItems="Center" gap="200" style={{ minWidth: 0 }}>
<Box
as="span"
grow="Yes"
style={{ minWidth: 0, overflow: 'hidden' }}
>
<Text as="span" size="T300" truncate style={{ fontWeight: 600 }}>
{roomName}
</Text>
</Box>
<Box as="span" shrink="No">
{badgeRow}
</Box>
</Box>
<Text
as="span"
size="T200"
truncate
style={{
opacity: 0.7,
fontWeight: 400,
}}
>
{inviteLine}
</Text>
{topic && (
<Text
as="span"
size="T200"
truncate
style={{
opacity: 0.55,
fontWeight: 400,
fontStyle: 'italic',
}}
>
{topic}
</Text>
)}
{errMessage && (
<Text as="span" size="T200" style={{ color: color.Critical.Main }}>
{errMessage}
</Text>
)}
<Box as="span" alignItems="Center" gap="200" style={{ marginTop: toRem(4) }}>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
disabled={busy}
onClick={leave}
before={leaving ? <Spinner variant="Secondary" size="100" /> : undefined}
>
<Text as="span" size="B300">
{t('Direct.invite_decline')}
</Text>
</Button>
<Button
size="300"
variant="Success"
fill="Soft"
radii="300"
outlined
disabled={busy}
onClick={join}
before={joining ? <Spinner variant="Success" fill="Soft" size="100" /> : undefined}
>
<Text as="span" size="B300">
{t('Direct.invite_accept')}
</Text>
</Button>
</Box>
</Box>
</Box>
</NavItemContent>
</NavItem>
);
}

View file

@ -1,3 +1,4 @@
export * from './RoomNavItem';
export * from './RoomNavCategoryButton';
export * from './DmStreamRow';
export * from './DirectInviteRow';

View file

@ -175,6 +175,7 @@ export function SystemNotification() {
settingsAtom,
'isNotificationSounds'
);
const [inviteSpamFilter, setInviteSpamFilter] = useSetting(settingsAtom, 'inviteSpamFilter');
return (
<Box direction="Column" gap="100">
@ -199,6 +200,18 @@ export function SystemNotification() {
after={<Switch value={isNotificationSounds} onChange={setIsNotificationSounds} />}
/>
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title={t('Settings.invite_spam_filter')}
description={t('Settings.invite_spam_filter_desc')}
after={<Switch value={inviteSpamFilter} onChange={setInviteSpamFilter} />}
/>
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"

View file

@ -1,36 +0,0 @@
import { useMatch } from 'react-router-dom';
import {
getInboxInvitesPath,
getInboxNotificationsPath,
getInboxPath,
} from '../../pages/pathUtils';
export const useInboxSelected = (): boolean => {
const match = useMatch({
path: getInboxPath(),
caseSensitive: true,
end: false,
});
return !!match;
};
export const useInboxNotificationsSelected = (): boolean => {
const match = useMatch({
path: getInboxNotificationsPath(),
caseSensitive: true,
end: false,
});
return !!match;
};
export const useInboxInvitesSelected = (): boolean => {
const match = useMatch({
path: getInboxInvitesPath(),
caseSensitive: true,
end: false,
});
return !!match;
};

View file

@ -20,7 +20,7 @@ import {
unregisterPusher,
urlBase64ToUint8Array,
} from '../utils/push';
import { getDirectRoomPath, getInboxInvitesPath } from '../pages/pathUtils';
import { getDirectPath, getDirectRoomPath } from '../pages/pathUtils';
import { pendingCallActionAtom } from '../state/pendingCallAction';
import { useRoomNavigate } from './useRoomNavigate';
@ -303,7 +303,14 @@ export function usePushNotificationsLifecycle(): void {
// hops accumulate as N+ entries in our app back-stack (see
// useAndroidBackButton) — user presses back many times to exit one chat.
if (detail?.isInvite) {
navigate(getInboxInvitesPath(), { replace: true });
// Invites live inline in the Direct list — the row sits at the top
// until the user accepts/declines. Always land on the bare /direct/
// panel rather than /direct/{roomId}/ for an invite-state room: on
// mobile MobileFriendlyPageNav hides the panel for any non-root
// direct path, dropping the user into Room.tsx which has no
// membership gate and would render an empty stripped-state timeline
// with no Accept/Decline UI.
navigate(getDirectPath(), { replace: true });
return;
}
if (detail?.roomId) navigateRoom(detail.roomId, undefined, { replace: true });
@ -343,8 +350,25 @@ export function usePushNotificationsLifecycle(): void {
room_id?: string;
call_action?: 'answer' | 'decline';
notif_event_id?: string;
// Sygnal flattens nested fields with `_` separator; the Android
// FCM service forwards every data entry verbatim into the launch
// intent (VojoFirebaseMessagingService.java foreach), so these
// are reliably present for invite pushes.
type?: string;
content_membership?: string;
};
// Invite-state rooms must land on the bare /direct/ panel — the
// inline DirectInviteRow lives there with Accept/Decline. Native
// tap on an invite must NOT fall through to the generic room-id
// branch below (would route to /direct/{roomId}/, which has no
// membership gate path of its own; see DirectRouteRoomProvider for
// the second-line defence).
if (data.type === 'm.room.member' && data.content_membership === 'invite') {
navigate(getDirectPath(), { replace: true });
return;
}
// Native CallStyle Answer → open the room and queue a JS-side
// switch/start via
// pendingCallActionAtom. The consumer hook picks it up once the

View file

@ -1,7 +1,7 @@
import { ReactNode } from 'react';
import { useMatch } from 'react-router-dom';
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
import { BOTS_PATH, DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from './paths';
import { BOTS_PATH, DIRECT_PATH, EXPLORE_PATH, HOME_PATH, SPACE_PATH } from './paths';
type MobileFriendlyClientNavProps = {
children: ReactNode;
@ -12,12 +12,11 @@ export function MobileFriendlyClientNav({ children }: MobileFriendlyClientNavPro
const directMatch = useMatch({ path: DIRECT_PATH, caseSensitive: true, end: true });
const spaceMatch = useMatch({ path: SPACE_PATH, caseSensitive: true, end: true });
const exploreMatch = useMatch({ path: EXPLORE_PATH, caseSensitive: true, end: true });
const inboxMatch = useMatch({ path: INBOX_PATH, caseSensitive: true, end: true });
const botsMatch = useMatch({ path: BOTS_PATH, caseSensitive: true, end: true });
if (
screenSize === ScreenSize.Mobile &&
!(homeMatch || directMatch || spaceMatch || exploreMatch || inboxMatch || botsMatch)
!(homeMatch || directMatch || spaceMatch || exploreMatch || botsMatch)
) {
return null;
}

View file

@ -18,16 +18,13 @@ import {
EXPLORE_PATH,
HOME_PATH,
LOGIN_PATH,
INBOX_PATH,
REGISTER_PATH,
RESET_PASSWORD_PATH,
SPACE_PATH,
_CREATE_PATH,
_FEATURED_PATH,
_INVITES_PATH,
_JOIN_PATH,
_LOBBY_PATH,
_NOTIFICATIONS_PATH,
_ROOM_PATH,
_SEARCH_PATH,
_SERVER_PATH,
@ -41,7 +38,6 @@ import {
getDirectCreatePath,
getExploreFeaturedPath,
getHomePath,
getInboxNotificationsPath,
getLoginPath,
getOriginBaseUrl,
getSpaceLobbyPath,
@ -54,7 +50,6 @@ import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct';
import { BotExperienceHost, Bots } from './client/bots';
import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space';
import { Explore, FeaturedRooms, PublicRooms } from './client/explore';
import { Notifications, Inbox, Invites } from './client/inbox';
import { setAfterLoginRedirectPath } from './afterLoginRedirectPath';
import { Room } from '../features/room';
import { Lobby } from '../features/lobby';
@ -330,30 +325,10 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
</Route>
<Route path={CREATE_PATH} element={<Create />} />
<Route path={USER_LINK_PATH} element={<UserLinkRedirect />} />
<Route
path={INBOX_PATH}
element={
<PageRoot
nav={
<MobileFriendlyPageNav path={INBOX_PATH}>
<Inbox />
</MobileFriendlyPageNav>
}
>
<Outlet />
</PageRoot>
}
>
{mobile ? null : (
<Route
index
loader={() => redirect(getInboxNotificationsPath())}
element={<WelcomePage />}
/>
)}
<Route path={_NOTIFICATIONS_PATH} element={<Notifications />} />
<Route path={_INVITES_PATH} element={<Invites />} />
</Route>
{/* Legacy /inbox/ tree invites moved inline into the Direct list,
the Notifications aggregator was removed. Keep the route as a
redirect so old push deep-links and bookmarks resolve cleanly. */}
<Route path="/inbox/*" element={<Navigate to={DIRECT_PATH} replace />} />
</Route>
<Route path="/*" element={<p>Page not found</p>} />
</Route>

View file

@ -13,7 +13,6 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getNotificationType, getUnreadInfo, isNotificationEvent } from '../../utils/room';
import { NotificationType, UnreadInfo } from '../../../types/matrix/room';
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
import { usePushNotificationsLifecycle } from '../../hooks/usePushNotifications';
import { PushPermissionPrompt } from '../../components/push-permission-prompt';
import { FullScreenIntentPrompt } from '../../components/full-screen-intent-prompt';
@ -80,7 +79,6 @@ function MessageNotifications() {
const mx = useMatrixClient();
const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
const notificationSelected = useInboxNotificationsSelected();
const selectedRoomId = useSelectedRoom();
const playSound = useCallback(() => {
@ -97,7 +95,7 @@ function MessageNotifications() {
data
) => {
if (mx.getSyncState() !== 'SYNCING') return;
if (document.hasFocus() && (selectedRoomId === room?.roomId || notificationSelected)) return;
if (document.hasFocus() && selectedRoomId === room?.roomId) return;
if (
!room ||
!data.liveEvent ||
@ -131,7 +129,7 @@ function MessageNotifications() {
return () => {
mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
};
}, [mx, notificationSound, notificationSelected, playSound, selectedRoomId]);
}, [mx, notificationSound, playSound, selectedRoomId]);
return (
// eslint-disable-next-line jsx-a11y/media-has-caption

View file

@ -10,7 +10,6 @@ import {
import {
DirectTab,
SpaceTabs,
InboxTab,
ExploreTab,
SettingsTab,
UnverifiedTab,
@ -43,7 +42,6 @@ export function SidebarNav() {
<SidebarStack>
<SearchTab />
<UnverifiedTab />
<InboxTab />
<SettingsTab />
</SidebarStack>
</>

View file

@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAtomValue } from 'jotai';
import { Box, Button, Icon, Icons, Text, color, config, toRem } from 'folds';
@ -11,10 +11,11 @@ import { getDirectCreatePath, getDirectRoomPath } from '../../pathUtils';
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import { VirtualTile } from '../../../components/virtualizer';
import { DmStreamRow } from '../../../features/room-nav';
import { DirectInviteRow, DmStreamRow } from '../../../features/room-nav';
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
import { useDirectRooms } from './useDirectRooms';
import { useDirectInvites, DirectInviteEntry } from './useDirectInvites';
import { PageNav, PageNavContent } from '../../../components/page';
import {
getRoomNotificationMode,
@ -26,6 +27,12 @@ import { DirectSelfRow } from './DirectSelfRow';
const MONO_FONT = '"JetBrains Mono Variable", ui-monospace, monospace';
type ListItem =
| { kind: 'invite'; entry: DirectInviteEntry }
| { kind: 'spam-toggle'; spamCount: number; expanded: boolean }
| { kind: 'spam-invite'; entry: DirectInviteEntry }
| { kind: 'direct'; roomId: string };
function DirectEmpty() {
const { t } = useTranslation();
const navigate = useNavigate();
@ -88,12 +95,52 @@ function DirectFooterStatus() {
);
}
type SpamToggleRowProps = {
spamCount: number;
expanded: boolean;
onToggle: () => void;
};
function SpamToggleRow({ spamCount, expanded, onToggle }: SpamToggleRowProps) {
const { t } = useTranslation();
return (
<Box
as="button"
type="button"
onClick={onToggle}
alignItems="Center"
gap="200"
aria-expanded={expanded}
style={{
appearance: 'none',
border: 'none',
background: 'transparent',
cursor: 'pointer',
padding: `${toRem(8)} ${toRem(12)}`,
width: '100%',
color: color.Surface.OnContainer,
opacity: 0.7,
textAlign: 'left',
font: 'inherit',
}}
>
<Icon size="100" src={expanded ? Icons.ChevronTop : Icons.ChevronBottom} />
<Text as="span" size="T200" style={{ fontWeight: 500 }}>
{expanded
? t('Direct.invite_hide_spam')
: t('Direct.invite_show_spam', { count: spamCount })}
</Text>
</Box>
);
}
export function Direct() {
const mx = useMatrixClient();
useNavToActivePathMapper('direct');
const scrollRef = useRef<HTMLDivElement>(null);
const directs = useDirectRooms();
const invites = useDirectInvites();
const notificationPreferences = useRoomsNotificationPreferencesContext();
const [spamExpanded, setSpamExpanded] = useState(false);
// roomToUnreadAtom only changes on read/unread transitions and ignores own
// events — covers incoming notifying messages but not own sends or muted
// incoming. Kept as a subscribe-only re-render trigger.
@ -130,16 +177,56 @@ export function Direct() {
}, [mx, directs]);
const selectedRoomId = useSelectedRoom();
const noRoomToDisplay = directs.length === 0;
// Sort each render — small list, getLastActiveTimestamp changes outside
// React's dep model so memoising would need a manual trigger anyway.
const sortedDirects = Array.from(directs).sort(factoryRoomIdByActivity(mx));
const items = useMemo<ListItem[]>(() => {
const list: ListItem[] = [];
const cleanInvites = invites.filter((i) => !i.isSpam);
const spamInvites = invites.filter((i) => i.isSpam);
cleanInvites.forEach((entry) => list.push({ kind: 'invite', entry }));
if (spamInvites.length > 0) {
list.push({
kind: 'spam-toggle',
spamCount: spamInvites.length,
expanded: spamExpanded,
});
if (spamExpanded) {
spamInvites.forEach((entry) => list.push({ kind: 'spam-invite', entry }));
}
}
const sortedDirects = Array.from(directs).sort(factoryRoomIdByActivity(mx));
sortedDirects.forEach((roomId) => list.push({ kind: 'direct', roomId }));
return list;
}, [invites, directs, spamExpanded, mx]);
const noRoomToDisplay = items.length === 0;
const virtualizer = useVirtualizer({
count: sortedDirects.length,
count: items.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 68,
// Per-kind estimates so the initial scroll height is close to actual
// before measureElement self-corrects: invite cards are ~140px (header +
// sub-line + buttons + optional topic), spam toggle is ~32px, DM rows
// ~68px. Keeps first paint stable when the panel opens with invites.
estimateSize: (index) => {
const item = items[index];
if (!item) return 68;
if (item.kind === 'invite' || item.kind === 'spam-invite') return 140;
if (item.kind === 'spam-toggle') return 32;
return 68;
},
// Stable per-item identity so the measurement cache survives item-kind
// shifts at the same index. Without this TanStack falls back to index,
// and a DM row at index 0 inheriting an invite card's measured height
// (or vice versa) flashes a wrong size for one frame on every list
// mutation that adds/removes an invite at the top.
getItemKey: (index) => {
const item = items[index];
if (!item) return index;
if (item.kind === 'invite') return `invite:${item.entry.roomId}`;
if (item.kind === 'spam-invite') return `spam-invite:${item.entry.roomId}`;
if (item.kind === 'spam-toggle') return 'spam-toggle';
return `direct:${item.roomId}`;
},
overscan: 10,
});
@ -159,15 +246,52 @@ export function Direct() {
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const roomId = sortedDirects[vItem.index];
const item = items[vItem.index];
if (!item) return null;
if (item.kind === 'invite' || item.kind === 'spam-invite') {
const { entry } = item;
const selected = selectedRoomId === entry.roomId;
return (
<VirtualTile
virtualItem={vItem}
key={`invite-${entry.roomId}`}
ref={virtualizer.measureElement}
>
<DirectInviteRow
room={entry.room}
selected={selected}
isSpam={item.kind === 'spam-invite'}
/>
</VirtualTile>
);
}
if (item.kind === 'spam-toggle') {
return (
<VirtualTile
virtualItem={vItem}
key="spam-toggle"
ref={virtualizer.measureElement}
>
<SpamToggleRow
spamCount={item.spamCount}
expanded={item.expanded}
onToggle={() => setSpamExpanded((v) => !v)}
/>
</VirtualTile>
);
}
// kind === 'direct'
const { roomId } = item;
const room = mx.getRoom(roomId);
if (!room) return null;
const selected = selectedRoomId === roomId;
return (
<VirtualTile
virtualItem={vItem}
key={vItem.index}
key={`direct-${roomId}`}
ref={virtualizer.measureElement}
>
<DmStreamRow

View file

@ -1,12 +1,14 @@
import React, { ReactNode } from 'react';
import { Room } from 'matrix-js-sdk';
import { useParams } from 'react-router-dom';
import { Navigate, useParams } from 'react-router-dom';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import { IsOneOnOneProvider, RoomProvider } from '../../../hooks/useRoom';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
import { isSpace } from '../../../utils/room';
import { useIsOneOnOneRoom } from '../../../hooks/useIsOneOnOneRoom';
import { Membership } from '../../../../types/matrix/room';
import { getDirectPath } from '../../pathUtils';
// Inner provider hosts the reactive 1:1 subscription. Hooks can't run inside
// the early-return path of the parent, so we split the component once `room`
@ -27,6 +29,15 @@ export function DirectRouteRoomProvider({ children }: { children: ReactNode }) {
const roomId = useSelectedRoom();
const room = mx.getRoom(roomId);
// Central membership guard: invite-state rooms have no useful Room view —
// the Accept/Decline UI lives on the inline DirectInviteRow in the panel.
// Push deep links and legacy /home/{roomId}/ shim can both deliver an
// invite-room id here; bounce to the bare /direct/ list so the user lands
// on the row instead of an empty stripped-state timeline.
if (room && room.getMyMembership() === Membership.Invite) {
return <Navigate to={getDirectPath()} replace />;
}
// After P3c the Direct tab is universal — any joined non-space room renders
// here. Spaces (= future Channels) keep their own /{spaceId}/ route.
if (!room || isSpace(room)) {

View file

@ -1,3 +1,4 @@
export * from './Direct';
export * from './RoomProvider';
export * from './DirectCreate';
export * from './useDirectInvites';

View file

@ -0,0 +1,97 @@
import { useAtomValue } from 'jotai';
import { useMemo } from 'react';
import { Room } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { allInvitesAtom } from '../../../state/room-list/inviteList';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { bannedInRooms, getMemberDisplayName, getStateEvent } from '../../../utils/room';
import { testBadWords } from '../../../plugins/bad-words';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { getMxIdLocalPart } from '../../../utils/matrix';
import { StateEvent } from '../../../../types/matrix/room';
export type DirectInviteEntry = {
room: Room;
roomId: string;
ts: number;
isSpam: boolean;
};
const getInviteTs = (room: Room, myUserId: string): number => {
const me = room.getMember(myUserId);
return me?.events.member?.getTs() ?? 0;
};
const inviteHasBadWords = (room: Room, myUserId: string): boolean => {
const roomName = room.name || '';
const topic =
getStateEvent(room, StateEvent.RoomTopic)?.getContent<{ topic?: string }>()?.topic ?? '';
const me = room.getMember(myUserId);
const memberEvent = me?.events.member;
const senderId = memberEvent?.getSender() ?? '';
const senderName = senderId
? getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId
: '';
const reasonContent = memberEvent?.getContent();
const reason =
reasonContent && 'reason' in reasonContent && typeof reasonContent.reason === 'string'
? reasonContent.reason
: '';
return (
testBadWords(roomName) ||
testBadWords(topic) ||
testBadWords(senderName) ||
testBadWords(senderId) ||
testBadWords(reason)
);
};
export const useDirectInvites = (): DirectInviteEntry[] => {
const mx = useMatrixClient();
const inviteIds = useAtomValue(allInvitesAtom);
const allRooms = useAtomValue(allRoomsAtom);
const [spamFilterEnabled] = useSetting(settingsAtom, 'inviteSpamFilter');
const myUserId = mx.getSafeUserId();
return useMemo(() => {
// Cache `bannedInRooms` per senderId — a single inviter MXID can be the
// sender of multiple invite rooms, and bannedInRooms iterates every
// joined room. Without the cache we'd be O(invites × joinedRooms) on
// every recompute.
const bannedCache = new Map<string, boolean>();
const isSenderBanned = (senderId: string): boolean => {
const cached = bannedCache.get(senderId);
if (cached !== undefined) return cached;
const banned = bannedInRooms(mx, allRooms, senderId);
bannedCache.set(senderId, banned);
return banned;
};
const out: DirectInviteEntry[] = [];
inviteIds.forEach((roomId) => {
const room = mx.getRoom(roomId);
if (!room) return;
const me = room.getMember(myUserId);
const senderId = me?.events.member?.getSender() ?? '';
// Moderation signal — sender is banned in a room we share. This is a
// server-side moderator's verdict, not a personal preference, so it's
// ALWAYS classified as spam regardless of the user-facing toggle.
const moderationSpam = !!senderId && isSenderBanned(senderId);
// Lexical signal — bad-words match in the room name / topic / sender /
// reason. This is heuristic and noisy, so it's gated on the user-facing
// `inviteSpamFilter` toggle for users who'd rather see everything raw.
const lexicalSpam = spamFilterEnabled && inviteHasBadWords(room, myUserId);
const isSpam = moderationSpam || lexicalSpam;
out.push({
room,
roomId,
ts: getInviteTs(room, myUserId),
isSpam,
});
});
out.sort((a, b) => b.ts - a.ts);
return out;
}, [mx, inviteIds, allRooms, myUserId, spamFilterEnabled]);
};

View file

@ -1,90 +0,0 @@
import React from 'react';
import { Avatar, Box, Icon, Icons, Text } from 'folds';
import { useTranslation } from 'react-i18next';
import { useAtomValue } from 'jotai';
import { NavCategory, NavItem, NavItemContent, NavLink } from '../../../components/nav';
import { getInboxInvitesPath, getInboxNotificationsPath } from '../../pathUtils';
import {
useInboxInvitesSelected,
useInboxNotificationsSelected,
} from '../../../hooks/router/useInbox';
import { UnreadBadge } from '../../../components/unread-badge';
import { allInvitesAtom } from '../../../state/room-list/inviteList';
import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page';
function InvitesNavItem() {
const { t } = useTranslation();
const invitesSelected = useInboxInvitesSelected();
const allInvites = useAtomValue(allInvitesAtom);
const inviteCount = allInvites.length;
return (
<NavItem
variant="Background"
radii="400"
highlight={inviteCount > 0}
aria-selected={invitesSelected}
>
<NavLink to={getInboxInvitesPath()}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Mail} size="100" filled={invitesSelected} />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
{t('Inbox.invites')}
</Text>
</Box>
{inviteCount > 0 && <UnreadBadge highlight count={inviteCount} />}
</Box>
</NavItemContent>
</NavLink>
</NavItem>
);
}
export function Inbox() {
const { t } = useTranslation();
useNavToActivePathMapper('inbox');
const notificationsSelected = useInboxNotificationsSelected();
return (
<PageNav>
<PageNavHeader>
<Box grow="Yes" gap="300">
<Box grow="Yes">
<Text size="H4" truncate>
{t('Inbox.inbox')}
</Text>
</Box>
</Box>
</PageNavHeader>
<PageNavContent>
<Box direction="Column" gap="300">
<NavCategory>
<NavItem variant="Background" radii="400" aria-selected={notificationsSelected}>
<NavLink to={getInboxNotificationsPath()}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.MessageUnread} size="100" filled={notificationsSelected} />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
{t('Inbox.notifications')}
</Text>
</Box>
</Box>
</NavItemContent>
</NavLink>
</NavItem>
<InvitesNavItem />
</NavCategory>
</Box>
</PageNavContent>
</PageNav>
);
}

View file

@ -1,840 +0,0 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import {
Avatar,
Badge,
Box,
Button,
Chip,
Icon,
IconButton,
Icons,
Overlay,
OverlayBackdrop,
OverlayCenter,
Scroll,
Spinner,
Text,
color,
config,
} from 'folds';
import { useAtomValue } from 'jotai';
import { useTranslation } from 'react-i18next';
import { RoomTopicEventContent } from 'matrix-js-sdk/lib/types';
import FocusTrap from 'focus-trap-react';
import { MatrixClient, MatrixError, Room } from 'matrix-js-sdk';
import { useNavigate } from 'react-router-dom';
import {
Page,
PageContent,
PageContentCenter,
PageHeader,
PageHero,
PageHeroEmpty,
PageHeroSection,
} from '../../../components/page';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { allInvitesAtom } from '../../../state/room-list/inviteList';
import { SequenceCard } from '../../../components/sequence-card';
import {
bannedInRooms,
getCommonRooms,
getDirectRoomAvatarUrl,
getMemberDisplayName,
getRoomAvatarUrl,
getStateEvent,
isDirectInvite,
isSpace,
} from '../../../utils/room';
import { nameInitials } from '../../../utils/common';
import { RoomAvatar } from '../../../components/room-avatar';
import {
addRoomIdToMDirect,
getCanonicalAliasOrRoomId,
getMxIdLocalPart,
guessDmRoomUserId,
rateLimitedActions,
} from '../../../utils/matrix';
import { getDirectRoomPath } from '../../pathUtils';
import { Time } from '../../../components/message';
import { useElementSizeObserver } from '../../../hooks/useElementSizeObserver';
import { onEnterOrSpace, stopPropagation } from '../../../utils/keyboard';
import { RoomTopicViewer } from '../../../components/room-topic-viewer';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { BackRouteHandler } from '../../../components/BackRouteHandler';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { StateEvent } from '../../../../types/matrix/room';
import { testBadWords } from '../../../plugins/bad-words';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers';
import { useReportRoomSupported } from '../../../hooks/useReportRoomSupported';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
const COMPACT_CARD_WIDTH = 548;
type InviteData = {
room: Room;
roomId: string;
roomName: string;
roomAvatar?: string;
roomTopic?: string;
roomAlias?: string;
senderId: string;
senderName: string;
inviteTs?: number;
reason?: string;
isSpace: boolean;
isDirect: boolean;
isEncrypted: boolean;
};
const makeInviteData = (mx: MatrixClient, room: Room, useAuthentication: boolean): InviteData => {
const userId = mx.getSafeUserId();
const direct = isDirectInvite(room, userId);
const roomAvatar = direct
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
: getRoomAvatarUrl(mx, room, 96, useAuthentication);
const roomName = room.name || room.getCanonicalAlias() || room.roomId;
const roomTopic =
getStateEvent(room, StateEvent.RoomTopic)?.getContent<RoomTopicEventContent>()?.topic ??
undefined;
const member = room.getMember(userId);
const memberEvent = member?.events.member;
const content = memberEvent?.getContent();
const senderId = memberEvent?.getSender();
const senderName = senderId
? getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId
: undefined;
const inviteTs = memberEvent?.getTs();
const reason =
content && 'reason' in content && typeof content.reason === 'string'
? content.reason
: undefined;
return {
room,
roomId: room.roomId,
roomAvatar,
roomName,
roomTopic,
roomAlias: room.getCanonicalAlias() ?? undefined,
senderId: senderId ?? 'Unknown',
senderName: senderName ?? 'Unknown',
inviteTs,
reason,
isSpace: isSpace(room),
isDirect: direct,
isEncrypted: !!getStateEvent(room, StateEvent.RoomEncryption),
};
};
const hasBadWords = (invite: InviteData): boolean =>
testBadWords(invite.roomName) ||
testBadWords(invite.roomTopic ?? '') ||
testBadWords(invite.senderName) ||
testBadWords(invite.senderId) ||
testBadWords(invite.reason || '');
type NavigateHandler = (roomId: string, space: boolean) => void;
type InviteCardProps = {
invite: InviteData;
compact?: boolean;
hour24Clock: boolean;
dateFormatString: string;
onNavigate: NavigateHandler;
hideAvatar: boolean;
};
function InviteCard({
invite,
compact,
hour24Clock,
dateFormatString,
onNavigate,
hideAvatar,
}: InviteCardProps) {
const { t } = useTranslation();
const mx = useMatrixClient();
const navigate = useNavigate();
const userId = mx.getSafeUserId();
const [viewTopic, setViewTopic] = useState(false);
const closeTopic = () => setViewTopic(false);
const openTopic = () => setViewTopic(true);
const [joinState, join] = useAsyncCallback<void, MatrixError, []>(
useCallback(async () => {
const dmUserId = isDirectInvite(invite.room, userId)
? guessDmRoomUserId(invite.room, userId)
: undefined;
await mx.joinRoom(invite.roomId);
if (dmUserId) {
await addRoomIdToMDirect(mx, invite.roomId, dmUserId);
const alias = getCanonicalAliasOrRoomId(mx, invite.roomId);
navigate(getDirectRoomPath(alias));
return;
}
onNavigate(invite.roomId, invite.isSpace);
}, [mx, invite, userId, onNavigate, navigate])
);
const [leaveState, leave] = useAsyncCallback<Record<string, never>, MatrixError, []>(
useCallback(() => mx.leave(invite.roomId), [mx, invite])
);
const joining =
joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success;
const leaving =
leaveState.status === AsyncStatus.Loading || leaveState.status === AsyncStatus.Success;
return (
<SequenceCard
variant="SurfaceVariant"
direction="Column"
gap="300"
style={{ padding: config.space.S400 }}
>
{(invite.isEncrypted || invite.isDirect || invite.isSpace) && (
<Box gap="200" alignItems="Center">
{invite.isEncrypted && (
<Box shrink="No" alignItems="Center" justifyContent="Center">
<Badge variant="Success" fill="Solid" size="400" radii="300">
<Text size="L400">{t('Inbox.encrypted')}</Text>
</Badge>
</Box>
)}
{invite.isDirect && (
<Box shrink="No" alignItems="Center" justifyContent="Center">
<Badge variant="Primary" fill="Solid" size="400" radii="300">
<Text size="L400">{t('Inbox.direct_message')}</Text>
</Badge>
</Box>
)}
{invite.isSpace && (
<Box shrink="No" alignItems="Center" justifyContent="Center">
<Badge variant="Secondary" fill="Soft" size="400" radii="300">
<Text size="L400">{t('Inbox.space')}</Text>
</Badge>
</Box>
)}
</Box>
)}
<Box gap="300">
<Avatar size="300">
<RoomAvatar
roomId={invite.roomId}
src={hideAvatar ? undefined : invite.roomAvatar}
alt={invite.roomName}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(hideAvatar && invite.roomAvatar ? undefined : invite.roomName)}
</Text>
)}
/>
</Avatar>
<Box direction={compact ? 'Column' : 'Row'} grow="Yes" gap="200">
<Box grow="Yes" direction="Column" gap="200">
<Box direction="Column">
<Text size="T300" truncate>
<b>{invite.roomName}</b>
</Text>
{invite.roomTopic && (
<Text
size="T200"
onClick={openTopic}
onKeyDown={onEnterOrSpace(openTopic)}
tabIndex={0}
truncate
>
{invite.roomTopic}
</Text>
)}
<Overlay open={viewTopic} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,
onDeactivate: closeTopic,
escapeDeactivates: stopPropagation,
}}
>
<RoomTopicViewer
name={invite.roomName}
topic={invite.roomTopic ?? ''}
requestClose={closeTopic}
/>
</FocusTrap>
</OverlayCenter>
</Overlay>
</Box>
{joinState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
{joinState.error.message}
</Text>
)}
{leaveState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
{leaveState.error.message}
</Text>
)}
</Box>
<Box gap="200" shrink="No" alignItems="Center">
<Button
onClick={leave}
size="300"
variant="Secondary"
radii="300"
fill="Soft"
disabled={joining || leaving}
before={leaving ? <Spinner variant="Secondary" size="100" /> : undefined}
>
<Text size="B300">{t('Inbox.decline')}</Text>
</Button>
<Button
onClick={join}
size="300"
variant="Success"
fill="Soft"
radii="300"
outlined
disabled={joining || leaving}
before={joining ? <Spinner variant="Success" fill="Soft" size="100" /> : undefined}
>
<Text size="B300">{t('Inbox.accept')}</Text>
</Button>
</Box>
</Box>
</Box>
<Box direction="Column">
<Box gap="200" alignItems="Baseline">
<Box grow="Yes">
<Text size="T200" priority="300">
{t('Inbox.from')}
<b>{invite.senderId}</b>
</Text>
</Box>
{typeof invite.inviteTs === 'number' && invite.inviteTs !== 0 && (
<Box shrink="No">
<Time
size="T200"
ts={invite.inviteTs}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
priority="300"
/>
</Box>
)}
</Box>
{invite.reason && (
<Text size="T200" priority="300">
{t('Inbox.reason_label')}
{invite.reason}
</Text>
)}
</Box>
</SequenceCard>
);
}
enum InviteFilter {
Known,
Unknown,
Spam,
}
type InviteFiltersProps = {
filter: InviteFilter;
onFilter: (filter: InviteFilter) => void;
knownInvites: InviteData[];
unknownInvites: InviteData[];
spamInvites: InviteData[];
};
function InviteFilters({
filter,
onFilter,
knownInvites,
unknownInvites,
spamInvites,
}: InviteFiltersProps) {
const { t } = useTranslation();
const isKnown = filter === InviteFilter.Known;
const isUnknown = filter === InviteFilter.Unknown;
const isSpam = filter === InviteFilter.Spam;
return (
<Box gap="200">
<Chip
variant={isKnown ? 'Success' : 'Surface'}
aria-selected={isKnown}
outlined={!isKnown}
onClick={() => onFilter(InviteFilter.Known)}
before={isKnown && <Icon size="100" src={Icons.Check} />}
after={
knownInvites.length > 0 && (
<Badge variant={isKnown ? 'Success' : 'Secondary'} fill="Solid" radii="Pill">
<Text size="L400">{knownInvites.length}</Text>
</Badge>
)
}
>
<Text size="T200">{t('Inbox.primary')}</Text>
</Chip>
<Chip
variant={isUnknown ? 'Warning' : 'Surface'}
aria-selected={isUnknown}
outlined={!isUnknown}
onClick={() => onFilter(InviteFilter.Unknown)}
before={isUnknown && <Icon size="100" src={Icons.Check} />}
after={
unknownInvites.length > 0 && (
<Badge variant={isUnknown ? 'Warning' : 'Secondary'} fill="Solid" radii="Pill">
<Text size="L400">{unknownInvites.length}</Text>
</Badge>
)
}
>
<Text size="T200">{t('Inbox.public')}</Text>
</Chip>
<Chip
variant={isSpam ? 'Critical' : 'Surface'}
aria-selected={isSpam}
outlined={!isSpam}
onClick={() => onFilter(InviteFilter.Spam)}
before={isSpam && <Icon size="100" src={Icons.Check} />}
after={
spamInvites.length > 0 && (
<Badge variant={isSpam ? 'Critical' : 'Secondary'} fill="Solid" radii="Pill">
<Text size="L400">{spamInvites.length}</Text>
</Badge>
)
}
>
<Text size="T200">{t('Inbox.spam')}</Text>
</Chip>
</Box>
);
}
type KnownInvitesProps = {
invites: InviteData[];
handleNavigate: NavigateHandler;
compact: boolean;
hour24Clock: boolean;
dateFormatString: string;
};
function KnownInvites({
invites,
handleNavigate,
compact,
hour24Clock,
dateFormatString,
}: KnownInvitesProps) {
const { t } = useTranslation();
return (
<Box direction="Column" gap="200">
<Text size="H4">{t('Inbox.primary')}</Text>
{invites.length > 0 ? (
<Box direction="Column" gap="100">
{invites.map((invite) => (
<InviteCard
key={invite.roomId}
invite={invite}
compact={compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
onNavigate={handleNavigate}
hideAvatar={false}
/>
))}
</Box>
) : (
<PageHeroEmpty>
<PageHeroSection>
<PageHero
icon={<Icon size="600" src={Icons.Mail} />}
title={t('Inbox.no_invites')}
subTitle={t('Inbox.no_invites_known_desc')}
/>
</PageHeroSection>
</PageHeroEmpty>
)}
</Box>
);
}
type UnknownInvitesProps = {
invites: InviteData[];
handleNavigate: NavigateHandler;
compact: boolean;
hour24Clock: boolean;
dateFormatString: string;
};
function UnknownInvites({
invites,
handleNavigate,
compact,
hour24Clock,
dateFormatString,
}: UnknownInvitesProps) {
const { t } = useTranslation();
const mx = useMatrixClient();
const [declineAllStatus, declineAll] = useAsyncCallback(
useCallback(async () => {
const roomIds = invites.map((invite) => invite.roomId);
await rateLimitedActions(roomIds, (roomId) => mx.leave(roomId));
}, [mx, invites])
);
const declining = declineAllStatus.status === AsyncStatus.Loading;
return (
<Box direction="Column" gap="200">
<Box gap="200" justifyContent="SpaceBetween" alignItems="Center">
<Text size="H4">{t('Inbox.public')}</Text>
<Box>
{invites.length > 0 && (
<Chip
variant="SurfaceVariant"
onClick={declineAll}
before={declining && <Spinner size="50" variant="Secondary" fill="Soft" />}
disabled={declining}
radii="Pill"
>
<Text size="T200">{t('Inbox.decline_all')}</Text>
</Chip>
)}
</Box>
</Box>
{invites.length > 0 ? (
<Box direction="Column" gap="100">
{invites.map((invite) => (
<InviteCard
key={invite.roomId}
invite={invite}
compact={compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
onNavigate={handleNavigate}
hideAvatar
/>
))}
</Box>
) : (
<PageHeroEmpty>
<PageHeroSection>
<PageHero
icon={<Icon size="600" src={Icons.Info} />}
title={t('Inbox.no_invites')}
subTitle={t('Inbox.no_invites_unknown_desc')}
/>
</PageHeroSection>
</PageHeroEmpty>
)}
</Box>
);
}
type SpamInvitesProps = {
invites: InviteData[];
handleNavigate: NavigateHandler;
compact: boolean;
hour24Clock: boolean;
dateFormatString: string;
};
function SpamInvites({
invites,
handleNavigate,
compact,
hour24Clock,
dateFormatString,
}: SpamInvitesProps) {
const { t } = useTranslation();
const mx = useMatrixClient();
const [showInvites, setShowInvites] = useState(false);
const reportRoomSupported = useReportRoomSupported();
const [declineAllStatus, declineAll] = useAsyncCallback(
useCallback(async () => {
const roomIds = invites.map((invite) => invite.roomId);
await rateLimitedActions(roomIds, (roomId) => mx.leave(roomId));
}, [mx, invites])
);
const [reportAllStatus, reportAll] = useAsyncCallback(
useCallback(async () => {
const roomIds = invites.map((invite) => invite.roomId);
await rateLimitedActions(roomIds, (roomId) => mx.reportRoom(roomId, 'Spam Invite'));
}, [mx, invites])
);
const ignoredUsers = useIgnoredUsers();
const unignoredUsers = Array.from(new Set(invites.map((invite) => invite.senderId))).filter(
(user) => !ignoredUsers.includes(user)
);
const [blockAllStatus, blockAll] = useAsyncCallback(
useCallback(
() => mx.setIgnoredUsers([...ignoredUsers, ...unignoredUsers]),
[mx, ignoredUsers, unignoredUsers]
)
);
const declining = declineAllStatus.status === AsyncStatus.Loading;
const reporting = reportAllStatus.status === AsyncStatus.Loading;
const blocking = blockAllStatus.status === AsyncStatus.Loading;
const loading = blocking || reporting || declining;
return (
<Box direction="Column" gap="200">
<Text size="H4">{t('Inbox.spam')}</Text>
{invites.length > 0 ? (
<Box direction="Column" gap="100">
<SequenceCard
variant="SurfaceVariant"
direction="Column"
gap="300"
style={{ padding: `${config.space.S400} ${config.space.S400} 0` }}
>
<PageHeroSection>
<PageHero
icon={<Icon size="600" src={Icons.Warning} />}
title={t('Inbox.spam_invites_count', { count: invites.length })}
subTitle={t('Inbox.spam_invites_desc')}
>
<Box direction="Row" gap="200" justifyContent="Center" wrap="Wrap">
<Button
size="300"
variant="Critical"
fill="Solid"
radii="300"
onClick={declineAll}
before={declining && <Spinner size="100" variant="Critical" fill="Solid" />}
disabled={loading}
>
<Text size="B300" truncate>
{t('Inbox.decline_all')}
</Text>
</Button>
{reportRoomSupported && reportAllStatus.status !== AsyncStatus.Success && (
<Button
size="300"
variant="Secondary"
fill="Solid"
radii="300"
onClick={reportAll}
before={reporting && <Spinner size="100" variant="Secondary" fill="Solid" />}
disabled={loading}
>
<Text size="B300" truncate>
{t('Inbox.report_all')}
</Text>
</Button>
)}
{unignoredUsers.length > 0 && (
<Button
size="300"
variant="Secondary"
fill="Solid"
radii="300"
disabled={loading}
onClick={blockAll}
before={blocking && <Spinner size="100" variant="Secondary" fill="Solid" />}
>
<Text size="B300" truncate>
{t('Inbox.block_all')}
</Text>
</Button>
)}
</Box>
<span data-spacing-node />
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="Pill"
before={
<Icon size="100" src={showInvites ? Icons.ChevronTop : Icons.ChevronBottom} />
}
onClick={() => setShowInvites(!showInvites)}
>
<Text size="B300">{showInvites ? t('Inbox.hide_all') : t('Inbox.view_all')}</Text>
</Button>
</PageHero>
</PageHeroSection>
</SequenceCard>
{showInvites &&
invites.map((invite) => (
<InviteCard
key={invite.roomId}
invite={invite}
compact={compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
onNavigate={handleNavigate}
hideAvatar
/>
))}
</Box>
) : (
<PageHeroEmpty>
<PageHeroSection>
<PageHero
icon={<Icon size="600" src={Icons.Warning} />}
title={t('Inbox.no_spam_invites')}
subTitle={t('Inbox.no_spam_invites_desc')}
/>
</PageHeroSection>
</PageHeroEmpty>
)}
</Box>
);
}
export function Invites() {
const { t } = useTranslation();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const { navigateRoom, navigateSpace } = useRoomNavigate();
const allRooms = useAtomValue(allRoomsAtom);
const allInviteIds = useAtomValue(allInvitesAtom);
const [filter, setFilter] = useState(InviteFilter.Known);
const invitesData = allInviteIds
.map((inviteId) => mx.getRoom(inviteId))
.filter((inviteRoom) => !!inviteRoom)
.map((inviteRoom) => makeInviteData(mx, inviteRoom, useAuthentication));
const [knownInvites, unknownInvites, spamInvites] = useMemo(() => {
const known: InviteData[] = [];
const unknown: InviteData[] = [];
const spam: InviteData[] = [];
invitesData.forEach((invite) => {
if (hasBadWords(invite) || bannedInRooms(mx, allRooms, invite.senderId)) {
spam.push(invite);
return;
}
if (getCommonRooms(mx, allRooms, invite.senderId).length === 0) {
unknown.push(invite);
return;
}
known.push(invite);
});
return [known, unknown, spam];
}, [mx, allRooms, invitesData]);
const containerRef = useRef<HTMLDivElement>(null);
const [compact, setCompact] = useState(document.body.clientWidth <= COMPACT_CARD_WIDTH);
useElementSizeObserver(
useCallback(() => containerRef.current, []),
useCallback((width) => setCompact(width <= COMPACT_CARD_WIDTH), [])
);
const screenSize = useScreenSizeContext();
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const handleNavigate = (roomId: string, space: boolean) => {
if (space) {
navigateSpace(roomId);
return;
}
navigateRoom(roomId);
};
return (
<Page>
<PageHeader balance>
<Box grow="Yes" gap="200">
<Box grow="Yes" basis="No">
{screenSize === ScreenSize.Mobile && (
<BackRouteHandler>
{(onBack) => (
<IconButton onClick={onBack}>
<Icon src={Icons.ArrowLeft} />
</IconButton>
)}
</BackRouteHandler>
)}
</Box>
<Box alignItems="Center" gap="200">
{screenSize !== ScreenSize.Mobile && <Icon size="400" src={Icons.Mail} />}
<Text size="H3" truncate>
{t('Inbox.invites')}
</Text>
</Box>
<Box grow="Yes" basis="No" />
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
<PageContentCenter>
<Box ref={containerRef} direction="Column" gap="600">
<Box direction="Column" gap="100">
<span data-spacing-node />
<Text size="L400">{t('Inbox.filter')}</Text>
<InviteFilters
filter={filter}
onFilter={setFilter}
knownInvites={knownInvites}
unknownInvites={unknownInvites}
spamInvites={spamInvites}
/>
</Box>
{filter === InviteFilter.Known && (
<KnownInvites
invites={knownInvites}
compact={compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
handleNavigate={handleNavigate}
/>
)}
{filter === InviteFilter.Unknown && (
<UnknownInvites
invites={unknownInvites}
compact={compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
handleNavigate={handleNavigate}
/>
)}
{filter === InviteFilter.Spam && (
<SpamInvites
invites={spamInvites}
compact={compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
handleNavigate={handleNavigate}
/>
)}
</Box>
</PageContentCenter>
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View file

@ -1,793 +0,0 @@
/* eslint-disable react/destructuring-assignment */
import React, { MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Avatar,
Box,
Chip,
Header,
Icon,
IconButton,
Icons,
Scroll,
Text,
config,
toRem,
} from 'folds';
import { useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
INotification,
INotificationsResponse,
IRoomEvent,
JoinRule,
Method,
RelationType,
Room,
} from 'matrix-js-sdk';
import { useVirtualizer } from '@tanstack/react-virtual';
import { HTMLReactParserOptions } from 'html-react-parser';
import { Opts as LinkifyOpts } from 'linkifyjs';
import { useAtomValue } from 'jotai';
import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { InboxNotificationsPathSearchParams } from '../../paths';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { SequenceCard } from '../../../components/sequence-card';
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
import {
getEditedEvent,
getMemberAvatarMxc,
getMemberDisplayName,
getRoomAvatarUrl,
isOneOnOneRoom,
} from '../../../utils/room';
import { ScrollTopContainer } from '../../../components/scroll-top-container';
import { useInterval } from '../../../hooks/useInterval';
import {
AvatarBase,
ImageContent,
MSticker,
MessageNotDecryptedContent,
MessageUnsupportedContent,
ModernLayout,
RedactedContent,
Reply,
Time,
Username,
UsernameBold,
} from '../../../components/message';
import {
factoryRenderLinkifyWithMention,
getReactCustomHtmlParser,
LINKIFY_OPTS,
makeMentionCustomProps,
renderMatrixMention,
} from '../../../plugins/react-custom-html-parser';
import { RenderMessageContent } from '../../../components/RenderMessageContent';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { Image } from '../../../components/media';
import { ImageViewer } from '../../../components/image-viewer';
import { GetContentCallback, MessageEvent, StateEvent } from '../../../../types/matrix/room';
import { useMatrixEventRenderer } from '../../../hooks/useMatrixEventRenderer';
import * as customHtmlCss from '../../../styles/CustomHtml.css';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
import { useRoomUnread } from '../../../state/hooks/unread';
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
import { markAsRead } from '../../../utils/notifications';
import { ContainerColor } from '../../../styles/ContainerColor.css';
import { VirtualTile } from '../../../components/virtualizer';
import { UserAvatar } from '../../../components/user-avatar';
import { EncryptedContent } from '../../../features/room/message';
import { useMentionClickHandler } from '../../../hooks/useMentionClickHandler';
import { useSpoilerClickHandler } from '../../../hooks/useSpoilerClickHandler';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { BackRouteHandler } from '../../../components/BackRouteHandler';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { usePowerLevels } from '../../../hooks/usePowerLevels';
import { usePowerLevelTags } from '../../../hooks/usePowerLevelTags';
import { useTheme } from '../../../hooks/useTheme';
import { PowerIcon } from '../../../components/power';
import colorMXID from '../../../../util/colorMXID';
import {
getPowerTagIconSrc,
useAccessiblePowerTagColors,
useGetMemberPowerTag,
} from '../../../hooks/useMemberPowerTag';
import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
type RoomNotificationsGroup = {
roomId: string;
notifications: INotification[];
};
type NotificationTimeline = {
nextToken?: string;
groups: RoomNotificationsGroup[];
};
type LoadTimeline = (from?: string) => Promise<void>;
type SilentReloadTimeline = () => Promise<void>;
const groupNotifications = (
notifications: INotification[],
allowRooms: Set<string>
): RoomNotificationsGroup[] => {
const groups: RoomNotificationsGroup[] = [];
notifications.forEach((notification) => {
if (!allowRooms.has(notification.room_id)) return;
const groupIndex = groups.length - 1;
const lastAddedGroup: RoomNotificationsGroup | undefined = groups[groupIndex];
if (lastAddedGroup && notification.room_id === lastAddedGroup.roomId) {
lastAddedGroup.notifications.push(notification);
return;
}
groups.push({
roomId: notification.room_id,
notifications: [notification],
});
});
return groups;
};
const useNotificationTimeline = (
paginationLimit: number,
onlyHighlight?: boolean
): [NotificationTimeline, LoadTimeline, SilentReloadTimeline] => {
const mx = useMatrixClient();
const allRooms = useAtomValue(allRoomsAtom);
const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]);
const [notificationTimeline, setNotificationTimeline] = useState<NotificationTimeline>({
groups: [],
});
const fetchNotifications = useCallback(
(from?: string, limit?: number, only?: 'highlight') => {
const queryParams = { from, limit, only };
return mx.http.authedRequest<INotificationsResponse>(
Method.Get,
'/notifications',
queryParams
);
},
[mx]
);
const loadTimeline: LoadTimeline = useCallback(
async (from) => {
if (!from) {
setNotificationTimeline({ groups: [] });
}
const data = await fetchNotifications(
from,
paginationLimit,
onlyHighlight ? 'highlight' : undefined
);
const groups = groupNotifications(data.notifications, allJoinedRooms);
setNotificationTimeline((currentTimeline) => {
if (currentTimeline.nextToken === from) {
return {
nextToken: data.next_token,
groups: from ? currentTimeline.groups.concat(groups) : groups,
};
}
return currentTimeline;
});
},
[paginationLimit, onlyHighlight, fetchNotifications, allJoinedRooms]
);
/**
* Reload timeline silently i.e without setting to default
* before fetching notifications from start
*/
const silentReloadTimeline: SilentReloadTimeline = useCallback(async () => {
const data = await fetchNotifications(
undefined,
paginationLimit,
onlyHighlight ? 'highlight' : undefined
);
const groups = groupNotifications(data.notifications, allJoinedRooms);
setNotificationTimeline({
nextToken: data.next_token,
groups,
});
}, [paginationLimit, onlyHighlight, fetchNotifications, allJoinedRooms]);
return [notificationTimeline, loadTimeline, silentReloadTimeline];
};
type RoomNotificationsGroupProps = {
room: Room;
notifications: INotification[];
mediaAutoLoad?: boolean;
urlPreview?: boolean;
hideActivity: boolean;
onOpen: (roomId: string, eventId: string) => void;
legacyUsernameColor?: boolean;
hour24Clock: boolean;
dateFormatString: string;
};
function RoomNotificationsGroupComp({
room,
notifications,
mediaAutoLoad,
urlPreview,
hideActivity,
onOpen,
legacyUsernameColor,
hour24Clock,
dateFormatString,
}: RoomNotificationsGroupProps) {
const { t } = useTranslation();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const powerLevels = usePowerLevels(room);
const creators = useRoomCreators(room);
const creatorsTag = useRoomCreatorsTag();
const powerLevelTags = usePowerLevelTags(room, powerLevels);
const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
const theme = useTheme();
const accessibleTagColors = useAccessiblePowerTagColors(theme.kind, creatorsTag, powerLevelTags);
const mentionClickHandler = useMentionClickHandler(room.roomId);
const spoilerClickHandler = useSpoilerClickHandler();
const linkifyOpts = useMemo<LinkifyOpts>(
() => ({
...LINKIFY_OPTS,
render: factoryRenderLinkifyWithMention((href) =>
renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler))
),
}),
[mx, room, mentionClickHandler]
);
const htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
() =>
getReactCustomHtmlParser(mx, room.roomId, {
linkifyOpts,
useAuthentication,
handleSpoilerClick: spoilerClickHandler,
handleMentionClick: mentionClickHandler,
}),
[mx, room, linkifyOpts, mentionClickHandler, spoilerClickHandler, useAuthentication]
);
const renderMatrixEvent = useMatrixEventRenderer<[IRoomEvent, string, GetContentCallback]>(
{
[MessageEvent.RoomMessage]: (event, displayName, getContent) => {
if (event.unsigned?.redacted_because) {
return <RedactedContent reason={event.unsigned?.redacted_because.content.reason} />;
}
return (
<RenderMessageContent
displayName={displayName}
msgType={event.content.msgtype ?? ''}
ts={event.origin_server_ts}
getContent={getContent}
mediaAutoLoad={mediaAutoLoad}
urlPreview={urlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment
/>
);
},
[MessageEvent.RoomMessageEncrypted]: (evt, displayName) => {
const evtTimeline = room.getTimelineForEvent(evt.event_id);
const mEvent = evtTimeline?.getEvents().find((e) => e.getId() === evt.event_id);
if (!mEvent || !evtTimeline) {
return (
<Box grow="Yes" direction="Column">
<Text size="T400" priority="300">
<code className={customHtmlCss.Code}>{evt.type}</code>
{' event'}
</Text>
</Box>
);
}
return (
<EncryptedContent mEvent={mEvent}>
{() => {
if (mEvent.isRedacted()) return <RedactedContent />;
if (mEvent.getType() === MessageEvent.Sticker)
return (
<MSticker
content={mEvent.getContent()}
renderImageContent={(props) => (
<ImageContent
{...props}
autoPlay={mediaAutoLoad}
renderImage={(p) => <Image {...p} loading="lazy" />}
renderViewer={(p) => <ImageViewer {...p} />}
/>
)}
/>
);
if (mEvent.getType() === MessageEvent.RoomMessage) {
const editedEvent = getEditedEvent(
evt.event_id,
mEvent,
evtTimeline.getTimelineSet()
);
const getContent = (() =>
editedEvent?.getContent()['m.new_content'] ??
mEvent.getContent()) as GetContentCallback;
return (
<RenderMessageContent
displayName={displayName}
msgType={mEvent.getContent().msgtype ?? ''}
ts={mEvent.getTs()}
edited={!!editedEvent}
getContent={getContent}
mediaAutoLoad={mediaAutoLoad}
urlPreview={urlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
);
}
if (mEvent.getType() === MessageEvent.RoomMessageEncrypted)
return (
<Text>
<MessageNotDecryptedContent />
</Text>
);
return (
<Text>
<MessageUnsupportedContent />
</Text>
);
}}
</EncryptedContent>
);
},
[MessageEvent.Sticker]: (event, displayName, getContent) => {
if (event.unsigned?.redacted_because) {
return <RedactedContent reason={event.unsigned?.redacted_because.content.reason} />;
}
return (
<MSticker
content={getContent()}
renderImageContent={(props) => (
<ImageContent
{...props}
autoPlay={mediaAutoLoad}
renderImage={(p) => <Image {...p} loading="lazy" />}
renderViewer={(p) => <ImageViewer {...p} />}
/>
)}
/>
);
},
[StateEvent.RoomTombstone]: (event) => {
const { content } = event;
return (
<Box grow="Yes" direction="Column">
<Text size="T400" priority="300">
Room Tombstone. {content.body}
</Text>
</Box>
);
},
},
undefined,
(event) => {
if (event.unsigned?.redacted_because) {
return <RedactedContent reason={event.unsigned?.redacted_because.content.reason} />;
}
return (
<Box grow="Yes" direction="Column">
<Text size="T400" priority="300">
<code className={customHtmlCss.Code}>{event.type}</code>
{' event'}
</Text>
</Box>
);
}
);
const handleOpenClick: MouseEventHandler = (evt) => {
const eventId = evt.currentTarget.getAttribute('data-event-id');
if (!eventId) return;
onOpen(room.roomId, eventId);
};
const handleMarkAsRead = () => {
markAsRead(mx, room.roomId, hideActivity);
};
return (
<Box direction="Column" gap="200">
<Header size="300">
<Box gap="200" grow="Yes">
<Avatar size="200" radii="300">
<RoomAvatar
roomId={room.roomId}
src={getRoomAvatarUrl(mx, room, 96, useAuthentication)}
alt={room.name}
renderFallback={() => (
<RoomIcon
size="50"
roomType={room.getType()}
joinRule={room.getJoinRule() ?? JoinRule.Restricted}
filled
/>
)}
/>
</Avatar>
<Text size="H4" truncate>
{room.name}
</Text>
</Box>
<Box shrink="No">
{unread && (
<Chip
variant="Primary"
radii="Pill"
onClick={handleMarkAsRead}
before={<Icon size="100" src={Icons.CheckTwice} />}
>
<Text size="T200">{t('Inbox.mark_as_read')}</Text>
</Chip>
)}
</Box>
</Header>
<Box direction="Column" gap="100">
{notifications.map((notification) => {
const { event } = notification;
const displayName =
getMemberDisplayName(room, event.sender) ??
getMxIdLocalPart(event.sender) ??
event.sender;
const senderAvatarMxc = getMemberAvatarMxc(room, event.sender);
const getContent = (() => event.content) as GetContentCallback;
const relation = event.content['m.relates_to'];
const replyEventId = relation?.['m.in_reply_to']?.event_id;
const threadRootId =
relation?.rel_type === RelationType.Thread ? relation.event_id : undefined;
const memberPowerTag = getMemberPowerTag(event.sender);
const tagColor = memberPowerTag?.color
? accessibleTagColors?.get(memberPowerTag.color)
: undefined;
const tagIconSrc = memberPowerTag?.icon
? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon)
: undefined;
const usernameColor = legacyUsernameColor ? colorMXID(event.sender) : tagColor;
return (
<SequenceCard
key={notification.event.event_id}
style={{ padding: config.space.S400 }}
variant="SurfaceVariant"
direction="Column"
>
<ModernLayout
before={
<AvatarBase>
<Avatar size="300">
<UserAvatar
userId={event.sender}
src={
senderAvatarMxc
? mxcUrlToHttp(
mx,
senderAvatarMxc,
useAuthentication,
48,
48,
'crop'
) ?? undefined
: undefined
}
alt={displayName}
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
/>
</Avatar>
</AvatarBase>
}
>
<Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
<Box gap="200" alignItems="Baseline">
<Box alignItems="Center" gap="200">
<Username style={{ color: usernameColor }}>
<Text as="span" truncate>
<UsernameBold>{displayName}</UsernameBold>
</Text>
</Username>
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
</Box>
<Time
ts={event.origin_server_ts}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
</Box>
<Box shrink="No" gap="200" alignItems="Center">
<Chip
data-event-id={event.event_id}
onClick={handleOpenClick}
variant="Secondary"
radii="400"
>
<Text size="T200">{t('Inbox.open')}</Text>
</Chip>
</Box>
</Box>
{replyEventId && (
<Reply
room={room}
replyEventId={replyEventId}
threadRootId={threadRootId}
onClick={handleOpenClick}
getMemberPowerTag={getMemberPowerTag}
accessibleTagColors={accessibleTagColors}
legacyUsernameColor={legacyUsernameColor}
/>
)}
{renderMatrixEvent(event.type, false, event, displayName, getContent)}
</ModernLayout>
</SequenceCard>
);
})}
</Box>
</Box>
);
}
const useNotificationsSearchParams = (
searchParams: URLSearchParams
): InboxNotificationsPathSearchParams =>
useMemo(
() => ({
only: searchParams.get('only') ?? undefined,
}),
[searchParams]
);
const DEFAULT_REFRESH_MS = 7000;
export function Notifications() {
const { t } = useTranslation();
const mx = useMatrixClient();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const screenSize = useScreenSizeContext();
const { navigateRoom } = useRoomNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const notificationsSearchParams = useNotificationsSearchParams(searchParams);
const scrollRef = useRef<HTMLDivElement>(null);
const scrollTopAnchorRef = useRef<HTMLDivElement>(null);
const [refreshIntervalTime, setRefreshIntervalTime] = useState(DEFAULT_REFRESH_MS);
const onlyHighlight = notificationsSearchParams.only === 'highlight';
const setOnlyHighlighted = (highlight: boolean) => {
if (highlight) {
setSearchParams(
new URLSearchParams({
only: 'highlight',
})
);
return;
}
setSearchParams();
};
const [notificationTimeline, _loadTimeline, silentReloadTimeline] = useNotificationTimeline(
24,
onlyHighlight
);
const [timelineState, loadTimeline] = useAsyncCallback(_loadTimeline);
const virtualizer = useVirtualizer({
count: notificationTimeline.groups.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 40,
overscan: 4,
});
const vItems = virtualizer.getVirtualItems();
useInterval(
useCallback(() => {
silentReloadTimeline();
}, [silentReloadTimeline]),
refreshIntervalTime
);
const handleScrollTopVisibility = useCallback(
(onTop: boolean) => setRefreshIntervalTime(onTop ? DEFAULT_REFRESH_MS : -1),
[]
);
useEffect(() => {
loadTimeline();
}, [loadTimeline]);
const lastVItem = vItems[vItems.length - 1];
const lastVItemIndex: number | undefined = lastVItem?.index;
useEffect(() => {
if (
timelineState.status === AsyncStatus.Success &&
notificationTimeline.groups.length - 1 === lastVItemIndex &&
notificationTimeline.nextToken
) {
loadTimeline(notificationTimeline.nextToken);
}
}, [timelineState, notificationTimeline, lastVItemIndex, loadTimeline]);
return (
<Page>
<PageHeader balance>
<Box grow="Yes" gap="200">
<Box grow="Yes" basis="No">
{screenSize === ScreenSize.Mobile && (
<BackRouteHandler>
{(onBack) => (
<IconButton onClick={onBack}>
<Icon src={Icons.ArrowLeft} />
</IconButton>
)}
</BackRouteHandler>
)}
</Box>
<Box alignItems="Center" gap="200">
{screenSize !== ScreenSize.Mobile && <Icon size="400" src={Icons.Message} />}
<Text size="H3" truncate>
{t('Inbox.notification_messages')}
</Text>
</Box>
<Box grow="Yes" basis="No" />
</Box>
</PageHeader>
<Box style={{ position: 'relative' }} grow="Yes">
<Scroll ref={scrollRef} hideTrack visibility="Hover">
<PageContent>
<PageContentCenter>
<Box direction="Column" gap="200">
<Box ref={scrollTopAnchorRef} direction="Column" gap="100">
<span data-spacing-node />
<Text size="L400">{t('Inbox.filter')}</Text>
<Box gap="200">
<Chip
onClick={() => setOnlyHighlighted(false)}
variant={!onlyHighlight ? 'Success' : 'Surface'}
aria-pressed={!onlyHighlight}
before={!onlyHighlight && <Icon size="100" src={Icons.Check} />}
outlined
>
<Text size="T200">{t('Inbox.all_notifications')}</Text>
</Chip>
<Chip
onClick={() => setOnlyHighlighted(true)}
variant={onlyHighlight ? 'Success' : 'Surface'}
aria-pressed={onlyHighlight}
before={onlyHighlight && <Icon size="100" src={Icons.Check} />}
outlined
>
<Text size="T200">{t('Inbox.highlighted')}</Text>
</Chip>
</Box>
</Box>
<ScrollTopContainer
scrollRef={scrollRef}
anchorRef={scrollTopAnchorRef}
onVisibilityChange={handleScrollTopVisibility}
>
<IconButton
onClick={() => virtualizer.scrollToOffset(0)}
variant="SurfaceVariant"
radii="Pill"
outlined
size="300"
aria-label={t('Inbox.scroll_to_top')}
>
<Icon src={Icons.ChevronTop} size="300" />
</IconButton>
</ScrollTopContainer>
<div
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{vItems.map((vItem) => {
const group = notificationTimeline.groups[vItem.index];
if (!group) return null;
const groupRoom = mx.getRoom(group.roomId);
if (!groupRoom) return null;
return (
<VirtualTile
virtualItem={vItem}
style={{ paddingTop: config.space.S500 }}
ref={virtualizer.measureElement}
key={vItem.index}
>
<RoomNotificationsGroupComp
room={groupRoom}
notifications={group.notifications}
mediaAutoLoad={mediaAutoLoad}
urlPreview={urlPreview}
hideActivity={hideActivity}
onOpen={navigateRoom}
legacyUsernameColor={
isOneOnOneRoom(groupRoom)
}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
</VirtualTile>
);
})}
</div>
{timelineState.status === AsyncStatus.Success &&
notificationTimeline.groups.length === 0 && (
<Box
className={ContainerColor({ variant: 'SurfaceVariant' })}
style={{
padding: config.space.S300,
borderRadius: config.radii.R400,
}}
direction="Column"
gap="200"
>
<Text>{t('Inbox.no_notifications')}</Text>
<Text size="T200">{t('Inbox.no_notifications_desc')}</Text>
</Box>
)}
{timelineState.status === AsyncStatus.Loading && (
<Box direction="Column" gap="100">
{[...Array(8).keys()].map((key) => (
<SequenceCard
variant="SurfaceVariant"
key={key}
style={{ minHeight: toRem(80) }}
/>
))}
</Box>
)}
{timelineState.status === AsyncStatus.Error && (
<Box
className={ContainerColor({ variant: 'Critical' })}
style={{
padding: config.space.S300,
borderRadius: config.radii.R400,
}}
direction="Column"
gap="200"
>
<Text size="L400">{(timelineState.error as Error).name}</Text>
<Text size="T300">{(timelineState.error as Error).message}</Text>
</Box>
)}
</Box>
</PageContentCenter>
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View file

@ -1,3 +0,0 @@
export * from './Inbox';
export * from './Notifications';
export * from './Invites';

View file

@ -1,66 +0,0 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Icon, Icons } from 'folds';
import { useTranslation } from 'react-i18next';
import { useAtomValue } from 'jotai';
import {
SidebarAvatar,
SidebarItem,
SidebarItemBadge,
SidebarItemTooltip,
} from '../../../components/sidebar';
import { allInvitesAtom } from '../../../state/room-list/inviteList';
import {
getInboxInvitesPath,
getInboxNotificationsPath,
getInboxPath,
joinPathComponent,
} from '../../pathUtils';
import { useInboxSelected } from '../../../hooks/router/useInbox';
import { UnreadBadge } from '../../../components/unread-badge';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath';
import { isNativePlatform } from '../../../utils/capacitor';
export function InboxTab() {
const { t } = useTranslation();
const screenSize = useScreenSizeContext();
const navigate = useNavigate();
const navToActivePath = useAtomValue(useNavToActivePathAtom());
const inboxSelected = useInboxSelected();
const allInvites = useAtomValue(allInvitesAtom);
const inviteCount = allInvites.length;
const handleInboxClick = () => {
const navOpts = { replace: isNativePlatform() };
if (screenSize === ScreenSize.Mobile) {
navigate(getInboxPath(), navOpts);
return;
}
const activePath = navToActivePath.get('inbox');
if (activePath) {
navigate(joinPathComponent(activePath), navOpts);
return;
}
const path = inviteCount > 0 ? getInboxInvitesPath() : getInboxNotificationsPath();
navigate(path, navOpts);
};
return (
<SidebarItem active={inboxSelected}>
<SidebarItemTooltip tooltip={t('Inbox.inbox')}>
{(triggerRef) => (
<SidebarAvatar as="button" ref={triggerRef} outlined onClick={handleInboxClick}>
<Icon src={Icons.Inbox} filled={inboxSelected} />
</SidebarAvatar>
)}
</SidebarItemTooltip>
{inviteCount > 0 && (
<SidebarItemBadge hasCount>
<UnreadBadge highlight count={inviteCount} />
</SidebarItemBadge>
)}
</SidebarItem>
);
}

View file

@ -1,6 +1,5 @@
export * from './DirectTab';
export * from './SpaceTabs';
export * from './InboxTab';
export * from './ExploreTab';
export * from './SettingsTab';
export * from './UnverifiedTab';

View file

@ -14,9 +14,6 @@ import {
HOME_ROOM_PATH,
HOME_SEARCH_PATH,
LOGIN_PATH,
INBOX_INVITES_PATH,
INBOX_NOTIFICATIONS_PATH,
INBOX_PATH,
REGISTER_PATH,
RESET_PASSWORD_PATH,
ROOT_PATH,
@ -157,10 +154,6 @@ export const getExploreServerPath = (server: string): string => {
export const getCreatePath = (): string => CREATE_PATH;
export const getInboxPath = (): string => INBOX_PATH;
export const getInboxNotificationsPath = (): string => INBOX_NOTIFICATIONS_PATH;
export const getInboxInvitesPath = (): string => INBOX_INVITES_PATH;
export const getBotsPath = (): string => BOTS_PATH;
export const getBotPath = (botId: string): string =>
generatePath(BOTS_BOT_PATH, { botId: encodeURIComponent(botId) });

View file

@ -88,15 +88,6 @@ export const USER_LINK_PATH = '/u/:userIdOrLocalPart';
export const BOTS_PATH = '/bots/';
export const BOTS_BOT_PATH = '/bots/:botId/';
export const _NOTIFICATIONS_PATH = 'notifications/';
export const _INVITES_PATH = 'invites/';
export const INBOX_PATH = '/inbox/';
export type InboxNotificationsPathSearchParams = {
only?: string;
};
export const INBOX_NOTIFICATIONS_PATH = `/inbox/${_NOTIFICATIONS_PATH}`;
export const INBOX_INVITES_PATH = `/inbox/${_INVITES_PATH}`;
export const SPACE_SETTINGS_PATH = '/space-settings/';
export const ROOM_SETTINGS_PATH = '/room-settings/';

View file

@ -1,55 +0,0 @@
import { useAtomValue } from 'jotai';
import { selectAtom } from 'jotai/utils';
import { MatrixClient } from 'matrix-js-sdk';
import { useCallback } from 'react';
import { isDirectInvite, isRoom, isSpace, isUnsupportedRoom } from '../../utils/room';
import { compareRoomsEqual } from '../room-list/utils';
import { allInvitesAtom } from '../room-list/inviteList';
export const useSpaceInvites = (mx: MatrixClient, invitesAtom: typeof allInvitesAtom) => {
const selector = useCallback(
(rooms: string[]) => rooms.filter((roomId) => isSpace(mx.getRoom(roomId))),
[mx]
);
return useAtomValue(selectAtom(invitesAtom, selector, compareRoomsEqual));
};
export const useRoomInvites = (
mx: MatrixClient,
invitesAtom: typeof allInvitesAtom,
mDirects: Set<string>
) => {
const selector = useCallback(
(rooms: string[]) =>
rooms.filter(
(roomId) =>
isRoom(mx.getRoom(roomId)) &&
!(mDirects.has(roomId) || isDirectInvite(mx.getRoom(roomId), mx.getUserId()))
),
[mx, mDirects]
);
return useAtomValue(selectAtom(invitesAtom, selector, compareRoomsEqual));
};
export const useDirectInvites = (
mx: MatrixClient,
invitesAtom: typeof allInvitesAtom,
mDirects: Set<string>
) => {
const selector = useCallback(
(rooms: string[]) =>
rooms.filter(
(roomId) => mDirects.has(roomId) || isDirectInvite(mx.getRoom(roomId), mx.getUserId())
),
[mx, mDirects]
);
return useAtomValue(selectAtom(invitesAtom, selector, compareRoomsEqual));
};
export const useUnsupportedInvites = (mx: MatrixClient, invitesAtom: typeof allInvitesAtom) => {
const selector = useCallback(
(rooms: string[]) => rooms.filter((roomId) => isUnsupportedRoom(mx.getRoom(roomId))),
[mx]
);
return useAtomValue(selectAtom(invitesAtom, selector, compareRoomsEqual));
};

View file

@ -30,6 +30,7 @@ export interface Settings {
showHiddenEvents: boolean;
isNotificationSounds: boolean;
inviteSpamFilter: boolean;
hour24Clock: boolean;
dateFormatString: string;
@ -63,6 +64,7 @@ const defaultSettings: Settings = {
showHiddenEvents: false,
isNotificationSounds: true,
inviteSpamFilter: true,
hour24Clock: false,
dateFormatString: 'D MMM YYYY',

View file

@ -4,7 +4,6 @@ import {
DIRECT_PATH,
EXPLORE_PATH,
HOME_PATH,
INBOX_PATH,
SPACE_PATH,
} from '../pages/paths';
import {
@ -12,7 +11,6 @@ import {
getDirectPath,
getExplorePath,
getHomePath,
getInboxPath,
getSpacePath,
} from '../pages/pathUtils';
@ -40,7 +38,6 @@ export const getRouteSectionParent = (pathname: string): string | null => {
}
if (under(EXPLORE_PATH)) return atRoot(EXPLORE_PATH) ? null : getExplorePath();
if (under(INBOX_PATH)) return atRoot(INBOX_PATH) ? null : getInboxPath();
return null;
};

View file

@ -738,11 +738,15 @@ self.addEventListener('push', (event: PushEvent) => {
}
if (isInvite) {
// Invite notifications route to /inbox/invites, so we carry `isInvite`
// in data for the click handler. Distinct tag namespace keeps invite
// banners from clobbering a message notification for the same room
// (rare, but possible if the invite event and a decrypted preview
// arrive close together).
// Invite notifications route to the bare /direct/ panel — the row
// lives inline in the Direct list and the user picks Accept/Decline
// there. We intentionally don't deep-link into /direct/{roomId}/ for
// invite-state rooms (Room.tsx has no useful render path for them
// and on mobile the panel itself would be hidden). We still carry
// `isInvite` in data so the client-side handler can branch on it.
// Distinct tag namespace keeps invite banners from clobbering a
// message notification for the same room (rare, but possible if
// the invite event and a decrypted preview arrive close together).
await self.registration.showNotification(title, {
body,
icon: '/res/android/android-chrome-192x192.png',
@ -789,11 +793,15 @@ self.addEventListener('notificationclick', (event) => {
return;
}
// Cold-start path: no live window to hand off to, so pick the
// destination up-front. Invites land on the inbox list (we don't want
// to drop the user into a room they haven't joined); everything else
// opens the room directly.
// destination up-front. Invites always land on the bare /direct/ panel
// (their row sits at the top until the user accepts/declines) — we
// intentionally don't deep-link into /direct/{roomId}/ for invite-state
// rooms because Room.tsx has no membership gate and on mobile the
// panel itself would be hidden, leaving no Accept/Decline UI on
// screen. Everything else opens the room directly via the Home
// redirect shim.
let path = '/';
if (isInvite) path = '/inbox/invites';
if (isInvite) path = '/direct/';
else if (roomId) path = `/home/${encodeURIComponent(roomId)}/`;
await self.clients.openWindow(path);
})()