feat(direct): land inline invite cards with spam-filter toggle and retire the /inbox/ tree along with its sidebar tab
This commit is contained in:
parent
bae6761683
commit
ed1544dd5e
27 changed files with 630 additions and 2048 deletions
|
|
@ -161,6 +161,8 @@
|
|||
"enable": "Enable",
|
||||
"notification_sound": "Notification Sound",
|
||||
"notification_sound_desc": "Play sound when a new message arrives.",
|
||||
"invite_spam_filter": "Spam Invites Filter",
|
||||
"invite_spam_filter_desc": "Hide incoming chat invites that look like spam (mass mailings, banned senders, suspicious words). Disable to see every invite as-is.",
|
||||
"push_notifications": "Background Notifications",
|
||||
"push_description": "Receive notifications even when Vojo is closed or minimized.",
|
||||
"push_permission_blocked": "Push notification permission was denied. Please enable it in your device settings.",
|
||||
|
|
@ -365,6 +367,21 @@
|
|||
"no_direct_messages": "No Direct Messages",
|
||||
"no_direct_messages_desc": "You do not have any direct messages yet.",
|
||||
"direct_message": "Direct Message",
|
||||
"invite_accept": "Accept",
|
||||
"invite_decline": "Decline",
|
||||
"invite_kind_direct": "a private chat",
|
||||
"invite_kind_group": "a group chat",
|
||||
"invite_kind_space": "a space",
|
||||
"invite_from_with_kind": "{{sender}} invites you to {{kind}}",
|
||||
"invite_to_kind": "Invite to {{kind}}",
|
||||
"invite_badge_direct": "Direct",
|
||||
"invite_badge_group": "Group",
|
||||
"invite_badge_space": "Space",
|
||||
"invite_badge_encrypted": "Encrypted",
|
||||
"invite_badge_spam": "Spam",
|
||||
"invite_show_spam_one": "Show {{count}} hidden spam invite",
|
||||
"invite_show_spam_other": "Show {{count}} hidden spam invites",
|
||||
"invite_hide_spam": "Hide spam",
|
||||
"create_chat": "Create Chat",
|
||||
"create_chat_subtitle": "Start a private, encrypted chat by entering a username.",
|
||||
"start_first_chat": "Start a chat",
|
||||
|
|
@ -527,47 +544,6 @@
|
|||
"member_joined_call": "<bold>{{user}}</bold> joined the call"
|
||||
},
|
||||
"Inbox": {
|
||||
"inbox": "Inbox",
|
||||
"invites": "Invites",
|
||||
"notifications": "Notifications",
|
||||
|
||||
"notification_messages": "Notifications",
|
||||
"filter": "Filter",
|
||||
"all_notifications": "All Notifications",
|
||||
"highlighted": "Highlighted",
|
||||
"mark_as_read": "Mark as Read",
|
||||
"open": "Open",
|
||||
"no_notifications": "No Notifications",
|
||||
"no_notifications_desc": "You don't have any new notifications to display yet.",
|
||||
"scroll_to_top": "Scroll to Top",
|
||||
|
||||
"encrypted": "Encrypted",
|
||||
"direct_message": "Direct Message",
|
||||
"space": "Space",
|
||||
"decline": "Decline",
|
||||
"accept": "Accept",
|
||||
"from": "From: ",
|
||||
"reason_label": "Reason: ",
|
||||
|
||||
"primary": "Primary",
|
||||
"public": "Public",
|
||||
"spam": "Spam",
|
||||
|
||||
"no_invites": "No Invites",
|
||||
"no_invites_known_desc": "When someone you share a room with sends you an invite, it'll show up here.",
|
||||
"no_invites_unknown_desc": "Invites from people outside your rooms will appear here.",
|
||||
"decline_all": "Decline All",
|
||||
|
||||
"spam_invites_count_one": "{{count}} Spam Invite",
|
||||
"spam_invites_count_other": "{{count}} Spam Invites",
|
||||
"spam_invites_desc": "Some of the following invites may contain harmful content or have been sent by banned users.",
|
||||
"report_all": "Report All",
|
||||
"block_all": "Block All",
|
||||
"hide_all": "Hide All",
|
||||
"view_all": "View All",
|
||||
"no_spam_invites": "No Spam Invites",
|
||||
"no_spam_invites_desc": "Invites detected as spam appear here.",
|
||||
|
||||
"invite_title": "Invite",
|
||||
"user_id": "User ID",
|
||||
"user_id_placeholder": "@username:server",
|
||||
|
|
|
|||
|
|
@ -161,6 +161,8 @@
|
|||
"enable": "Включить",
|
||||
"notification_sound": "Звук уведомлений",
|
||||
"notification_sound_desc": "Воспроизводить звук при получении нового сообщения.",
|
||||
"invite_spam_filter": "Фильтр спам-приглашений",
|
||||
"invite_spam_filter_desc": "Скрывать входящие приглашения в чаты, похожие на спам (массовые рассылки, заблокированные отправители, подозрительные слова). Выключите, чтобы видеть все приглашения как есть.",
|
||||
"push_notifications": "Фоновые уведомления",
|
||||
"push_description": "Получать уведомления даже когда Vojo свёрнут или закрыт.",
|
||||
"push_permission_blocked": "Разрешение на push-уведомления отклонено. Включите его в настройках устройства.",
|
||||
|
|
@ -365,6 +367,23 @@
|
|||
"no_direct_messages": "Нет личных сообщений",
|
||||
"no_direct_messages_desc": "У вас ещё нет личных сообщений.",
|
||||
"direct_message": "Новый чат",
|
||||
"invite_accept": "Принять",
|
||||
"invite_decline": "Отклонить",
|
||||
"invite_kind_direct": "личный чат",
|
||||
"invite_kind_group": "групповой чат",
|
||||
"invite_kind_space": "пространство",
|
||||
"invite_from_with_kind": "{{sender}} приглашает в {{kind}}",
|
||||
"invite_to_kind": "Приглашение в {{kind}}",
|
||||
"invite_badge_direct": "Личные",
|
||||
"invite_badge_group": "Группа",
|
||||
"invite_badge_space": "Пространство",
|
||||
"invite_badge_encrypted": "Шифрование",
|
||||
"invite_badge_spam": "Спам",
|
||||
"invite_show_spam_one": "Показать {{count}} скрытое приглашение",
|
||||
"invite_show_spam_few": "Показать {{count}} скрытых приглашения",
|
||||
"invite_show_spam_many": "Показать {{count}} скрытых приглашений",
|
||||
"invite_show_spam_other": "Показать {{count}} скрытых приглашений",
|
||||
"invite_hide_spam": "Скрыть спам",
|
||||
"create_chat": "Новый чат",
|
||||
"create_chat_subtitle": "Начните приватный зашифрованный чат, указав имя пользователя.",
|
||||
"start_first_chat": "Начать чат",
|
||||
|
|
@ -529,49 +548,6 @@
|
|||
"member_joined_call": "<bold>{{user}}</bold> теперь в звонке"
|
||||
},
|
||||
"Inbox": {
|
||||
"inbox": "Входящие",
|
||||
"invites": "Приглашения",
|
||||
"notifications": "Уведомления",
|
||||
|
||||
"notification_messages": "Уведомления",
|
||||
"filter": "Фильтр",
|
||||
"all_notifications": "Все уведомления",
|
||||
"highlighted": "Выделенные",
|
||||
"mark_as_read": "Отметить прочитанным",
|
||||
"open": "Открыть",
|
||||
"no_notifications": "Нет уведомлений",
|
||||
"no_notifications_desc": "У вас пока нет новых уведомлений.",
|
||||
"scroll_to_top": "Наверх",
|
||||
|
||||
"encrypted": "Зашифровано",
|
||||
"direct_message": "Личное сообщение",
|
||||
"space": "Пространство",
|
||||
"decline": "Отклонить",
|
||||
"accept": "Принять",
|
||||
"from": "От: ",
|
||||
"reason_label": "Причина: ",
|
||||
|
||||
"primary": "Основные",
|
||||
"public": "Публичные",
|
||||
"spam": "Спам",
|
||||
|
||||
"no_invites": "Нет приглашений",
|
||||
"no_invites_known_desc": "Когда кто-то, с кем вы состоите в одной комнате, отправит вам приглашение, оно появится здесь.",
|
||||
"no_invites_unknown_desc": "Приглашения от людей за пределами ваших комнат появятся здесь.",
|
||||
"decline_all": "Отклонить все",
|
||||
|
||||
"spam_invites_count_one": "{{count}} спам-приглашение",
|
||||
"spam_invites_count_few": "{{count}} спам-приглашения",
|
||||
"spam_invites_count_many": "{{count}} спам-приглашений",
|
||||
"spam_invites_count_other": "{{count}} спам-приглашений",
|
||||
"spam_invites_desc": "Некоторые из этих приглашений могут содержать вредоносный контент или были отправлены забаненными пользователями.",
|
||||
"report_all": "Пожаловаться на все",
|
||||
"block_all": "Заблокировать всех",
|
||||
"hide_all": "Скрыть все",
|
||||
"view_all": "Показать все",
|
||||
"no_spam_invites": "Нет спам-приглашений",
|
||||
"no_spam_invites_desc": "Приглашения, распознанные как спам, появятся здесь.",
|
||||
|
||||
"invite_title": "Пригласить",
|
||||
"user_id": "ID пользователя",
|
||||
"user_id_placeholder": "@username:server",
|
||||
|
|
|
|||
282
src/app/features/room-nav/DirectInviteRow.tsx
Normal file
282
src/app/features/room-nav/DirectInviteRow.tsx
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { Room } from 'matrix-js-sdk/lib/models/room';
|
||||
import { MatrixError } from 'matrix-js-sdk';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Avatar, Badge, Box, Button, Spinner, Text, color, toRem } from 'folds';
|
||||
import { NavItem, NavItemContent } from '../../components/nav';
|
||||
import { RoomAvatar } from '../../components/room-avatar';
|
||||
import { nameInitials } from '../../utils/common';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import {
|
||||
getDirectRoomAvatarUrl,
|
||||
getMemberDisplayName,
|
||||
getRoomAvatarUrl,
|
||||
getStateEvent,
|
||||
isDirectInvite,
|
||||
isSpace,
|
||||
} from '../../utils/room';
|
||||
import {
|
||||
addRoomIdToMDirect,
|
||||
getCanonicalAliasOrRoomId,
|
||||
getMxIdLocalPart,
|
||||
guessDmRoomUserId,
|
||||
} from '../../utils/matrix';
|
||||
import { getDirectRoomPath } from '../../pages/pathUtils';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
|
||||
const ROW_MIN_HEIGHT = toRem(68);
|
||||
|
||||
type DirectInviteRowProps = {
|
||||
room: Room;
|
||||
selected: boolean;
|
||||
isSpam?: boolean;
|
||||
};
|
||||
|
||||
export function DirectInviteRow({ room, selected, isSpam }: DirectInviteRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const { navigateSpace } = useRoomNavigate();
|
||||
const navigate = useNavigate();
|
||||
const userId = mx.getSafeUserId();
|
||||
|
||||
const direct = isDirectInvite(room, userId);
|
||||
const space = isSpace(room);
|
||||
const encrypted = !!getStateEvent(room, StateEvent.RoomEncryption);
|
||||
|
||||
const avatar = direct
|
||||
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
|
||||
: getRoomAvatarUrl(mx, room, 96, useAuthentication);
|
||||
const roomName = room.name || room.getCanonicalAlias() || room.roomId;
|
||||
|
||||
const member = room.getMember(userId);
|
||||
const senderId = member?.events.member?.getSender();
|
||||
let senderName: string | null = null;
|
||||
if (senderId) {
|
||||
senderName = getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
|
||||
}
|
||||
const topic =
|
||||
getStateEvent(room, StateEvent.RoomTopic)?.getContent<{ topic?: string }>()?.topic ?? null;
|
||||
let kindLabel: string;
|
||||
if (space) {
|
||||
kindLabel = t('Direct.invite_kind_space');
|
||||
} else if (direct) {
|
||||
kindLabel = t('Direct.invite_kind_direct');
|
||||
} else {
|
||||
kindLabel = t('Direct.invite_kind_group');
|
||||
}
|
||||
|
||||
const [joinState, join] = useAsyncCallback<void, MatrixError, []>(
|
||||
useCallback(async () => {
|
||||
// m.direct is also written by the global useAutoDirectSync listener on
|
||||
// the invite→join MyMembership transition; the call here lets the
|
||||
// navigate() below land on a route that already sees the room as a DM,
|
||||
// shaving one render. Both writes are idempotent.
|
||||
const dmUserId = direct ? guessDmRoomUserId(room, userId) : undefined;
|
||||
await mx.joinRoom(room.roomId);
|
||||
if (dmUserId) {
|
||||
await addRoomIdToMDirect(mx, room.roomId, dmUserId);
|
||||
}
|
||||
if (space) {
|
||||
navigateSpace(room.roomId);
|
||||
return;
|
||||
}
|
||||
const alias = getCanonicalAliasOrRoomId(mx, room.roomId);
|
||||
navigate(getDirectRoomPath(alias));
|
||||
}, [mx, room, direct, space, userId, navigate, navigateSpace])
|
||||
);
|
||||
|
||||
const [leaveState, leave] = useAsyncCallback<Record<string, never>, MatrixError, []>(
|
||||
useCallback(() => mx.leave(room.roomId), [mx, room.roomId])
|
||||
);
|
||||
|
||||
const joining =
|
||||
joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success;
|
||||
const leaving =
|
||||
leaveState.status === AsyncStatus.Loading || leaveState.status === AsyncStatus.Success;
|
||||
const busy = joining || leaving;
|
||||
|
||||
let errMessage: string | null = null;
|
||||
if (joinState.status === AsyncStatus.Error) {
|
||||
errMessage = joinState.error.message;
|
||||
} else if (leaveState.status === AsyncStatus.Error) {
|
||||
errMessage = leaveState.error.message;
|
||||
}
|
||||
|
||||
const inviteLine = senderName
|
||||
? t('Direct.invite_from_with_kind', { sender: senderName, kind: kindLabel })
|
||||
: t('Direct.invite_to_kind', { kind: kindLabel });
|
||||
|
||||
let kindBadge: React.ReactNode;
|
||||
if (space) {
|
||||
kindBadge = (
|
||||
<Badge variant="Secondary" fill="Soft" radii="300" size="400">
|
||||
<Text as="span" size="L400">
|
||||
{t('Direct.invite_badge_space')}
|
||||
</Text>
|
||||
</Badge>
|
||||
);
|
||||
} else if (direct) {
|
||||
kindBadge = (
|
||||
<Badge variant="Primary" fill="Solid" radii="300" size="400">
|
||||
<Text as="span" size="L400">
|
||||
{t('Direct.invite_badge_direct')}
|
||||
</Text>
|
||||
</Badge>
|
||||
);
|
||||
} else {
|
||||
kindBadge = (
|
||||
<Badge variant="Secondary" fill="Solid" radii="300" size="400">
|
||||
<Text as="span" size="L400">
|
||||
{t('Direct.invite_badge_group')}
|
||||
</Text>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
const badgeRow = (
|
||||
<Box as="span" alignItems="Center" gap="100" wrap="Wrap">
|
||||
{kindBadge}
|
||||
{encrypted && (
|
||||
<Badge variant="Success" fill="Solid" radii="300" size="400">
|
||||
<Text as="span" size="L400">
|
||||
{t('Direct.invite_badge_encrypted')}
|
||||
</Text>
|
||||
</Badge>
|
||||
)}
|
||||
{isSpam && (
|
||||
<Badge variant="Critical" fill="Solid" radii="300" size="400">
|
||||
<Text as="span" size="L400">
|
||||
{t('Direct.invite_badge_spam')}
|
||||
</Text>
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<NavItem
|
||||
variant="Background"
|
||||
radii="400"
|
||||
highlight={!isSpam}
|
||||
aria-selected={selected}
|
||||
style={{
|
||||
minHeight: ROW_MIN_HEIGHT,
|
||||
boxSizing: 'border-box',
|
||||
opacity: isSpam ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
<NavItemContent>
|
||||
<Box
|
||||
as="span"
|
||||
grow="Yes"
|
||||
alignItems="Start"
|
||||
gap="300"
|
||||
style={{
|
||||
minHeight: ROW_MIN_HEIGHT,
|
||||
boxSizing: 'border-box',
|
||||
padding: `${toRem(8)} 0`,
|
||||
}}
|
||||
>
|
||||
<Avatar size="300" radii="400">
|
||||
<RoomAvatar
|
||||
roomId={room.roomId}
|
||||
src={isSpam ? undefined : avatar}
|
||||
alt={roomName}
|
||||
renderFallback={() => (
|
||||
<Text as="span" size="H6">
|
||||
{nameInitials(isSpam ? undefined : roomName)}
|
||||
</Text>
|
||||
)}
|
||||
/>
|
||||
</Avatar>
|
||||
<Box
|
||||
as="span"
|
||||
direction="Column"
|
||||
grow="Yes"
|
||||
gap="100"
|
||||
style={{ minWidth: 0, overflow: 'hidden' }}
|
||||
>
|
||||
<Box as="span" alignItems="Center" gap="200" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
as="span"
|
||||
grow="Yes"
|
||||
style={{ minWidth: 0, overflow: 'hidden' }}
|
||||
>
|
||||
<Text as="span" size="T300" truncate style={{ fontWeight: 600 }}>
|
||||
{roomName}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box as="span" shrink="No">
|
||||
{badgeRow}
|
||||
</Box>
|
||||
</Box>
|
||||
<Text
|
||||
as="span"
|
||||
size="T200"
|
||||
truncate
|
||||
style={{
|
||||
opacity: 0.7,
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
{inviteLine}
|
||||
</Text>
|
||||
{topic && (
|
||||
<Text
|
||||
as="span"
|
||||
size="T200"
|
||||
truncate
|
||||
style={{
|
||||
opacity: 0.55,
|
||||
fontWeight: 400,
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
>
|
||||
{topic}
|
||||
</Text>
|
||||
)}
|
||||
{errMessage && (
|
||||
<Text as="span" size="T200" style={{ color: color.Critical.Main }}>
|
||||
{errMessage}
|
||||
</Text>
|
||||
)}
|
||||
<Box as="span" alignItems="Center" gap="200" style={{ marginTop: toRem(4) }}>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
disabled={busy}
|
||||
onClick={leave}
|
||||
before={leaving ? <Spinner variant="Secondary" size="100" /> : undefined}
|
||||
>
|
||||
<Text as="span" size="B300">
|
||||
{t('Direct.invite_decline')}
|
||||
</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Success"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
outlined
|
||||
disabled={busy}
|
||||
onClick={join}
|
||||
before={joining ? <Spinner variant="Success" fill="Soft" size="100" /> : undefined}
|
||||
>
|
||||
<Text as="span" size="B300">
|
||||
{t('Direct.invite_accept')}
|
||||
</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</NavItemContent>
|
||||
</NavItem>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './RoomNavItem';
|
||||
export * from './RoomNavCategoryButton';
|
||||
export * from './DmStreamRow';
|
||||
export * from './DirectInviteRow';
|
||||
|
|
|
|||
|
|
@ -175,6 +175,7 @@ export function SystemNotification() {
|
|||
settingsAtom,
|
||||
'isNotificationSounds'
|
||||
);
|
||||
const [inviteSpamFilter, setInviteSpamFilter] = useSetting(settingsAtom, 'inviteSpamFilter');
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
|
|
@ -199,6 +200,18 @@ export function SystemNotification() {
|
|||
after={<Switch value={isNotificationSounds} onChange={setIsNotificationSounds} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title={t('Settings.invite_spam_filter')}
|
||||
description={t('Settings.invite_spam_filter_desc')}
|
||||
after={<Switch value={inviteSpamFilter} onChange={setInviteSpamFilter} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
import { useMatch } from 'react-router-dom';
|
||||
import {
|
||||
getInboxInvitesPath,
|
||||
getInboxNotificationsPath,
|
||||
getInboxPath,
|
||||
} from '../../pages/pathUtils';
|
||||
|
||||
export const useInboxSelected = (): boolean => {
|
||||
const match = useMatch({
|
||||
path: getInboxPath(),
|
||||
caseSensitive: true,
|
||||
end: false,
|
||||
});
|
||||
|
||||
return !!match;
|
||||
};
|
||||
|
||||
export const useInboxNotificationsSelected = (): boolean => {
|
||||
const match = useMatch({
|
||||
path: getInboxNotificationsPath(),
|
||||
caseSensitive: true,
|
||||
end: false,
|
||||
});
|
||||
|
||||
return !!match;
|
||||
};
|
||||
|
||||
export const useInboxInvitesSelected = (): boolean => {
|
||||
const match = useMatch({
|
||||
path: getInboxInvitesPath(),
|
||||
caseSensitive: true,
|
||||
end: false,
|
||||
});
|
||||
|
||||
return !!match;
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,16 +18,13 @@ import {
|
|||
EXPLORE_PATH,
|
||||
HOME_PATH,
|
||||
LOGIN_PATH,
|
||||
INBOX_PATH,
|
||||
REGISTER_PATH,
|
||||
RESET_PASSWORD_PATH,
|
||||
SPACE_PATH,
|
||||
_CREATE_PATH,
|
||||
_FEATURED_PATH,
|
||||
_INVITES_PATH,
|
||||
_JOIN_PATH,
|
||||
_LOBBY_PATH,
|
||||
_NOTIFICATIONS_PATH,
|
||||
_ROOM_PATH,
|
||||
_SEARCH_PATH,
|
||||
_SERVER_PATH,
|
||||
|
|
@ -41,7 +38,6 @@ import {
|
|||
getDirectCreatePath,
|
||||
getExploreFeaturedPath,
|
||||
getHomePath,
|
||||
getInboxNotificationsPath,
|
||||
getLoginPath,
|
||||
getOriginBaseUrl,
|
||||
getSpaceLobbyPath,
|
||||
|
|
@ -54,7 +50,6 @@ import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct';
|
|||
import { BotExperienceHost, Bots } from './client/bots';
|
||||
import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space';
|
||||
import { Explore, FeaturedRooms, PublicRooms } from './client/explore';
|
||||
import { Notifications, Inbox, Invites } from './client/inbox';
|
||||
import { setAfterLoginRedirectPath } from './afterLoginRedirectPath';
|
||||
import { Room } from '../features/room';
|
||||
import { Lobby } from '../features/lobby';
|
||||
|
|
@ -330,30 +325,10 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
|||
</Route>
|
||||
<Route path={CREATE_PATH} element={<Create />} />
|
||||
<Route path={USER_LINK_PATH} element={<UserLinkRedirect />} />
|
||||
<Route
|
||||
path={INBOX_PATH}
|
||||
element={
|
||||
<PageRoot
|
||||
nav={
|
||||
<MobileFriendlyPageNav path={INBOX_PATH}>
|
||||
<Inbox />
|
||||
</MobileFriendlyPageNav>
|
||||
}
|
||||
>
|
||||
<Outlet />
|
||||
</PageRoot>
|
||||
}
|
||||
>
|
||||
{mobile ? null : (
|
||||
<Route
|
||||
index
|
||||
loader={() => redirect(getInboxNotificationsPath())}
|
||||
element={<WelcomePage />}
|
||||
/>
|
||||
)}
|
||||
<Route path={_NOTIFICATIONS_PATH} element={<Notifications />} />
|
||||
<Route path={_INVITES_PATH} element={<Invites />} />
|
||||
</Route>
|
||||
{/* Legacy /inbox/ tree — invites moved inline into the Direct list,
|
||||
the Notifications aggregator was removed. Keep the route as a
|
||||
redirect so old push deep-links and bookmarks resolve cleanly. */}
|
||||
<Route path="/inbox/*" element={<Navigate to={DIRECT_PATH} replace />} />
|
||||
</Route>
|
||||
<Route path="/*" element={<p>Page not found</p>} />
|
||||
</Route>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import {
|
|||
import {
|
||||
DirectTab,
|
||||
SpaceTabs,
|
||||
InboxTab,
|
||||
ExploreTab,
|
||||
SettingsTab,
|
||||
UnverifiedTab,
|
||||
|
|
@ -43,7 +42,6 @@ export function SidebarNav() {
|
|||
<SidebarStack>
|
||||
<SearchTab />
|
||||
<UnverifiedTab />
|
||||
<InboxTab />
|
||||
<SettingsTab />
|
||||
</SidebarStack>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Box, Button, Icon, Icons, Text, color, config, toRem } from 'folds';
|
||||
|
|
@ -11,10 +11,11 @@ import { getDirectCreatePath, getDirectRoomPath } from '../../pathUtils';
|
|||
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
|
||||
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
||||
import { VirtualTile } from '../../../components/virtualizer';
|
||||
import { DmStreamRow } from '../../../features/room-nav';
|
||||
import { DirectInviteRow, DmStreamRow } from '../../../features/room-nav';
|
||||
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
|
||||
import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
|
||||
import { useDirectRooms } from './useDirectRooms';
|
||||
import { useDirectInvites, DirectInviteEntry } from './useDirectInvites';
|
||||
import { PageNav, PageNavContent } from '../../../components/page';
|
||||
import {
|
||||
getRoomNotificationMode,
|
||||
|
|
@ -26,6 +27,12 @@ import { DirectSelfRow } from './DirectSelfRow';
|
|||
|
||||
const MONO_FONT = '"JetBrains Mono Variable", ui-monospace, monospace';
|
||||
|
||||
type ListItem =
|
||||
| { kind: 'invite'; entry: DirectInviteEntry }
|
||||
| { kind: 'spam-toggle'; spamCount: number; expanded: boolean }
|
||||
| { kind: 'spam-invite'; entry: DirectInviteEntry }
|
||||
| { kind: 'direct'; roomId: string };
|
||||
|
||||
function DirectEmpty() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -88,12 +95,52 @@ function DirectFooterStatus() {
|
|||
);
|
||||
}
|
||||
|
||||
type SpamToggleRowProps = {
|
||||
spamCount: number;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
};
|
||||
function SpamToggleRow({ spamCount, expanded, onToggle }: SpamToggleRowProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Box
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
aria-expanded={expanded}
|
||||
style={{
|
||||
appearance: 'none',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
cursor: 'pointer',
|
||||
padding: `${toRem(8)} ${toRem(12)}`,
|
||||
width: '100%',
|
||||
color: color.Surface.OnContainer,
|
||||
opacity: 0.7,
|
||||
textAlign: 'left',
|
||||
font: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Icon size="100" src={expanded ? Icons.ChevronTop : Icons.ChevronBottom} />
|
||||
<Text as="span" size="T200" style={{ fontWeight: 500 }}>
|
||||
{expanded
|
||||
? t('Direct.invite_hide_spam')
|
||||
: t('Direct.invite_show_spam', { count: spamCount })}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function Direct() {
|
||||
const mx = useMatrixClient();
|
||||
useNavToActivePathMapper('direct');
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const directs = useDirectRooms();
|
||||
const invites = useDirectInvites();
|
||||
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
||||
const [spamExpanded, setSpamExpanded] = useState(false);
|
||||
// roomToUnreadAtom only changes on read/unread transitions and ignores own
|
||||
// events — covers incoming notifying messages but not own sends or muted
|
||||
// incoming. Kept as a subscribe-only re-render trigger.
|
||||
|
|
@ -130,16 +177,56 @@ export function Direct() {
|
|||
}, [mx, directs]);
|
||||
|
||||
const selectedRoomId = useSelectedRoom();
|
||||
const noRoomToDisplay = directs.length === 0;
|
||||
|
||||
// Sort each render — small list, getLastActiveTimestamp changes outside
|
||||
// React's dep model so memoising would need a manual trigger anyway.
|
||||
const sortedDirects = Array.from(directs).sort(factoryRoomIdByActivity(mx));
|
||||
const items = useMemo<ListItem[]>(() => {
|
||||
const list: ListItem[] = [];
|
||||
const cleanInvites = invites.filter((i) => !i.isSpam);
|
||||
const spamInvites = invites.filter((i) => i.isSpam);
|
||||
cleanInvites.forEach((entry) => list.push({ kind: 'invite', entry }));
|
||||
if (spamInvites.length > 0) {
|
||||
list.push({
|
||||
kind: 'spam-toggle',
|
||||
spamCount: spamInvites.length,
|
||||
expanded: spamExpanded,
|
||||
});
|
||||
if (spamExpanded) {
|
||||
spamInvites.forEach((entry) => list.push({ kind: 'spam-invite', entry }));
|
||||
}
|
||||
}
|
||||
const sortedDirects = Array.from(directs).sort(factoryRoomIdByActivity(mx));
|
||||
sortedDirects.forEach((roomId) => list.push({ kind: 'direct', roomId }));
|
||||
return list;
|
||||
}, [invites, directs, spamExpanded, mx]);
|
||||
|
||||
const noRoomToDisplay = items.length === 0;
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: sortedDirects.length,
|
||||
count: items.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 68,
|
||||
// Per-kind estimates so the initial scroll height is close to actual
|
||||
// before measureElement self-corrects: invite cards are ~140px (header +
|
||||
// sub-line + buttons + optional topic), spam toggle is ~32px, DM rows
|
||||
// ~68px. Keeps first paint stable when the panel opens with invites.
|
||||
estimateSize: (index) => {
|
||||
const item = items[index];
|
||||
if (!item) return 68;
|
||||
if (item.kind === 'invite' || item.kind === 'spam-invite') return 140;
|
||||
if (item.kind === 'spam-toggle') return 32;
|
||||
return 68;
|
||||
},
|
||||
// Stable per-item identity so the measurement cache survives item-kind
|
||||
// shifts at the same index. Without this TanStack falls back to index,
|
||||
// and a DM row at index 0 inheriting an invite card's measured height
|
||||
// (or vice versa) flashes a wrong size for one frame on every list
|
||||
// mutation that adds/removes an invite at the top.
|
||||
getItemKey: (index) => {
|
||||
const item = items[index];
|
||||
if (!item) return index;
|
||||
if (item.kind === 'invite') return `invite:${item.entry.roomId}`;
|
||||
if (item.kind === 'spam-invite') return `spam-invite:${item.entry.roomId}`;
|
||||
if (item.kind === 'spam-toggle') return 'spam-toggle';
|
||||
return `direct:${item.roomId}`;
|
||||
},
|
||||
overscan: 10,
|
||||
});
|
||||
|
||||
|
|
@ -159,15 +246,52 @@ export function Direct() {
|
|||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((vItem) => {
|
||||
const roomId = sortedDirects[vItem.index];
|
||||
const item = items[vItem.index];
|
||||
if (!item) return null;
|
||||
|
||||
if (item.kind === 'invite' || item.kind === 'spam-invite') {
|
||||
const { entry } = item;
|
||||
const selected = selectedRoomId === entry.roomId;
|
||||
return (
|
||||
<VirtualTile
|
||||
virtualItem={vItem}
|
||||
key={`invite-${entry.roomId}`}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<DirectInviteRow
|
||||
room={entry.room}
|
||||
selected={selected}
|
||||
isSpam={item.kind === 'spam-invite'}
|
||||
/>
|
||||
</VirtualTile>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.kind === 'spam-toggle') {
|
||||
return (
|
||||
<VirtualTile
|
||||
virtualItem={vItem}
|
||||
key="spam-toggle"
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<SpamToggleRow
|
||||
spamCount={item.spamCount}
|
||||
expanded={item.expanded}
|
||||
onToggle={() => setSpamExpanded((v) => !v)}
|
||||
/>
|
||||
</VirtualTile>
|
||||
);
|
||||
}
|
||||
|
||||
// kind === 'direct'
|
||||
const { roomId } = item;
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return null;
|
||||
const selected = selectedRoomId === roomId;
|
||||
|
||||
return (
|
||||
<VirtualTile
|
||||
virtualItem={vItem}
|
||||
key={vItem.index}
|
||||
key={`direct-${roomId}`}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<DmStreamRow
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import React, { ReactNode } from 'react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Navigate, useParams } from 'react-router-dom';
|
||||
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
||||
import { IsOneOnOneProvider, RoomProvider } from '../../../hooks/useRoom';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
|
||||
import { isSpace } from '../../../utils/room';
|
||||
import { useIsOneOnOneRoom } from '../../../hooks/useIsOneOnOneRoom';
|
||||
import { Membership } from '../../../../types/matrix/room';
|
||||
import { getDirectPath } from '../../pathUtils';
|
||||
|
||||
// Inner provider hosts the reactive 1:1 subscription. Hooks can't run inside
|
||||
// the early-return path of the parent, so we split the component once `room`
|
||||
|
|
@ -27,6 +29,15 @@ export function DirectRouteRoomProvider({ children }: { children: ReactNode }) {
|
|||
const roomId = useSelectedRoom();
|
||||
const room = mx.getRoom(roomId);
|
||||
|
||||
// Central membership guard: invite-state rooms have no useful Room view —
|
||||
// the Accept/Decline UI lives on the inline DirectInviteRow in the panel.
|
||||
// Push deep links and legacy /home/{roomId}/ shim can both deliver an
|
||||
// invite-room id here; bounce to the bare /direct/ list so the user lands
|
||||
// on the row instead of an empty stripped-state timeline.
|
||||
if (room && room.getMyMembership() === Membership.Invite) {
|
||||
return <Navigate to={getDirectPath()} replace />;
|
||||
}
|
||||
|
||||
// After P3c the Direct tab is universal — any joined non-space room renders
|
||||
// here. Spaces (= future Channels) keep their own /{spaceId}/ route.
|
||||
if (!room || isSpace(room)) {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './Direct';
|
||||
export * from './RoomProvider';
|
||||
export * from './DirectCreate';
|
||||
export * from './useDirectInvites';
|
||||
|
|
|
|||
97
src/app/pages/client/direct/useDirectInvites.ts
Normal file
97
src/app/pages/client/direct/useDirectInvites.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { useAtomValue } from 'jotai';
|
||||
import { useMemo } from 'react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { allInvitesAtom } from '../../../state/room-list/inviteList';
|
||||
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
||||
import { bannedInRooms, getMemberDisplayName, getStateEvent } from '../../../utils/room';
|
||||
import { testBadWords } from '../../../plugins/bad-words';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
import { getMxIdLocalPart } from '../../../utils/matrix';
|
||||
import { StateEvent } from '../../../../types/matrix/room';
|
||||
|
||||
export type DirectInviteEntry = {
|
||||
room: Room;
|
||||
roomId: string;
|
||||
ts: number;
|
||||
isSpam: boolean;
|
||||
};
|
||||
|
||||
const getInviteTs = (room: Room, myUserId: string): number => {
|
||||
const me = room.getMember(myUserId);
|
||||
return me?.events.member?.getTs() ?? 0;
|
||||
};
|
||||
|
||||
const inviteHasBadWords = (room: Room, myUserId: string): boolean => {
|
||||
const roomName = room.name || '';
|
||||
const topic =
|
||||
getStateEvent(room, StateEvent.RoomTopic)?.getContent<{ topic?: string }>()?.topic ?? '';
|
||||
const me = room.getMember(myUserId);
|
||||
const memberEvent = me?.events.member;
|
||||
const senderId = memberEvent?.getSender() ?? '';
|
||||
const senderName = senderId
|
||||
? getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId
|
||||
: '';
|
||||
const reasonContent = memberEvent?.getContent();
|
||||
const reason =
|
||||
reasonContent && 'reason' in reasonContent && typeof reasonContent.reason === 'string'
|
||||
? reasonContent.reason
|
||||
: '';
|
||||
|
||||
return (
|
||||
testBadWords(roomName) ||
|
||||
testBadWords(topic) ||
|
||||
testBadWords(senderName) ||
|
||||
testBadWords(senderId) ||
|
||||
testBadWords(reason)
|
||||
);
|
||||
};
|
||||
|
||||
export const useDirectInvites = (): DirectInviteEntry[] => {
|
||||
const mx = useMatrixClient();
|
||||
const inviteIds = useAtomValue(allInvitesAtom);
|
||||
const allRooms = useAtomValue(allRoomsAtom);
|
||||
const [spamFilterEnabled] = useSetting(settingsAtom, 'inviteSpamFilter');
|
||||
const myUserId = mx.getSafeUserId();
|
||||
|
||||
return useMemo(() => {
|
||||
// Cache `bannedInRooms` per senderId — a single inviter MXID can be the
|
||||
// sender of multiple invite rooms, and bannedInRooms iterates every
|
||||
// joined room. Without the cache we'd be O(invites × joinedRooms) on
|
||||
// every recompute.
|
||||
const bannedCache = new Map<string, boolean>();
|
||||
const isSenderBanned = (senderId: string): boolean => {
|
||||
const cached = bannedCache.get(senderId);
|
||||
if (cached !== undefined) return cached;
|
||||
const banned = bannedInRooms(mx, allRooms, senderId);
|
||||
bannedCache.set(senderId, banned);
|
||||
return banned;
|
||||
};
|
||||
|
||||
const out: DirectInviteEntry[] = [];
|
||||
inviteIds.forEach((roomId) => {
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return;
|
||||
const me = room.getMember(myUserId);
|
||||
const senderId = me?.events.member?.getSender() ?? '';
|
||||
// Moderation signal — sender is banned in a room we share. This is a
|
||||
// server-side moderator's verdict, not a personal preference, so it's
|
||||
// ALWAYS classified as spam regardless of the user-facing toggle.
|
||||
const moderationSpam = !!senderId && isSenderBanned(senderId);
|
||||
// Lexical signal — bad-words match in the room name / topic / sender /
|
||||
// reason. This is heuristic and noisy, so it's gated on the user-facing
|
||||
// `inviteSpamFilter` toggle for users who'd rather see everything raw.
|
||||
const lexicalSpam = spamFilterEnabled && inviteHasBadWords(room, myUserId);
|
||||
const isSpam = moderationSpam || lexicalSpam;
|
||||
out.push({
|
||||
room,
|
||||
roomId,
|
||||
ts: getInviteTs(room, myUserId),
|
||||
isSpam,
|
||||
});
|
||||
});
|
||||
out.sort((a, b) => b.ts - a.ts);
|
||||
return out;
|
||||
}, [mx, inviteIds, allRooms, myUserId, spamFilterEnabled]);
|
||||
};
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Avatar, Box, Icon, Icons, Text } from 'folds';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { NavCategory, NavItem, NavItemContent, NavLink } from '../../../components/nav';
|
||||
import { getInboxInvitesPath, getInboxNotificationsPath } from '../../pathUtils';
|
||||
import {
|
||||
useInboxInvitesSelected,
|
||||
useInboxNotificationsSelected,
|
||||
} from '../../../hooks/router/useInbox';
|
||||
import { UnreadBadge } from '../../../components/unread-badge';
|
||||
import { allInvitesAtom } from '../../../state/room-list/inviteList';
|
||||
import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
|
||||
import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page';
|
||||
|
||||
function InvitesNavItem() {
|
||||
const { t } = useTranslation();
|
||||
const invitesSelected = useInboxInvitesSelected();
|
||||
const allInvites = useAtomValue(allInvitesAtom);
|
||||
const inviteCount = allInvites.length;
|
||||
|
||||
return (
|
||||
<NavItem
|
||||
variant="Background"
|
||||
radii="400"
|
||||
highlight={inviteCount > 0}
|
||||
aria-selected={invitesSelected}
|
||||
>
|
||||
<NavLink to={getInboxInvitesPath()}>
|
||||
<NavItemContent>
|
||||
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||
<Avatar size="200" radii="400">
|
||||
<Icon src={Icons.Mail} size="100" filled={invitesSelected} />
|
||||
</Avatar>
|
||||
<Box as="span" grow="Yes">
|
||||
<Text as="span" size="Inherit" truncate>
|
||||
{t('Inbox.invites')}
|
||||
</Text>
|
||||
</Box>
|
||||
{inviteCount > 0 && <UnreadBadge highlight count={inviteCount} />}
|
||||
</Box>
|
||||
</NavItemContent>
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
);
|
||||
}
|
||||
|
||||
export function Inbox() {
|
||||
const { t } = useTranslation();
|
||||
useNavToActivePathMapper('inbox');
|
||||
const notificationsSelected = useInboxNotificationsSelected();
|
||||
|
||||
return (
|
||||
<PageNav>
|
||||
<PageNavHeader>
|
||||
<Box grow="Yes" gap="300">
|
||||
<Box grow="Yes">
|
||||
<Text size="H4" truncate>
|
||||
{t('Inbox.inbox')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</PageNavHeader>
|
||||
|
||||
<PageNavContent>
|
||||
<Box direction="Column" gap="300">
|
||||
<NavCategory>
|
||||
<NavItem variant="Background" radii="400" aria-selected={notificationsSelected}>
|
||||
<NavLink to={getInboxNotificationsPath()}>
|
||||
<NavItemContent>
|
||||
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||
<Avatar size="200" radii="400">
|
||||
<Icon src={Icons.MessageUnread} size="100" filled={notificationsSelected} />
|
||||
</Avatar>
|
||||
<Box as="span" grow="Yes">
|
||||
<Text as="span" size="Inherit" truncate>
|
||||
{t('Inbox.notifications')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</NavItemContent>
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
<InvitesNavItem />
|
||||
</NavCategory>
|
||||
</Box>
|
||||
</PageNavContent>
|
||||
</PageNav>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,840 +0,0 @@
|
|||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Scroll,
|
||||
Spinner,
|
||||
Text,
|
||||
color,
|
||||
config,
|
||||
} from 'folds';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RoomTopicEventContent } from 'matrix-js-sdk/lib/types';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { MatrixClient, MatrixError, Room } from 'matrix-js-sdk';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Page,
|
||||
PageContent,
|
||||
PageContentCenter,
|
||||
PageHeader,
|
||||
PageHero,
|
||||
PageHeroEmpty,
|
||||
PageHeroSection,
|
||||
} from '../../../components/page';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { allInvitesAtom } from '../../../state/room-list/inviteList';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import {
|
||||
bannedInRooms,
|
||||
getCommonRooms,
|
||||
getDirectRoomAvatarUrl,
|
||||
getMemberDisplayName,
|
||||
getRoomAvatarUrl,
|
||||
getStateEvent,
|
||||
isDirectInvite,
|
||||
isSpace,
|
||||
} from '../../../utils/room';
|
||||
import { nameInitials } from '../../../utils/common';
|
||||
import { RoomAvatar } from '../../../components/room-avatar';
|
||||
import {
|
||||
addRoomIdToMDirect,
|
||||
getCanonicalAliasOrRoomId,
|
||||
getMxIdLocalPart,
|
||||
guessDmRoomUserId,
|
||||
rateLimitedActions,
|
||||
} from '../../../utils/matrix';
|
||||
import { getDirectRoomPath } from '../../pathUtils';
|
||||
import { Time } from '../../../components/message';
|
||||
import { useElementSizeObserver } from '../../../hooks/useElementSizeObserver';
|
||||
import { onEnterOrSpace, stopPropagation } from '../../../utils/keyboard';
|
||||
import { RoomTopicViewer } from '../../../components/room-topic-viewer';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
||||
import { BackRouteHandler } from '../../../components/BackRouteHandler';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { StateEvent } from '../../../../types/matrix/room';
|
||||
import { testBadWords } from '../../../plugins/bad-words';
|
||||
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
||||
import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers';
|
||||
import { useReportRoomSupported } from '../../../hooks/useReportRoomSupported';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
|
||||
const COMPACT_CARD_WIDTH = 548;
|
||||
|
||||
type InviteData = {
|
||||
room: Room;
|
||||
roomId: string;
|
||||
roomName: string;
|
||||
roomAvatar?: string;
|
||||
roomTopic?: string;
|
||||
roomAlias?: string;
|
||||
|
||||
senderId: string;
|
||||
senderName: string;
|
||||
inviteTs?: number;
|
||||
reason?: string;
|
||||
|
||||
isSpace: boolean;
|
||||
isDirect: boolean;
|
||||
isEncrypted: boolean;
|
||||
};
|
||||
|
||||
const makeInviteData = (mx: MatrixClient, room: Room, useAuthentication: boolean): InviteData => {
|
||||
const userId = mx.getSafeUserId();
|
||||
const direct = isDirectInvite(room, userId);
|
||||
|
||||
const roomAvatar = direct
|
||||
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
|
||||
: getRoomAvatarUrl(mx, room, 96, useAuthentication);
|
||||
const roomName = room.name || room.getCanonicalAlias() || room.roomId;
|
||||
const roomTopic =
|
||||
getStateEvent(room, StateEvent.RoomTopic)?.getContent<RoomTopicEventContent>()?.topic ??
|
||||
undefined;
|
||||
|
||||
const member = room.getMember(userId);
|
||||
const memberEvent = member?.events.member;
|
||||
|
||||
const content = memberEvent?.getContent();
|
||||
const senderId = memberEvent?.getSender();
|
||||
|
||||
const senderName = senderId
|
||||
? getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId
|
||||
: undefined;
|
||||
const inviteTs = memberEvent?.getTs();
|
||||
const reason =
|
||||
content && 'reason' in content && typeof content.reason === 'string'
|
||||
? content.reason
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
room,
|
||||
roomId: room.roomId,
|
||||
roomAvatar,
|
||||
roomName,
|
||||
roomTopic,
|
||||
roomAlias: room.getCanonicalAlias() ?? undefined,
|
||||
|
||||
senderId: senderId ?? 'Unknown',
|
||||
senderName: senderName ?? 'Unknown',
|
||||
inviteTs,
|
||||
reason,
|
||||
|
||||
isSpace: isSpace(room),
|
||||
isDirect: direct,
|
||||
isEncrypted: !!getStateEvent(room, StateEvent.RoomEncryption),
|
||||
};
|
||||
};
|
||||
|
||||
const hasBadWords = (invite: InviteData): boolean =>
|
||||
testBadWords(invite.roomName) ||
|
||||
testBadWords(invite.roomTopic ?? '') ||
|
||||
testBadWords(invite.senderName) ||
|
||||
testBadWords(invite.senderId) ||
|
||||
testBadWords(invite.reason || '');
|
||||
|
||||
type NavigateHandler = (roomId: string, space: boolean) => void;
|
||||
|
||||
type InviteCardProps = {
|
||||
invite: InviteData;
|
||||
compact?: boolean;
|
||||
hour24Clock: boolean;
|
||||
dateFormatString: string;
|
||||
onNavigate: NavigateHandler;
|
||||
hideAvatar: boolean;
|
||||
};
|
||||
function InviteCard({
|
||||
invite,
|
||||
compact,
|
||||
hour24Clock,
|
||||
dateFormatString,
|
||||
onNavigate,
|
||||
hideAvatar,
|
||||
}: InviteCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
const navigate = useNavigate();
|
||||
const userId = mx.getSafeUserId();
|
||||
|
||||
const [viewTopic, setViewTopic] = useState(false);
|
||||
const closeTopic = () => setViewTopic(false);
|
||||
const openTopic = () => setViewTopic(true);
|
||||
|
||||
const [joinState, join] = useAsyncCallback<void, MatrixError, []>(
|
||||
useCallback(async () => {
|
||||
const dmUserId = isDirectInvite(invite.room, userId)
|
||||
? guessDmRoomUserId(invite.room, userId)
|
||||
: undefined;
|
||||
|
||||
await mx.joinRoom(invite.roomId);
|
||||
if (dmUserId) {
|
||||
await addRoomIdToMDirect(mx, invite.roomId, dmUserId);
|
||||
const alias = getCanonicalAliasOrRoomId(mx, invite.roomId);
|
||||
navigate(getDirectRoomPath(alias));
|
||||
return;
|
||||
}
|
||||
onNavigate(invite.roomId, invite.isSpace);
|
||||
}, [mx, invite, userId, onNavigate, navigate])
|
||||
);
|
||||
const [leaveState, leave] = useAsyncCallback<Record<string, never>, MatrixError, []>(
|
||||
useCallback(() => mx.leave(invite.roomId), [mx, invite])
|
||||
);
|
||||
|
||||
const joining =
|
||||
joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success;
|
||||
const leaving =
|
||||
leaveState.status === AsyncStatus.Loading || leaveState.status === AsyncStatus.Success;
|
||||
|
||||
return (
|
||||
<SequenceCard
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="300"
|
||||
style={{ padding: config.space.S400 }}
|
||||
>
|
||||
{(invite.isEncrypted || invite.isDirect || invite.isSpace) && (
|
||||
<Box gap="200" alignItems="Center">
|
||||
{invite.isEncrypted && (
|
||||
<Box shrink="No" alignItems="Center" justifyContent="Center">
|
||||
<Badge variant="Success" fill="Solid" size="400" radii="300">
|
||||
<Text size="L400">{t('Inbox.encrypted')}</Text>
|
||||
</Badge>
|
||||
</Box>
|
||||
)}
|
||||
{invite.isDirect && (
|
||||
<Box shrink="No" alignItems="Center" justifyContent="Center">
|
||||
<Badge variant="Primary" fill="Solid" size="400" radii="300">
|
||||
<Text size="L400">{t('Inbox.direct_message')}</Text>
|
||||
</Badge>
|
||||
</Box>
|
||||
)}
|
||||
{invite.isSpace && (
|
||||
<Box shrink="No" alignItems="Center" justifyContent="Center">
|
||||
<Badge variant="Secondary" fill="Soft" size="400" radii="300">
|
||||
<Text size="L400">{t('Inbox.space')}</Text>
|
||||
</Badge>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
<Box gap="300">
|
||||
<Avatar size="300">
|
||||
<RoomAvatar
|
||||
roomId={invite.roomId}
|
||||
src={hideAvatar ? undefined : invite.roomAvatar}
|
||||
alt={invite.roomName}
|
||||
renderFallback={() => (
|
||||
<Text as="span" size="H6">
|
||||
{nameInitials(hideAvatar && invite.roomAvatar ? undefined : invite.roomName)}
|
||||
</Text>
|
||||
)}
|
||||
/>
|
||||
</Avatar>
|
||||
<Box direction={compact ? 'Column' : 'Row'} grow="Yes" gap="200">
|
||||
<Box grow="Yes" direction="Column" gap="200">
|
||||
<Box direction="Column">
|
||||
<Text size="T300" truncate>
|
||||
<b>{invite.roomName}</b>
|
||||
</Text>
|
||||
{invite.roomTopic && (
|
||||
<Text
|
||||
size="T200"
|
||||
onClick={openTopic}
|
||||
onKeyDown={onEnterOrSpace(openTopic)}
|
||||
tabIndex={0}
|
||||
truncate
|
||||
>
|
||||
{invite.roomTopic}
|
||||
</Text>
|
||||
)}
|
||||
<Overlay open={viewTopic} backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
clickOutsideDeactivates: true,
|
||||
onDeactivate: closeTopic,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<RoomTopicViewer
|
||||
name={invite.roomName}
|
||||
topic={invite.roomTopic ?? ''}
|
||||
requestClose={closeTopic}
|
||||
/>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
</Box>
|
||||
{joinState.status === AsyncStatus.Error && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
{joinState.error.message}
|
||||
</Text>
|
||||
)}
|
||||
{leaveState.status === AsyncStatus.Error && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
{leaveState.error.message}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box gap="200" shrink="No" alignItems="Center">
|
||||
<Button
|
||||
onClick={leave}
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
radii="300"
|
||||
fill="Soft"
|
||||
disabled={joining || leaving}
|
||||
before={leaving ? <Spinner variant="Secondary" size="100" /> : undefined}
|
||||
>
|
||||
<Text size="B300">{t('Inbox.decline')}</Text>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={join}
|
||||
size="300"
|
||||
variant="Success"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
outlined
|
||||
disabled={joining || leaving}
|
||||
before={joining ? <Spinner variant="Success" fill="Soft" size="100" /> : undefined}
|
||||
>
|
||||
<Text size="B300">{t('Inbox.accept')}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box direction="Column">
|
||||
<Box gap="200" alignItems="Baseline">
|
||||
<Box grow="Yes">
|
||||
<Text size="T200" priority="300">
|
||||
{t('Inbox.from')}
|
||||
<b>{invite.senderId}</b>
|
||||
</Text>
|
||||
</Box>
|
||||
{typeof invite.inviteTs === 'number' && invite.inviteTs !== 0 && (
|
||||
<Box shrink="No">
|
||||
<Time
|
||||
size="T200"
|
||||
ts={invite.inviteTs}
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
priority="300"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{invite.reason && (
|
||||
<Text size="T200" priority="300">
|
||||
{t('Inbox.reason_label')}
|
||||
{invite.reason}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</SequenceCard>
|
||||
);
|
||||
}
|
||||
|
||||
enum InviteFilter {
|
||||
Known,
|
||||
Unknown,
|
||||
Spam,
|
||||
}
|
||||
type InviteFiltersProps = {
|
||||
filter: InviteFilter;
|
||||
onFilter: (filter: InviteFilter) => void;
|
||||
knownInvites: InviteData[];
|
||||
unknownInvites: InviteData[];
|
||||
spamInvites: InviteData[];
|
||||
};
|
||||
function InviteFilters({
|
||||
filter,
|
||||
onFilter,
|
||||
knownInvites,
|
||||
unknownInvites,
|
||||
spamInvites,
|
||||
}: InviteFiltersProps) {
|
||||
const { t } = useTranslation();
|
||||
const isKnown = filter === InviteFilter.Known;
|
||||
const isUnknown = filter === InviteFilter.Unknown;
|
||||
const isSpam = filter === InviteFilter.Spam;
|
||||
|
||||
return (
|
||||
<Box gap="200">
|
||||
<Chip
|
||||
variant={isKnown ? 'Success' : 'Surface'}
|
||||
aria-selected={isKnown}
|
||||
outlined={!isKnown}
|
||||
onClick={() => onFilter(InviteFilter.Known)}
|
||||
before={isKnown && <Icon size="100" src={Icons.Check} />}
|
||||
after={
|
||||
knownInvites.length > 0 && (
|
||||
<Badge variant={isKnown ? 'Success' : 'Secondary'} fill="Solid" radii="Pill">
|
||||
<Text size="L400">{knownInvites.length}</Text>
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text size="T200">{t('Inbox.primary')}</Text>
|
||||
</Chip>
|
||||
<Chip
|
||||
variant={isUnknown ? 'Warning' : 'Surface'}
|
||||
aria-selected={isUnknown}
|
||||
outlined={!isUnknown}
|
||||
onClick={() => onFilter(InviteFilter.Unknown)}
|
||||
before={isUnknown && <Icon size="100" src={Icons.Check} />}
|
||||
after={
|
||||
unknownInvites.length > 0 && (
|
||||
<Badge variant={isUnknown ? 'Warning' : 'Secondary'} fill="Solid" radii="Pill">
|
||||
<Text size="L400">{unknownInvites.length}</Text>
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text size="T200">{t('Inbox.public')}</Text>
|
||||
</Chip>
|
||||
<Chip
|
||||
variant={isSpam ? 'Critical' : 'Surface'}
|
||||
aria-selected={isSpam}
|
||||
outlined={!isSpam}
|
||||
onClick={() => onFilter(InviteFilter.Spam)}
|
||||
before={isSpam && <Icon size="100" src={Icons.Check} />}
|
||||
after={
|
||||
spamInvites.length > 0 && (
|
||||
<Badge variant={isSpam ? 'Critical' : 'Secondary'} fill="Solid" radii="Pill">
|
||||
<Text size="L400">{spamInvites.length}</Text>
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text size="T200">{t('Inbox.spam')}</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type KnownInvitesProps = {
|
||||
invites: InviteData[];
|
||||
handleNavigate: NavigateHandler;
|
||||
compact: boolean;
|
||||
hour24Clock: boolean;
|
||||
dateFormatString: string;
|
||||
};
|
||||
function KnownInvites({
|
||||
invites,
|
||||
handleNavigate,
|
||||
compact,
|
||||
hour24Clock,
|
||||
dateFormatString,
|
||||
}: KnownInvitesProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Box direction="Column" gap="200">
|
||||
<Text size="H4">{t('Inbox.primary')}</Text>
|
||||
{invites.length > 0 ? (
|
||||
<Box direction="Column" gap="100">
|
||||
{invites.map((invite) => (
|
||||
<InviteCard
|
||||
key={invite.roomId}
|
||||
invite={invite}
|
||||
compact={compact}
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
onNavigate={handleNavigate}
|
||||
hideAvatar={false}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<PageHeroEmpty>
|
||||
<PageHeroSection>
|
||||
<PageHero
|
||||
icon={<Icon size="600" src={Icons.Mail} />}
|
||||
title={t('Inbox.no_invites')}
|
||||
subTitle={t('Inbox.no_invites_known_desc')}
|
||||
/>
|
||||
</PageHeroSection>
|
||||
</PageHeroEmpty>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type UnknownInvitesProps = {
|
||||
invites: InviteData[];
|
||||
handleNavigate: NavigateHandler;
|
||||
compact: boolean;
|
||||
hour24Clock: boolean;
|
||||
dateFormatString: string;
|
||||
};
|
||||
function UnknownInvites({
|
||||
invites,
|
||||
handleNavigate,
|
||||
compact,
|
||||
hour24Clock,
|
||||
dateFormatString,
|
||||
}: UnknownInvitesProps) {
|
||||
const { t } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const [declineAllStatus, declineAll] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const roomIds = invites.map((invite) => invite.roomId);
|
||||
|
||||
await rateLimitedActions(roomIds, (roomId) => mx.leave(roomId));
|
||||
}, [mx, invites])
|
||||
);
|
||||
|
||||
const declining = declineAllStatus.status === AsyncStatus.Loading;
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="200">
|
||||
<Box gap="200" justifyContent="SpaceBetween" alignItems="Center">
|
||||
<Text size="H4">{t('Inbox.public')}</Text>
|
||||
<Box>
|
||||
{invites.length > 0 && (
|
||||
<Chip
|
||||
variant="SurfaceVariant"
|
||||
onClick={declineAll}
|
||||
before={declining && <Spinner size="50" variant="Secondary" fill="Soft" />}
|
||||
disabled={declining}
|
||||
radii="Pill"
|
||||
>
|
||||
<Text size="T200">{t('Inbox.decline_all')}</Text>
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
{invites.length > 0 ? (
|
||||
<Box direction="Column" gap="100">
|
||||
{invites.map((invite) => (
|
||||
<InviteCard
|
||||
key={invite.roomId}
|
||||
invite={invite}
|
||||
compact={compact}
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
onNavigate={handleNavigate}
|
||||
hideAvatar
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<PageHeroEmpty>
|
||||
<PageHeroSection>
|
||||
<PageHero
|
||||
icon={<Icon size="600" src={Icons.Info} />}
|
||||
title={t('Inbox.no_invites')}
|
||||
subTitle={t('Inbox.no_invites_unknown_desc')}
|
||||
/>
|
||||
</PageHeroSection>
|
||||
</PageHeroEmpty>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type SpamInvitesProps = {
|
||||
invites: InviteData[];
|
||||
handleNavigate: NavigateHandler;
|
||||
compact: boolean;
|
||||
hour24Clock: boolean;
|
||||
dateFormatString: string;
|
||||
};
|
||||
function SpamInvites({
|
||||
invites,
|
||||
handleNavigate,
|
||||
compact,
|
||||
hour24Clock,
|
||||
dateFormatString,
|
||||
}: SpamInvitesProps) {
|
||||
const { t } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
const [showInvites, setShowInvites] = useState(false);
|
||||
|
||||
const reportRoomSupported = useReportRoomSupported();
|
||||
|
||||
const [declineAllStatus, declineAll] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const roomIds = invites.map((invite) => invite.roomId);
|
||||
|
||||
await rateLimitedActions(roomIds, (roomId) => mx.leave(roomId));
|
||||
}, [mx, invites])
|
||||
);
|
||||
|
||||
const [reportAllStatus, reportAll] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const roomIds = invites.map((invite) => invite.roomId);
|
||||
|
||||
await rateLimitedActions(roomIds, (roomId) => mx.reportRoom(roomId, 'Spam Invite'));
|
||||
}, [mx, invites])
|
||||
);
|
||||
|
||||
const ignoredUsers = useIgnoredUsers();
|
||||
const unignoredUsers = Array.from(new Set(invites.map((invite) => invite.senderId))).filter(
|
||||
(user) => !ignoredUsers.includes(user)
|
||||
);
|
||||
const [blockAllStatus, blockAll] = useAsyncCallback(
|
||||
useCallback(
|
||||
() => mx.setIgnoredUsers([...ignoredUsers, ...unignoredUsers]),
|
||||
[mx, ignoredUsers, unignoredUsers]
|
||||
)
|
||||
);
|
||||
|
||||
const declining = declineAllStatus.status === AsyncStatus.Loading;
|
||||
const reporting = reportAllStatus.status === AsyncStatus.Loading;
|
||||
const blocking = blockAllStatus.status === AsyncStatus.Loading;
|
||||
const loading = blocking || reporting || declining;
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="200">
|
||||
<Text size="H4">{t('Inbox.spam')}</Text>
|
||||
{invites.length > 0 ? (
|
||||
<Box direction="Column" gap="100">
|
||||
<SequenceCard
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="300"
|
||||
style={{ padding: `${config.space.S400} ${config.space.S400} 0` }}
|
||||
>
|
||||
<PageHeroSection>
|
||||
<PageHero
|
||||
icon={<Icon size="600" src={Icons.Warning} />}
|
||||
title={t('Inbox.spam_invites_count', { count: invites.length })}
|
||||
subTitle={t('Inbox.spam_invites_desc')}
|
||||
>
|
||||
<Box direction="Row" gap="200" justifyContent="Center" wrap="Wrap">
|
||||
<Button
|
||||
size="300"
|
||||
variant="Critical"
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
onClick={declineAll}
|
||||
before={declining && <Spinner size="100" variant="Critical" fill="Solid" />}
|
||||
disabled={loading}
|
||||
>
|
||||
<Text size="B300" truncate>
|
||||
{t('Inbox.decline_all')}
|
||||
</Text>
|
||||
</Button>
|
||||
{reportRoomSupported && reportAllStatus.status !== AsyncStatus.Success && (
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
onClick={reportAll}
|
||||
before={reporting && <Spinner size="100" variant="Secondary" fill="Solid" />}
|
||||
disabled={loading}
|
||||
>
|
||||
<Text size="B300" truncate>
|
||||
{t('Inbox.report_all')}
|
||||
</Text>
|
||||
</Button>
|
||||
)}
|
||||
{unignoredUsers.length > 0 && (
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
disabled={loading}
|
||||
onClick={blockAll}
|
||||
before={blocking && <Spinner size="100" variant="Secondary" fill="Solid" />}
|
||||
>
|
||||
<Text size="B300" truncate>
|
||||
{t('Inbox.block_all')}
|
||||
</Text>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<span data-spacing-node />
|
||||
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="Pill"
|
||||
before={
|
||||
<Icon size="100" src={showInvites ? Icons.ChevronTop : Icons.ChevronBottom} />
|
||||
}
|
||||
onClick={() => setShowInvites(!showInvites)}
|
||||
>
|
||||
<Text size="B300">{showInvites ? t('Inbox.hide_all') : t('Inbox.view_all')}</Text>
|
||||
</Button>
|
||||
</PageHero>
|
||||
</PageHeroSection>
|
||||
</SequenceCard>
|
||||
{showInvites &&
|
||||
invites.map((invite) => (
|
||||
<InviteCard
|
||||
key={invite.roomId}
|
||||
invite={invite}
|
||||
compact={compact}
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
onNavigate={handleNavigate}
|
||||
hideAvatar
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<PageHeroEmpty>
|
||||
<PageHeroSection>
|
||||
<PageHero
|
||||
icon={<Icon size="600" src={Icons.Warning} />}
|
||||
title={t('Inbox.no_spam_invites')}
|
||||
subTitle={t('Inbox.no_spam_invites_desc')}
|
||||
/>
|
||||
</PageHeroSection>
|
||||
</PageHeroEmpty>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function Invites() {
|
||||
const { t } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const { navigateRoom, navigateSpace } = useRoomNavigate();
|
||||
const allRooms = useAtomValue(allRoomsAtom);
|
||||
const allInviteIds = useAtomValue(allInvitesAtom);
|
||||
|
||||
const [filter, setFilter] = useState(InviteFilter.Known);
|
||||
|
||||
const invitesData = allInviteIds
|
||||
.map((inviteId) => mx.getRoom(inviteId))
|
||||
.filter((inviteRoom) => !!inviteRoom)
|
||||
.map((inviteRoom) => makeInviteData(mx, inviteRoom, useAuthentication));
|
||||
|
||||
const [knownInvites, unknownInvites, spamInvites] = useMemo(() => {
|
||||
const known: InviteData[] = [];
|
||||
const unknown: InviteData[] = [];
|
||||
const spam: InviteData[] = [];
|
||||
invitesData.forEach((invite) => {
|
||||
if (hasBadWords(invite) || bannedInRooms(mx, allRooms, invite.senderId)) {
|
||||
spam.push(invite);
|
||||
return;
|
||||
}
|
||||
|
||||
if (getCommonRooms(mx, allRooms, invite.senderId).length === 0) {
|
||||
unknown.push(invite);
|
||||
return;
|
||||
}
|
||||
|
||||
known.push(invite);
|
||||
});
|
||||
|
||||
return [known, unknown, spam];
|
||||
}, [mx, allRooms, invitesData]);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [compact, setCompact] = useState(document.body.clientWidth <= COMPACT_CARD_WIDTH);
|
||||
useElementSizeObserver(
|
||||
useCallback(() => containerRef.current, []),
|
||||
useCallback((width) => setCompact(width <= COMPACT_CARD_WIDTH), [])
|
||||
);
|
||||
const screenSize = useScreenSizeContext();
|
||||
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||
|
||||
const handleNavigate = (roomId: string, space: boolean) => {
|
||||
if (space) {
|
||||
navigateSpace(roomId);
|
||||
return;
|
||||
}
|
||||
navigateRoom(roomId);
|
||||
};
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader balance>
|
||||
<Box grow="Yes" gap="200">
|
||||
<Box grow="Yes" basis="No">
|
||||
{screenSize === ScreenSize.Mobile && (
|
||||
<BackRouteHandler>
|
||||
{(onBack) => (
|
||||
<IconButton onClick={onBack}>
|
||||
<Icon src={Icons.ArrowLeft} />
|
||||
</IconButton>
|
||||
)}
|
||||
</BackRouteHandler>
|
||||
)}
|
||||
</Box>
|
||||
<Box alignItems="Center" gap="200">
|
||||
{screenSize !== ScreenSize.Mobile && <Icon size="400" src={Icons.Mail} />}
|
||||
<Text size="H3" truncate>
|
||||
{t('Inbox.invites')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box grow="Yes" basis="No" />
|
||||
</Box>
|
||||
</PageHeader>
|
||||
<Box grow="Yes">
|
||||
<Scroll hideTrack visibility="Hover">
|
||||
<PageContent>
|
||||
<PageContentCenter>
|
||||
<Box ref={containerRef} direction="Column" gap="600">
|
||||
<Box direction="Column" gap="100">
|
||||
<span data-spacing-node />
|
||||
<Text size="L400">{t('Inbox.filter')}</Text>
|
||||
<InviteFilters
|
||||
filter={filter}
|
||||
onFilter={setFilter}
|
||||
knownInvites={knownInvites}
|
||||
unknownInvites={unknownInvites}
|
||||
spamInvites={spamInvites}
|
||||
/>
|
||||
</Box>
|
||||
{filter === InviteFilter.Known && (
|
||||
<KnownInvites
|
||||
invites={knownInvites}
|
||||
compact={compact}
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
handleNavigate={handleNavigate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{filter === InviteFilter.Unknown && (
|
||||
<UnknownInvites
|
||||
invites={unknownInvites}
|
||||
compact={compact}
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
handleNavigate={handleNavigate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{filter === InviteFilter.Spam && (
|
||||
<SpamInvites
|
||||
invites={spamInvites}
|
||||
compact={compact}
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
handleNavigate={handleNavigate}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</PageContentCenter>
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,793 +0,0 @@
|
|||
/* eslint-disable react/destructuring-assignment */
|
||||
import React, { MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Chip,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Scroll,
|
||||
Text,
|
||||
config,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
INotification,
|
||||
INotificationsResponse,
|
||||
IRoomEvent,
|
||||
JoinRule,
|
||||
Method,
|
||||
RelationType,
|
||||
Room,
|
||||
} from 'matrix-js-sdk';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { HTMLReactParserOptions } from 'html-react-parser';
|
||||
import { Opts as LinkifyOpts } from 'linkifyjs';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { InboxNotificationsPathSearchParams } from '../../paths';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
|
||||
import {
|
||||
getEditedEvent,
|
||||
getMemberAvatarMxc,
|
||||
getMemberDisplayName,
|
||||
getRoomAvatarUrl,
|
||||
isOneOnOneRoom,
|
||||
} from '../../../utils/room';
|
||||
import { ScrollTopContainer } from '../../../components/scroll-top-container';
|
||||
import { useInterval } from '../../../hooks/useInterval';
|
||||
import {
|
||||
AvatarBase,
|
||||
ImageContent,
|
||||
MSticker,
|
||||
MessageNotDecryptedContent,
|
||||
MessageUnsupportedContent,
|
||||
ModernLayout,
|
||||
RedactedContent,
|
||||
Reply,
|
||||
Time,
|
||||
Username,
|
||||
UsernameBold,
|
||||
} from '../../../components/message';
|
||||
import {
|
||||
factoryRenderLinkifyWithMention,
|
||||
getReactCustomHtmlParser,
|
||||
LINKIFY_OPTS,
|
||||
makeMentionCustomProps,
|
||||
renderMatrixMention,
|
||||
} from '../../../plugins/react-custom-html-parser';
|
||||
import { RenderMessageContent } from '../../../components/RenderMessageContent';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
import { Image } from '../../../components/media';
|
||||
import { ImageViewer } from '../../../components/image-viewer';
|
||||
import { GetContentCallback, MessageEvent, StateEvent } from '../../../../types/matrix/room';
|
||||
import { useMatrixEventRenderer } from '../../../hooks/useMatrixEventRenderer';
|
||||
import * as customHtmlCss from '../../../styles/CustomHtml.css';
|
||||
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
|
||||
import { useRoomUnread } from '../../../state/hooks/unread';
|
||||
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
|
||||
import { markAsRead } from '../../../utils/notifications';
|
||||
import { ContainerColor } from '../../../styles/ContainerColor.css';
|
||||
import { VirtualTile } from '../../../components/virtualizer';
|
||||
import { UserAvatar } from '../../../components/user-avatar';
|
||||
import { EncryptedContent } from '../../../features/room/message';
|
||||
import { useMentionClickHandler } from '../../../hooks/useMentionClickHandler';
|
||||
import { useSpoilerClickHandler } from '../../../hooks/useSpoilerClickHandler';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
||||
import { BackRouteHandler } from '../../../components/BackRouteHandler';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
||||
import { usePowerLevels } from '../../../hooks/usePowerLevels';
|
||||
import { usePowerLevelTags } from '../../../hooks/usePowerLevelTags';
|
||||
import { useTheme } from '../../../hooks/useTheme';
|
||||
import { PowerIcon } from '../../../components/power';
|
||||
import colorMXID from '../../../../util/colorMXID';
|
||||
import {
|
||||
getPowerTagIconSrc,
|
||||
useAccessiblePowerTagColors,
|
||||
useGetMemberPowerTag,
|
||||
} from '../../../hooks/useMemberPowerTag';
|
||||
import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
|
||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||
|
||||
type RoomNotificationsGroup = {
|
||||
roomId: string;
|
||||
notifications: INotification[];
|
||||
};
|
||||
type NotificationTimeline = {
|
||||
nextToken?: string;
|
||||
groups: RoomNotificationsGroup[];
|
||||
};
|
||||
type LoadTimeline = (from?: string) => Promise<void>;
|
||||
type SilentReloadTimeline = () => Promise<void>;
|
||||
|
||||
const groupNotifications = (
|
||||
notifications: INotification[],
|
||||
allowRooms: Set<string>
|
||||
): RoomNotificationsGroup[] => {
|
||||
const groups: RoomNotificationsGroup[] = [];
|
||||
notifications.forEach((notification) => {
|
||||
if (!allowRooms.has(notification.room_id)) return;
|
||||
|
||||
const groupIndex = groups.length - 1;
|
||||
const lastAddedGroup: RoomNotificationsGroup | undefined = groups[groupIndex];
|
||||
if (lastAddedGroup && notification.room_id === lastAddedGroup.roomId) {
|
||||
lastAddedGroup.notifications.push(notification);
|
||||
return;
|
||||
}
|
||||
groups.push({
|
||||
roomId: notification.room_id,
|
||||
notifications: [notification],
|
||||
});
|
||||
});
|
||||
return groups;
|
||||
};
|
||||
|
||||
const useNotificationTimeline = (
|
||||
paginationLimit: number,
|
||||
onlyHighlight?: boolean
|
||||
): [NotificationTimeline, LoadTimeline, SilentReloadTimeline] => {
|
||||
const mx = useMatrixClient();
|
||||
const allRooms = useAtomValue(allRoomsAtom);
|
||||
const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]);
|
||||
|
||||
const [notificationTimeline, setNotificationTimeline] = useState<NotificationTimeline>({
|
||||
groups: [],
|
||||
});
|
||||
|
||||
const fetchNotifications = useCallback(
|
||||
(from?: string, limit?: number, only?: 'highlight') => {
|
||||
const queryParams = { from, limit, only };
|
||||
return mx.http.authedRequest<INotificationsResponse>(
|
||||
Method.Get,
|
||||
'/notifications',
|
||||
queryParams
|
||||
);
|
||||
},
|
||||
[mx]
|
||||
);
|
||||
|
||||
const loadTimeline: LoadTimeline = useCallback(
|
||||
async (from) => {
|
||||
if (!from) {
|
||||
setNotificationTimeline({ groups: [] });
|
||||
}
|
||||
const data = await fetchNotifications(
|
||||
from,
|
||||
paginationLimit,
|
||||
onlyHighlight ? 'highlight' : undefined
|
||||
);
|
||||
const groups = groupNotifications(data.notifications, allJoinedRooms);
|
||||
|
||||
setNotificationTimeline((currentTimeline) => {
|
||||
if (currentTimeline.nextToken === from) {
|
||||
return {
|
||||
nextToken: data.next_token,
|
||||
groups: from ? currentTimeline.groups.concat(groups) : groups,
|
||||
};
|
||||
}
|
||||
return currentTimeline;
|
||||
});
|
||||
},
|
||||
[paginationLimit, onlyHighlight, fetchNotifications, allJoinedRooms]
|
||||
);
|
||||
|
||||
/**
|
||||
* Reload timeline silently i.e without setting to default
|
||||
* before fetching notifications from start
|
||||
*/
|
||||
const silentReloadTimeline: SilentReloadTimeline = useCallback(async () => {
|
||||
const data = await fetchNotifications(
|
||||
undefined,
|
||||
paginationLimit,
|
||||
onlyHighlight ? 'highlight' : undefined
|
||||
);
|
||||
const groups = groupNotifications(data.notifications, allJoinedRooms);
|
||||
setNotificationTimeline({
|
||||
nextToken: data.next_token,
|
||||
groups,
|
||||
});
|
||||
}, [paginationLimit, onlyHighlight, fetchNotifications, allJoinedRooms]);
|
||||
|
||||
return [notificationTimeline, loadTimeline, silentReloadTimeline];
|
||||
};
|
||||
|
||||
type RoomNotificationsGroupProps = {
|
||||
room: Room;
|
||||
notifications: INotification[];
|
||||
mediaAutoLoad?: boolean;
|
||||
urlPreview?: boolean;
|
||||
hideActivity: boolean;
|
||||
onOpen: (roomId: string, eventId: string) => void;
|
||||
legacyUsernameColor?: boolean;
|
||||
hour24Clock: boolean;
|
||||
dateFormatString: string;
|
||||
};
|
||||
function RoomNotificationsGroupComp({
|
||||
room,
|
||||
notifications,
|
||||
mediaAutoLoad,
|
||||
urlPreview,
|
||||
hideActivity,
|
||||
onOpen,
|
||||
legacyUsernameColor,
|
||||
hour24Clock,
|
||||
dateFormatString,
|
||||
}: RoomNotificationsGroupProps) {
|
||||
const { t } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
||||
|
||||
const powerLevels = usePowerLevels(room);
|
||||
const creators = useRoomCreators(room);
|
||||
|
||||
const creatorsTag = useRoomCreatorsTag();
|
||||
const powerLevelTags = usePowerLevelTags(room, powerLevels);
|
||||
const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
|
||||
|
||||
const theme = useTheme();
|
||||
const accessibleTagColors = useAccessiblePowerTagColors(theme.kind, creatorsTag, powerLevelTags);
|
||||
|
||||
const mentionClickHandler = useMentionClickHandler(room.roomId);
|
||||
const spoilerClickHandler = useSpoilerClickHandler();
|
||||
|
||||
const linkifyOpts = useMemo<LinkifyOpts>(
|
||||
() => ({
|
||||
...LINKIFY_OPTS,
|
||||
render: factoryRenderLinkifyWithMention((href) =>
|
||||
renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler))
|
||||
),
|
||||
}),
|
||||
[mx, room, mentionClickHandler]
|
||||
);
|
||||
const htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
|
||||
() =>
|
||||
getReactCustomHtmlParser(mx, room.roomId, {
|
||||
linkifyOpts,
|
||||
useAuthentication,
|
||||
handleSpoilerClick: spoilerClickHandler,
|
||||
handleMentionClick: mentionClickHandler,
|
||||
}),
|
||||
[mx, room, linkifyOpts, mentionClickHandler, spoilerClickHandler, useAuthentication]
|
||||
);
|
||||
|
||||
const renderMatrixEvent = useMatrixEventRenderer<[IRoomEvent, string, GetContentCallback]>(
|
||||
{
|
||||
[MessageEvent.RoomMessage]: (event, displayName, getContent) => {
|
||||
if (event.unsigned?.redacted_because) {
|
||||
return <RedactedContent reason={event.unsigned?.redacted_because.content.reason} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<RenderMessageContent
|
||||
displayName={displayName}
|
||||
msgType={event.content.msgtype ?? ''}
|
||||
ts={event.origin_server_ts}
|
||||
getContent={getContent}
|
||||
mediaAutoLoad={mediaAutoLoad}
|
||||
urlPreview={urlPreview}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
outlineAttachment
|
||||
/>
|
||||
);
|
||||
},
|
||||
[MessageEvent.RoomMessageEncrypted]: (evt, displayName) => {
|
||||
const evtTimeline = room.getTimelineForEvent(evt.event_id);
|
||||
|
||||
const mEvent = evtTimeline?.getEvents().find((e) => e.getId() === evt.event_id);
|
||||
|
||||
if (!mEvent || !evtTimeline) {
|
||||
return (
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Text size="T400" priority="300">
|
||||
<code className={customHtmlCss.Code}>{evt.type}</code>
|
||||
{' event'}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EncryptedContent mEvent={mEvent}>
|
||||
{() => {
|
||||
if (mEvent.isRedacted()) return <RedactedContent />;
|
||||
if (mEvent.getType() === MessageEvent.Sticker)
|
||||
return (
|
||||
<MSticker
|
||||
content={mEvent.getContent()}
|
||||
renderImageContent={(props) => (
|
||||
<ImageContent
|
||||
{...props}
|
||||
autoPlay={mediaAutoLoad}
|
||||
renderImage={(p) => <Image {...p} loading="lazy" />}
|
||||
renderViewer={(p) => <ImageViewer {...p} />}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
if (mEvent.getType() === MessageEvent.RoomMessage) {
|
||||
const editedEvent = getEditedEvent(
|
||||
evt.event_id,
|
||||
mEvent,
|
||||
evtTimeline.getTimelineSet()
|
||||
);
|
||||
const getContent = (() =>
|
||||
editedEvent?.getContent()['m.new_content'] ??
|
||||
mEvent.getContent()) as GetContentCallback;
|
||||
|
||||
return (
|
||||
<RenderMessageContent
|
||||
displayName={displayName}
|
||||
msgType={mEvent.getContent().msgtype ?? ''}
|
||||
ts={mEvent.getTs()}
|
||||
edited={!!editedEvent}
|
||||
getContent={getContent}
|
||||
mediaAutoLoad={mediaAutoLoad}
|
||||
urlPreview={urlPreview}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (mEvent.getType() === MessageEvent.RoomMessageEncrypted)
|
||||
return (
|
||||
<Text>
|
||||
<MessageNotDecryptedContent />
|
||||
</Text>
|
||||
);
|
||||
return (
|
||||
<Text>
|
||||
<MessageUnsupportedContent />
|
||||
</Text>
|
||||
);
|
||||
}}
|
||||
</EncryptedContent>
|
||||
);
|
||||
},
|
||||
[MessageEvent.Sticker]: (event, displayName, getContent) => {
|
||||
if (event.unsigned?.redacted_because) {
|
||||
return <RedactedContent reason={event.unsigned?.redacted_because.content.reason} />;
|
||||
}
|
||||
return (
|
||||
<MSticker
|
||||
content={getContent()}
|
||||
renderImageContent={(props) => (
|
||||
<ImageContent
|
||||
{...props}
|
||||
autoPlay={mediaAutoLoad}
|
||||
renderImage={(p) => <Image {...p} loading="lazy" />}
|
||||
renderViewer={(p) => <ImageViewer {...p} />}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[StateEvent.RoomTombstone]: (event) => {
|
||||
const { content } = event;
|
||||
return (
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Text size="T400" priority="300">
|
||||
Room Tombstone. {content.body}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
(event) => {
|
||||
if (event.unsigned?.redacted_because) {
|
||||
return <RedactedContent reason={event.unsigned?.redacted_because.content.reason} />;
|
||||
}
|
||||
return (
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Text size="T400" priority="300">
|
||||
<code className={customHtmlCss.Code}>{event.type}</code>
|
||||
{' event'}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const handleOpenClick: MouseEventHandler = (evt) => {
|
||||
const eventId = evt.currentTarget.getAttribute('data-event-id');
|
||||
if (!eventId) return;
|
||||
onOpen(room.roomId, eventId);
|
||||
};
|
||||
const handleMarkAsRead = () => {
|
||||
markAsRead(mx, room.roomId, hideActivity);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="200">
|
||||
<Header size="300">
|
||||
<Box gap="200" grow="Yes">
|
||||
<Avatar size="200" radii="300">
|
||||
<RoomAvatar
|
||||
roomId={room.roomId}
|
||||
src={getRoomAvatarUrl(mx, room, 96, useAuthentication)}
|
||||
alt={room.name}
|
||||
renderFallback={() => (
|
||||
<RoomIcon
|
||||
size="50"
|
||||
roomType={room.getType()}
|
||||
joinRule={room.getJoinRule() ?? JoinRule.Restricted}
|
||||
filled
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Avatar>
|
||||
<Text size="H4" truncate>
|
||||
{room.name}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
{unread && (
|
||||
<Chip
|
||||
variant="Primary"
|
||||
radii="Pill"
|
||||
onClick={handleMarkAsRead}
|
||||
before={<Icon size="100" src={Icons.CheckTwice} />}
|
||||
>
|
||||
<Text size="T200">{t('Inbox.mark_as_read')}</Text>
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</Header>
|
||||
<Box direction="Column" gap="100">
|
||||
{notifications.map((notification) => {
|
||||
const { event } = notification;
|
||||
|
||||
const displayName =
|
||||
getMemberDisplayName(room, event.sender) ??
|
||||
getMxIdLocalPart(event.sender) ??
|
||||
event.sender;
|
||||
const senderAvatarMxc = getMemberAvatarMxc(room, event.sender);
|
||||
const getContent = (() => event.content) as GetContentCallback;
|
||||
|
||||
const relation = event.content['m.relates_to'];
|
||||
const replyEventId = relation?.['m.in_reply_to']?.event_id;
|
||||
const threadRootId =
|
||||
relation?.rel_type === RelationType.Thread ? relation.event_id : undefined;
|
||||
|
||||
const memberPowerTag = getMemberPowerTag(event.sender);
|
||||
const tagColor = memberPowerTag?.color
|
||||
? accessibleTagColors?.get(memberPowerTag.color)
|
||||
: undefined;
|
||||
const tagIconSrc = memberPowerTag?.icon
|
||||
? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon)
|
||||
: undefined;
|
||||
|
||||
const usernameColor = legacyUsernameColor ? colorMXID(event.sender) : tagColor;
|
||||
|
||||
return (
|
||||
<SequenceCard
|
||||
key={notification.event.event_id}
|
||||
style={{ padding: config.space.S400 }}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
>
|
||||
<ModernLayout
|
||||
before={
|
||||
<AvatarBase>
|
||||
<Avatar size="300">
|
||||
<UserAvatar
|
||||
userId={event.sender}
|
||||
src={
|
||||
senderAvatarMxc
|
||||
? mxcUrlToHttp(
|
||||
mx,
|
||||
senderAvatarMxc,
|
||||
useAuthentication,
|
||||
48,
|
||||
48,
|
||||
'crop'
|
||||
) ?? undefined
|
||||
: undefined
|
||||
}
|
||||
alt={displayName}
|
||||
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
</AvatarBase>
|
||||
}
|
||||
>
|
||||
<Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
|
||||
<Box gap="200" alignItems="Baseline">
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Username style={{ color: usernameColor }}>
|
||||
<Text as="span" truncate>
|
||||
<UsernameBold>{displayName}</UsernameBold>
|
||||
</Text>
|
||||
</Username>
|
||||
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
|
||||
</Box>
|
||||
<Time
|
||||
ts={event.origin_server_ts}
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
/>
|
||||
</Box>
|
||||
<Box shrink="No" gap="200" alignItems="Center">
|
||||
<Chip
|
||||
data-event-id={event.event_id}
|
||||
onClick={handleOpenClick}
|
||||
variant="Secondary"
|
||||
radii="400"
|
||||
>
|
||||
<Text size="T200">{t('Inbox.open')}</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
</Box>
|
||||
{replyEventId && (
|
||||
<Reply
|
||||
room={room}
|
||||
replyEventId={replyEventId}
|
||||
threadRootId={threadRootId}
|
||||
onClick={handleOpenClick}
|
||||
getMemberPowerTag={getMemberPowerTag}
|
||||
accessibleTagColors={accessibleTagColors}
|
||||
legacyUsernameColor={legacyUsernameColor}
|
||||
/>
|
||||
)}
|
||||
{renderMatrixEvent(event.type, false, event, displayName, getContent)}
|
||||
</ModernLayout>
|
||||
</SequenceCard>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const useNotificationsSearchParams = (
|
||||
searchParams: URLSearchParams
|
||||
): InboxNotificationsPathSearchParams =>
|
||||
useMemo(
|
||||
() => ({
|
||||
only: searchParams.get('only') ?? undefined,
|
||||
}),
|
||||
[searchParams]
|
||||
);
|
||||
|
||||
const DEFAULT_REFRESH_MS = 7000;
|
||||
|
||||
export function Notifications() {
|
||||
const { t } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||
const screenSize = useScreenSizeContext();
|
||||
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const notificationsSearchParams = useNotificationsSearchParams(searchParams);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const scrollTopAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const [refreshIntervalTime, setRefreshIntervalTime] = useState(DEFAULT_REFRESH_MS);
|
||||
|
||||
const onlyHighlight = notificationsSearchParams.only === 'highlight';
|
||||
const setOnlyHighlighted = (highlight: boolean) => {
|
||||
if (highlight) {
|
||||
setSearchParams(
|
||||
new URLSearchParams({
|
||||
only: 'highlight',
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
setSearchParams();
|
||||
};
|
||||
|
||||
const [notificationTimeline, _loadTimeline, silentReloadTimeline] = useNotificationTimeline(
|
||||
24,
|
||||
onlyHighlight
|
||||
);
|
||||
const [timelineState, loadTimeline] = useAsyncCallback(_loadTimeline);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: notificationTimeline.groups.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 40,
|
||||
overscan: 4,
|
||||
});
|
||||
const vItems = virtualizer.getVirtualItems();
|
||||
|
||||
useInterval(
|
||||
useCallback(() => {
|
||||
silentReloadTimeline();
|
||||
}, [silentReloadTimeline]),
|
||||
refreshIntervalTime
|
||||
);
|
||||
|
||||
const handleScrollTopVisibility = useCallback(
|
||||
(onTop: boolean) => setRefreshIntervalTime(onTop ? DEFAULT_REFRESH_MS : -1),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadTimeline();
|
||||
}, [loadTimeline]);
|
||||
|
||||
const lastVItem = vItems[vItems.length - 1];
|
||||
const lastVItemIndex: number | undefined = lastVItem?.index;
|
||||
useEffect(() => {
|
||||
if (
|
||||
timelineState.status === AsyncStatus.Success &&
|
||||
notificationTimeline.groups.length - 1 === lastVItemIndex &&
|
||||
notificationTimeline.nextToken
|
||||
) {
|
||||
loadTimeline(notificationTimeline.nextToken);
|
||||
}
|
||||
}, [timelineState, notificationTimeline, lastVItemIndex, loadTimeline]);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader balance>
|
||||
<Box grow="Yes" gap="200">
|
||||
<Box grow="Yes" basis="No">
|
||||
{screenSize === ScreenSize.Mobile && (
|
||||
<BackRouteHandler>
|
||||
{(onBack) => (
|
||||
<IconButton onClick={onBack}>
|
||||
<Icon src={Icons.ArrowLeft} />
|
||||
</IconButton>
|
||||
)}
|
||||
</BackRouteHandler>
|
||||
)}
|
||||
</Box>
|
||||
<Box alignItems="Center" gap="200">
|
||||
{screenSize !== ScreenSize.Mobile && <Icon size="400" src={Icons.Message} />}
|
||||
<Text size="H3" truncate>
|
||||
{t('Inbox.notification_messages')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box grow="Yes" basis="No" />
|
||||
</Box>
|
||||
</PageHeader>
|
||||
|
||||
<Box style={{ position: 'relative' }} grow="Yes">
|
||||
<Scroll ref={scrollRef} hideTrack visibility="Hover">
|
||||
<PageContent>
|
||||
<PageContentCenter>
|
||||
<Box direction="Column" gap="200">
|
||||
<Box ref={scrollTopAnchorRef} direction="Column" gap="100">
|
||||
<span data-spacing-node />
|
||||
<Text size="L400">{t('Inbox.filter')}</Text>
|
||||
<Box gap="200">
|
||||
<Chip
|
||||
onClick={() => setOnlyHighlighted(false)}
|
||||
variant={!onlyHighlight ? 'Success' : 'Surface'}
|
||||
aria-pressed={!onlyHighlight}
|
||||
before={!onlyHighlight && <Icon size="100" src={Icons.Check} />}
|
||||
outlined
|
||||
>
|
||||
<Text size="T200">{t('Inbox.all_notifications')}</Text>
|
||||
</Chip>
|
||||
<Chip
|
||||
onClick={() => setOnlyHighlighted(true)}
|
||||
variant={onlyHighlight ? 'Success' : 'Surface'}
|
||||
aria-pressed={onlyHighlight}
|
||||
before={onlyHighlight && <Icon size="100" src={Icons.Check} />}
|
||||
outlined
|
||||
>
|
||||
<Text size="T200">{t('Inbox.highlighted')}</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
</Box>
|
||||
<ScrollTopContainer
|
||||
scrollRef={scrollRef}
|
||||
anchorRef={scrollTopAnchorRef}
|
||||
onVisibilityChange={handleScrollTopVisibility}
|
||||
>
|
||||
<IconButton
|
||||
onClick={() => virtualizer.scrollToOffset(0)}
|
||||
variant="SurfaceVariant"
|
||||
radii="Pill"
|
||||
outlined
|
||||
size="300"
|
||||
aria-label={t('Inbox.scroll_to_top')}
|
||||
>
|
||||
<Icon src={Icons.ChevronTop} size="300" />
|
||||
</IconButton>
|
||||
</ScrollTopContainer>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: virtualizer.getTotalSize(),
|
||||
}}
|
||||
>
|
||||
{vItems.map((vItem) => {
|
||||
const group = notificationTimeline.groups[vItem.index];
|
||||
if (!group) return null;
|
||||
const groupRoom = mx.getRoom(group.roomId);
|
||||
if (!groupRoom) return null;
|
||||
|
||||
return (
|
||||
<VirtualTile
|
||||
virtualItem={vItem}
|
||||
style={{ paddingTop: config.space.S500 }}
|
||||
ref={virtualizer.measureElement}
|
||||
key={vItem.index}
|
||||
>
|
||||
<RoomNotificationsGroupComp
|
||||
room={groupRoom}
|
||||
notifications={group.notifications}
|
||||
mediaAutoLoad={mediaAutoLoad}
|
||||
urlPreview={urlPreview}
|
||||
hideActivity={hideActivity}
|
||||
onOpen={navigateRoom}
|
||||
legacyUsernameColor={
|
||||
isOneOnOneRoom(groupRoom)
|
||||
}
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
/>
|
||||
</VirtualTile>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{timelineState.status === AsyncStatus.Success &&
|
||||
notificationTimeline.groups.length === 0 && (
|
||||
<Box
|
||||
className={ContainerColor({ variant: 'SurfaceVariant' })}
|
||||
style={{
|
||||
padding: config.space.S300,
|
||||
borderRadius: config.radii.R400,
|
||||
}}
|
||||
direction="Column"
|
||||
gap="200"
|
||||
>
|
||||
<Text>{t('Inbox.no_notifications')}</Text>
|
||||
<Text size="T200">{t('Inbox.no_notifications_desc')}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{timelineState.status === AsyncStatus.Loading && (
|
||||
<Box direction="Column" gap="100">
|
||||
{[...Array(8).keys()].map((key) => (
|
||||
<SequenceCard
|
||||
variant="SurfaceVariant"
|
||||
key={key}
|
||||
style={{ minHeight: toRem(80) }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
{timelineState.status === AsyncStatus.Error && (
|
||||
<Box
|
||||
className={ContainerColor({ variant: 'Critical' })}
|
||||
style={{
|
||||
padding: config.space.S300,
|
||||
borderRadius: config.radii.R400,
|
||||
}}
|
||||
direction="Column"
|
||||
gap="200"
|
||||
>
|
||||
<Text size="L400">{(timelineState.error as Error).name}</Text>
|
||||
<Text size="T300">{(timelineState.error as Error).message}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</PageContentCenter>
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export * from './Inbox';
|
||||
export * from './Notifications';
|
||||
export * from './Invites';
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Icon, Icons } from 'folds';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import {
|
||||
SidebarAvatar,
|
||||
SidebarItem,
|
||||
SidebarItemBadge,
|
||||
SidebarItemTooltip,
|
||||
} from '../../../components/sidebar';
|
||||
import { allInvitesAtom } from '../../../state/room-list/inviteList';
|
||||
import {
|
||||
getInboxInvitesPath,
|
||||
getInboxNotificationsPath,
|
||||
getInboxPath,
|
||||
joinPathComponent,
|
||||
} from '../../pathUtils';
|
||||
import { useInboxSelected } from '../../../hooks/router/useInbox';
|
||||
import { UnreadBadge } from '../../../components/unread-badge';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
||||
import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath';
|
||||
import { isNativePlatform } from '../../../utils/capacitor';
|
||||
|
||||
export function InboxTab() {
|
||||
const { t } = useTranslation();
|
||||
const screenSize = useScreenSizeContext();
|
||||
const navigate = useNavigate();
|
||||
const navToActivePath = useAtomValue(useNavToActivePathAtom());
|
||||
const inboxSelected = useInboxSelected();
|
||||
const allInvites = useAtomValue(allInvitesAtom);
|
||||
const inviteCount = allInvites.length;
|
||||
|
||||
const handleInboxClick = () => {
|
||||
const navOpts = { replace: isNativePlatform() };
|
||||
if (screenSize === ScreenSize.Mobile) {
|
||||
navigate(getInboxPath(), navOpts);
|
||||
return;
|
||||
}
|
||||
const activePath = navToActivePath.get('inbox');
|
||||
if (activePath) {
|
||||
navigate(joinPathComponent(activePath), navOpts);
|
||||
return;
|
||||
}
|
||||
|
||||
const path = inviteCount > 0 ? getInboxInvitesPath() : getInboxNotificationsPath();
|
||||
navigate(path, navOpts);
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarItem active={inboxSelected}>
|
||||
<SidebarItemTooltip tooltip={t('Inbox.inbox')}>
|
||||
{(triggerRef) => (
|
||||
<SidebarAvatar as="button" ref={triggerRef} outlined onClick={handleInboxClick}>
|
||||
<Icon src={Icons.Inbox} filled={inboxSelected} />
|
||||
</SidebarAvatar>
|
||||
)}
|
||||
</SidebarItemTooltip>
|
||||
{inviteCount > 0 && (
|
||||
<SidebarItemBadge hasCount>
|
||||
<UnreadBadge highlight count={inviteCount} />
|
||||
</SidebarItemBadge>
|
||||
)}
|
||||
</SidebarItem>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
export * from './DirectTab';
|
||||
export * from './SpaceTabs';
|
||||
export * from './InboxTab';
|
||||
export * from './ExploreTab';
|
||||
export * from './SettingsTab';
|
||||
export * from './UnverifiedTab';
|
||||
|
|
|
|||
|
|
@ -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) });
|
||||
|
|
|
|||
|
|
@ -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/';
|
||||
|
|
|
|||
|
|
@ -1,55 +0,0 @@
|
|||
import { useAtomValue } from 'jotai';
|
||||
import { selectAtom } from 'jotai/utils';
|
||||
import { MatrixClient } from 'matrix-js-sdk';
|
||||
import { useCallback } from 'react';
|
||||
import { isDirectInvite, isRoom, isSpace, isUnsupportedRoom } from '../../utils/room';
|
||||
import { compareRoomsEqual } from '../room-list/utils';
|
||||
import { allInvitesAtom } from '../room-list/inviteList';
|
||||
|
||||
export const useSpaceInvites = (mx: MatrixClient, invitesAtom: typeof allInvitesAtom) => {
|
||||
const selector = useCallback(
|
||||
(rooms: string[]) => rooms.filter((roomId) => isSpace(mx.getRoom(roomId))),
|
||||
[mx]
|
||||
);
|
||||
return useAtomValue(selectAtom(invitesAtom, selector, compareRoomsEqual));
|
||||
};
|
||||
|
||||
export const useRoomInvites = (
|
||||
mx: MatrixClient,
|
||||
invitesAtom: typeof allInvitesAtom,
|
||||
mDirects: Set<string>
|
||||
) => {
|
||||
const selector = useCallback(
|
||||
(rooms: string[]) =>
|
||||
rooms.filter(
|
||||
(roomId) =>
|
||||
isRoom(mx.getRoom(roomId)) &&
|
||||
!(mDirects.has(roomId) || isDirectInvite(mx.getRoom(roomId), mx.getUserId()))
|
||||
),
|
||||
[mx, mDirects]
|
||||
);
|
||||
return useAtomValue(selectAtom(invitesAtom, selector, compareRoomsEqual));
|
||||
};
|
||||
|
||||
export const useDirectInvites = (
|
||||
mx: MatrixClient,
|
||||
invitesAtom: typeof allInvitesAtom,
|
||||
mDirects: Set<string>
|
||||
) => {
|
||||
const selector = useCallback(
|
||||
(rooms: string[]) =>
|
||||
rooms.filter(
|
||||
(roomId) => mDirects.has(roomId) || isDirectInvite(mx.getRoom(roomId), mx.getUserId())
|
||||
),
|
||||
[mx, mDirects]
|
||||
);
|
||||
return useAtomValue(selectAtom(invitesAtom, selector, compareRoomsEqual));
|
||||
};
|
||||
|
||||
export const useUnsupportedInvites = (mx: MatrixClient, invitesAtom: typeof allInvitesAtom) => {
|
||||
const selector = useCallback(
|
||||
(rooms: string[]) => rooms.filter((roomId) => isUnsupportedRoom(mx.getRoom(roomId))),
|
||||
[mx]
|
||||
);
|
||||
return useAtomValue(selectAtom(invitesAtom, selector, compareRoomsEqual));
|
||||
};
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
26
src/sw.ts
26
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);
|
||||
})()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue