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} + + )} + + + + + + + + + ); +} 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} - - )} - - - - - - - - - - - - {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')} - > - - - {reportRoomSupported && reportAllStatus.status !== AsyncStatus.Success && ( - - )} - {unignoredUsers.length > 0 && ( - - )} - - - - - - - - - {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); })()