From ed1544dd5ef8ce1251837e880cd13da91d879315 Mon Sep 17 00:00:00 2001
From: heaven
Date: Sun, 3 May 2026 13:49:33 +0300
Subject: [PATCH] feat(direct): land inline invite cards with spam-filter
toggle and retire the /inbox/ tree along with its sidebar tab
---
public/locales/en.json | 58 +-
public/locales/ru.json | 62 +-
src/app/features/room-nav/DirectInviteRow.tsx | 282 ++++++
src/app/features/room-nav/index.ts | 1 +
.../notifications/SystemNotification.tsx | 13 +
src/app/hooks/router/useInbox.ts | 36 -
src/app/hooks/usePushNotifications.ts | 28 +-
src/app/pages/MobileFriendly.tsx | 5 +-
src/app/pages/Router.tsx | 33 +-
src/app/pages/client/ClientNonUIFeatures.tsx | 6 +-
src/app/pages/client/SidebarNav.tsx | 2 -
src/app/pages/client/direct/Direct.tsx | 146 ++-
src/app/pages/client/direct/RoomProvider.tsx | 13 +-
src/app/pages/client/direct/index.ts | 1 +
.../pages/client/direct/useDirectInvites.ts | 97 ++
src/app/pages/client/inbox/Inbox.tsx | 90 --
src/app/pages/client/inbox/Invites.tsx | 840 ------------------
src/app/pages/client/inbox/Notifications.tsx | 793 -----------------
src/app/pages/client/inbox/index.ts | 3 -
src/app/pages/client/sidebar/InboxTab.tsx | 66 --
src/app/pages/client/sidebar/index.ts | 1 -
src/app/pages/pathUtils.ts | 7 -
src/app/pages/paths.ts | 9 -
src/app/state/hooks/inviteList.ts | 55 --
src/app/state/settings.ts | 2 +
src/app/utils/routeParent.ts | 3 -
src/sw.ts | 26 +-
27 files changed, 630 insertions(+), 2048 deletions(-)
create mode 100644 src/app/features/room-nav/DirectInviteRow.tsx
delete mode 100644 src/app/hooks/router/useInbox.ts
create mode 100644 src/app/pages/client/direct/useDirectInvites.ts
delete mode 100644 src/app/pages/client/inbox/Inbox.tsx
delete mode 100644 src/app/pages/client/inbox/Invites.tsx
delete mode 100644 src/app/pages/client/inbox/Notifications.tsx
delete mode 100644 src/app/pages/client/inbox/index.ts
delete mode 100644 src/app/pages/client/sidebar/InboxTab.tsx
delete mode 100644 src/app/state/hooks/inviteList.ts
diff --git a/public/locales/en.json b/public/locales/en.json
index 46e9ae51..34edb500 100644
--- a/public/locales/en.json
+++ b/public/locales/en.json
@@ -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": "{{user}} 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",
diff --git a/public/locales/ru.json b/public/locales/ru.json
index 24e906c1..73c057cc 100644
--- a/public/locales/ru.json
+++ b/public/locales/ru.json
@@ -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": "{{user}} теперь в звонке"
},
"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",
diff --git a/src/app/features/room-nav/DirectInviteRow.tsx b/src/app/features/room-nav/DirectInviteRow.tsx
new file mode 100644
index 00000000..f6707a91
--- /dev/null
+++ b/src/app/features/room-nav/DirectInviteRow.tsx
@@ -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(
+ 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, 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 = (
+
+
+ {t('Direct.invite_badge_space')}
+
+
+ );
+ } else if (direct) {
+ kindBadge = (
+
+
+ {t('Direct.invite_badge_direct')}
+
+
+ );
+ } else {
+ kindBadge = (
+
+
+ {t('Direct.invite_badge_group')}
+
+
+ );
+ }
+
+ const badgeRow = (
+
+ {kindBadge}
+ {encrypted && (
+
+
+ {t('Direct.invite_badge_encrypted')}
+
+
+ )}
+ {isSpam && (
+
+
+ {t('Direct.invite_badge_spam')}
+
+
+ )}
+
+ );
+
+ return (
+
+
+
+
+ (
+
+ {nameInitials(isSpam ? undefined : roomName)}
+
+ )}
+ />
+
+
+
+
+
+ {roomName}
+
+
+
+ {badgeRow}
+
+
+
+ {inviteLine}
+
+ {topic && (
+
+ {topic}
+
+ )}
+ {errMessage && (
+
+ {errMessage}
+
+ )}
+
+ : undefined}
+ >
+
+ {t('Direct.invite_decline')}
+
+
+ : undefined}
+ >
+
+ {t('Direct.invite_accept')}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/features/room-nav/index.ts b/src/app/features/room-nav/index.ts
index e58899a7..87a8ffa9 100644
--- a/src/app/features/room-nav/index.ts
+++ b/src/app/features/room-nav/index.ts
@@ -1,3 +1,4 @@
export * from './RoomNavItem';
export * from './RoomNavCategoryButton';
export * from './DmStreamRow';
+export * from './DirectInviteRow';
diff --git a/src/app/features/settings/notifications/SystemNotification.tsx b/src/app/features/settings/notifications/SystemNotification.tsx
index d8695986..247db63c 100644
--- a/src/app/features/settings/notifications/SystemNotification.tsx
+++ b/src/app/features/settings/notifications/SystemNotification.tsx
@@ -175,6 +175,7 @@ export function SystemNotification() {
settingsAtom,
'isNotificationSounds'
);
+ const [inviteSpamFilter, setInviteSpamFilter] = useSetting(settingsAtom, 'inviteSpamFilter');
return (
@@ -199,6 +200,18 @@ export function SystemNotification() {
after={}
/>
+
+ }
+ />
+
{
- 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;
-};
diff --git a/src/app/hooks/usePushNotifications.ts b/src/app/hooks/usePushNotifications.ts
index 660e7fb2..97fc0193 100644
--- a/src/app/hooks/usePushNotifications.ts
+++ b/src/app/hooks/usePushNotifications.ts
@@ -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
diff --git a/src/app/pages/MobileFriendly.tsx b/src/app/pages/MobileFriendly.tsx
index 5b3997f7..f26219e9 100644
--- a/src/app/pages/MobileFriendly.tsx
+++ b/src/app/pages/MobileFriendly.tsx
@@ -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;
}
diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx
index c525e810..0893ed86 100644
--- a/src/app/pages/Router.tsx
+++ b/src/app/pages/Router.tsx
@@ -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)
} />
} />
-
-
-
- }
- >
-
-
- }
- >
- {mobile ? null : (
- redirect(getInboxNotificationsPath())}
- element={}
- />
- )}
- } />
- } />
-
+ {/* 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. */}
+ } />
Page not found
} />
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index f65f879f..70bd6aa4 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -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
diff --git a/src/app/pages/client/SidebarNav.tsx b/src/app/pages/client/SidebarNav.tsx
index e4bb3f4c..98141716 100644
--- a/src/app/pages/client/SidebarNav.tsx
+++ b/src/app/pages/client/SidebarNav.tsx
@@ -10,7 +10,6 @@ import {
import {
DirectTab,
SpaceTabs,
- InboxTab,
ExploreTab,
SettingsTab,
UnverifiedTab,
@@ -43,7 +42,6 @@ export function SidebarNav() {
-
>
diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx
index ce76c3e3..0bad07af 100644
--- a/src/app/pages/client/direct/Direct.tsx
+++ b/src/app/pages/client/direct/Direct.tsx
@@ -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 (
+
+
+
+ {expanded
+ ? t('Direct.invite_hide_spam')
+ : t('Direct.invite_show_spam', { count: spamCount })}
+
+
+ );
+}
+
export function Direct() {
const mx = useMatrixClient();
useNavToActivePathMapper('direct');
const scrollRef = useRef(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(() => {
+ 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 (
+
+
+
+ );
+ }
+
+ if (item.kind === 'spam-toggle') {
+ return (
+
+ setSpamExpanded((v) => !v)}
+ />
+
+ );
+ }
+
+ // kind === 'direct'
+ const { roomId } = item;
const room = mx.getRoom(roomId);
if (!room) return null;
const selected = selectedRoomId === roomId;
-
return (
;
+ }
+
// 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)) {
diff --git a/src/app/pages/client/direct/index.ts b/src/app/pages/client/direct/index.ts
index d247bbc0..0d846b93 100644
--- a/src/app/pages/client/direct/index.ts
+++ b/src/app/pages/client/direct/index.ts
@@ -1,3 +1,4 @@
export * from './Direct';
export * from './RoomProvider';
export * from './DirectCreate';
+export * from './useDirectInvites';
diff --git a/src/app/pages/client/direct/useDirectInvites.ts b/src/app/pages/client/direct/useDirectInvites.ts
new file mode 100644
index 00000000..374684ca
--- /dev/null
+++ b/src/app/pages/client/direct/useDirectInvites.ts
@@ -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();
+ 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]);
+};
diff --git a/src/app/pages/client/inbox/Inbox.tsx b/src/app/pages/client/inbox/Inbox.tsx
deleted file mode 100644
index cbcb120a..00000000
--- a/src/app/pages/client/inbox/Inbox.tsx
+++ /dev/null
@@ -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 (
- 0}
- aria-selected={invitesSelected}
- >
-
-
-
-
-
-
-
-
- {t('Inbox.invites')}
-
-
- {inviteCount > 0 && }
-
-
-
-
- );
-}
-
-export function Inbox() {
- const { t } = useTranslation();
- useNavToActivePathMapper('inbox');
- const notificationsSelected = useInboxNotificationsSelected();
-
- return (
-
-
-
-
-
- {t('Inbox.inbox')}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {t('Inbox.notifications')}
-
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/src/app/pages/client/inbox/Invites.tsx b/src/app/pages/client/inbox/Invites.tsx
deleted file mode 100644
index 9ca8c415..00000000
--- a/src/app/pages/client/inbox/Invites.tsx
+++ /dev/null
@@ -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()?.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(
- 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, 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 (
-
- {(invite.isEncrypted || invite.isDirect || invite.isSpace) && (
-
- {invite.isEncrypted && (
-
-
- {t('Inbox.encrypted')}
-
-
- )}
- {invite.isDirect && (
-
-
- {t('Inbox.direct_message')}
-
-
- )}
- {invite.isSpace && (
-
-
- {t('Inbox.space')}
-
-
- )}
-
- )}
-
-
- (
-
- {nameInitials(hideAvatar && invite.roomAvatar ? undefined : invite.roomName)}
-
- )}
- />
-
-
-
-
-
- {invite.roomName}
-
- {invite.roomTopic && (
-
- {invite.roomTopic}
-
- )}
- }>
-
-
-
-
-
-
-
- {joinState.status === AsyncStatus.Error && (
-
- {joinState.error.message}
-
- )}
- {leaveState.status === AsyncStatus.Error && (
-
- {leaveState.error.message}
-
- )}
-
-
- : undefined}
- >
- {t('Inbox.decline')}
-
- : undefined}
- >
- {t('Inbox.accept')}
-
-
-
-
-
-
-
-
- {t('Inbox.from')}
- {invite.senderId}
-
-
- {typeof invite.inviteTs === 'number' && invite.inviteTs !== 0 && (
-
-
-
- )}
-
- {invite.reason && (
-
- {t('Inbox.reason_label')}
- {invite.reason}
-
- )}
-
-
- );
-}
-
-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 (
-
- onFilter(InviteFilter.Known)}
- before={isKnown && }
- after={
- knownInvites.length > 0 && (
-
- {knownInvites.length}
-
- )
- }
- >
- {t('Inbox.primary')}
-
- onFilter(InviteFilter.Unknown)}
- before={isUnknown && }
- after={
- unknownInvites.length > 0 && (
-
- {unknownInvites.length}
-
- )
- }
- >
- {t('Inbox.public')}
-
- onFilter(InviteFilter.Spam)}
- before={isSpam && }
- after={
- spamInvites.length > 0 && (
-
- {spamInvites.length}
-
- )
- }
- >
- {t('Inbox.spam')}
-
-
- );
-}
-
-type KnownInvitesProps = {
- invites: InviteData[];
- handleNavigate: NavigateHandler;
- compact: boolean;
- hour24Clock: boolean;
- dateFormatString: string;
-};
-function KnownInvites({
- invites,
- handleNavigate,
- compact,
- hour24Clock,
- dateFormatString,
-}: KnownInvitesProps) {
- const { t } = useTranslation();
- return (
-
- {t('Inbox.primary')}
- {invites.length > 0 ? (
-
- {invites.map((invite) => (
-
- ))}
-
- ) : (
-
-
- }
- title={t('Inbox.no_invites')}
- subTitle={t('Inbox.no_invites_known_desc')}
- />
-
-
- )}
-
- );
-}
-
-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 (
-
-
- {t('Inbox.public')}
-
- {invites.length > 0 && (
- }
- disabled={declining}
- radii="Pill"
- >
- {t('Inbox.decline_all')}
-
- )}
-
-
- {invites.length > 0 ? (
-
- {invites.map((invite) => (
-
- ))}
-
- ) : (
-
-
- }
- title={t('Inbox.no_invites')}
- subTitle={t('Inbox.no_invites_unknown_desc')}
- />
-
-
- )}
-
- );
-}
-
-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 (
-
- {t('Inbox.spam')}
- {invites.length > 0 ? (
-
-
-
- }
- title={t('Inbox.spam_invites_count', { count: invites.length })}
- subTitle={t('Inbox.spam_invites_desc')}
- >
-
- }
- disabled={loading}
- >
-
- {t('Inbox.decline_all')}
-
-
- {reportRoomSupported && reportAllStatus.status !== AsyncStatus.Success && (
- }
- disabled={loading}
- >
-
- {t('Inbox.report_all')}
-
-
- )}
- {unignoredUsers.length > 0 && (
- }
- >
-
- {t('Inbox.block_all')}
-
-
- )}
-
-
-
-
-
- }
- onClick={() => setShowInvites(!showInvites)}
- >
- {showInvites ? t('Inbox.hide_all') : t('Inbox.view_all')}
-
-
-
-
- {showInvites &&
- invites.map((invite) => (
-
- ))}
-
- ) : (
-
-
- }
- title={t('Inbox.no_spam_invites')}
- subTitle={t('Inbox.no_spam_invites_desc')}
- />
-
-
- )}
-
- );
-}
-
-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(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 (
-
-
-
-
- {screenSize === ScreenSize.Mobile && (
-
- {(onBack) => (
-
-
-
- )}
-
- )}
-
-
- {screenSize !== ScreenSize.Mobile && }
-
- {t('Inbox.invites')}
-
-
-
-
-
-
-
-
-
-
-
-
- {t('Inbox.filter')}
-
-
- {filter === InviteFilter.Known && (
-
- )}
-
- {filter === InviteFilter.Unknown && (
-
- )}
-
- {filter === InviteFilter.Spam && (
-
- )}
-
-
-
-
-
-
- );
-}
diff --git a/src/app/pages/client/inbox/Notifications.tsx b/src/app/pages/client/inbox/Notifications.tsx
deleted file mode 100644
index d1a78dd5..00000000
--- a/src/app/pages/client/inbox/Notifications.tsx
+++ /dev/null
@@ -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;
-type SilentReloadTimeline = () => Promise;
-
-const groupNotifications = (
- notifications: INotification[],
- allowRooms: Set
-): 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({
- groups: [],
- });
-
- const fetchNotifications = useCallback(
- (from?: string, limit?: number, only?: 'highlight') => {
- const queryParams = { from, limit, only };
- return mx.http.authedRequest(
- 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(
- () => ({
- ...LINKIFY_OPTS,
- render: factoryRenderLinkifyWithMention((href) =>
- renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler))
- ),
- }),
- [mx, room, mentionClickHandler]
- );
- const htmlReactParserOptions = useMemo(
- () =>
- 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 ;
- }
-
- return (
-
- );
- },
- [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 (
-
-
- {evt.type}
- {' event'}
-
-
- );
- }
-
- return (
-
- {() => {
- if (mEvent.isRedacted()) return ;
- if (mEvent.getType() === MessageEvent.Sticker)
- return (
- (
- }
- renderViewer={(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 (
-
- );
- }
- if (mEvent.getType() === MessageEvent.RoomMessageEncrypted)
- return (
-
-
-
- );
- return (
-
-
-
- );
- }}
-
- );
- },
- [MessageEvent.Sticker]: (event, displayName, getContent) => {
- if (event.unsigned?.redacted_because) {
- return ;
- }
- return (
- (
- }
- renderViewer={(p) => }
- />
- )}
- />
- );
- },
- [StateEvent.RoomTombstone]: (event) => {
- const { content } = event;
- return (
-
-
- Room Tombstone. {content.body}
-
-
- );
- },
- },
- undefined,
- (event) => {
- if (event.unsigned?.redacted_because) {
- return ;
- }
- return (
-
-
- {event.type}
- {' event'}
-
-
- );
- }
- );
-
- 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 (
-
-
-
-
- (
-
- )}
- />
-
-
- {room.name}
-
-
-
- {unread && (
- }
- >
- {t('Inbox.mark_as_read')}
-
- )}
-
-
-
- {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 (
-
-
-
- }
- />
-
-
- }
- >
-
-
-
-
-
- {displayName}
-
-
- {tagIconSrc && }
-
-
-
-
-
- {t('Inbox.open')}
-
-
-
- {replyEventId && (
-
- )}
- {renderMatrixEvent(event.type, false, event, displayName, getContent)}
-
-
- );
- })}
-
-
- );
-}
-
-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(null);
- const scrollTopAnchorRef = useRef(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 (
-
-
-
-
- {screenSize === ScreenSize.Mobile && (
-
- {(onBack) => (
-
-
-
- )}
-
- )}
-
-
- {screenSize !== ScreenSize.Mobile && }
-
- {t('Inbox.notification_messages')}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {t('Inbox.filter')}
-
- setOnlyHighlighted(false)}
- variant={!onlyHighlight ? 'Success' : 'Surface'}
- aria-pressed={!onlyHighlight}
- before={!onlyHighlight && }
- outlined
- >
- {t('Inbox.all_notifications')}
-
- setOnlyHighlighted(true)}
- variant={onlyHighlight ? 'Success' : 'Surface'}
- aria-pressed={onlyHighlight}
- before={onlyHighlight && }
- outlined
- >
- {t('Inbox.highlighted')}
-
-
-
-
- virtualizer.scrollToOffset(0)}
- variant="SurfaceVariant"
- radii="Pill"
- outlined
- size="300"
- aria-label={t('Inbox.scroll_to_top')}
- >
-
-
-
-
- {vItems.map((vItem) => {
- const group = notificationTimeline.groups[vItem.index];
- if (!group) return null;
- const groupRoom = mx.getRoom(group.roomId);
- if (!groupRoom) return null;
-
- return (
-
-
-
- );
- })}
-
-
- {timelineState.status === AsyncStatus.Success &&
- notificationTimeline.groups.length === 0 && (
-
- {t('Inbox.no_notifications')}
- {t('Inbox.no_notifications_desc')}
-
- )}
-
- {timelineState.status === AsyncStatus.Loading && (
-
- {[...Array(8).keys()].map((key) => (
-
- ))}
-
- )}
- {timelineState.status === AsyncStatus.Error && (
-
- {(timelineState.error as Error).name}
- {(timelineState.error as Error).message}
-
- )}
-
-
-
-
-
-
- );
-}
diff --git a/src/app/pages/client/inbox/index.ts b/src/app/pages/client/inbox/index.ts
deleted file mode 100644
index c8036b47..00000000
--- a/src/app/pages/client/inbox/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export * from './Inbox';
-export * from './Notifications';
-export * from './Invites';
diff --git a/src/app/pages/client/sidebar/InboxTab.tsx b/src/app/pages/client/sidebar/InboxTab.tsx
deleted file mode 100644
index 20145dce..00000000
--- a/src/app/pages/client/sidebar/InboxTab.tsx
+++ /dev/null
@@ -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 (
-
-
- {(triggerRef) => (
-
-
-
- )}
-
- {inviteCount > 0 && (
-
-
-
- )}
-
- );
-}
diff --git a/src/app/pages/client/sidebar/index.ts b/src/app/pages/client/sidebar/index.ts
index d3475c47..bfc81fbd 100644
--- a/src/app/pages/client/sidebar/index.ts
+++ b/src/app/pages/client/sidebar/index.ts
@@ -1,6 +1,5 @@
export * from './DirectTab';
export * from './SpaceTabs';
-export * from './InboxTab';
export * from './ExploreTab';
export * from './SettingsTab';
export * from './UnverifiedTab';
diff --git a/src/app/pages/pathUtils.ts b/src/app/pages/pathUtils.ts
index 514dbb9b..8a251029 100644
--- a/src/app/pages/pathUtils.ts
+++ b/src/app/pages/pathUtils.ts
@@ -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) });
diff --git a/src/app/pages/paths.ts b/src/app/pages/paths.ts
index 532a6cd6..14d8a242 100644
--- a/src/app/pages/paths.ts
+++ b/src/app/pages/paths.ts
@@ -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/';
diff --git a/src/app/state/hooks/inviteList.ts b/src/app/state/hooks/inviteList.ts
deleted file mode 100644
index 5e003fb1..00000000
--- a/src/app/state/hooks/inviteList.ts
+++ /dev/null
@@ -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
-) => {
- 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
-) => {
- 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));
-};
diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts
index 1bc7c28c..99667717 100644
--- a/src/app/state/settings.ts
+++ b/src/app/state/settings.ts
@@ -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',
diff --git a/src/app/utils/routeParent.ts b/src/app/utils/routeParent.ts
index 38fddb6f..137622a1 100644
--- a/src/app/utils/routeParent.ts
+++ b/src/app/utils/routeParent.ts
@@ -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;
};
diff --git a/src/sw.ts b/src/sw.ts
index 3cbce968..c663d3cc 100644
--- a/src/sw.ts
+++ b/src/sw.ts
@@ -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);
})()