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
7fcc2339d0
commit
eb56a0bc4e
27 changed files with 630 additions and 2048 deletions
|
|
@ -161,6 +161,8 @@
|
||||||
"enable": "Enable",
|
"enable": "Enable",
|
||||||
"notification_sound": "Notification Sound",
|
"notification_sound": "Notification Sound",
|
||||||
"notification_sound_desc": "Play sound when a new message arrives.",
|
"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_notifications": "Background Notifications",
|
||||||
"push_description": "Receive notifications even when Vojo is closed or minimized.",
|
"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.",
|
"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": "No Direct Messages",
|
||||||
"no_direct_messages_desc": "You do not have any direct messages yet.",
|
"no_direct_messages_desc": "You do not have any direct messages yet.",
|
||||||
"direct_message": "Direct Message",
|
"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": "Create Chat",
|
||||||
"create_chat_subtitle": "Start a private, encrypted chat by entering a username.",
|
"create_chat_subtitle": "Start a private, encrypted chat by entering a username.",
|
||||||
"start_first_chat": "Start a chat",
|
"start_first_chat": "Start a chat",
|
||||||
|
|
@ -527,47 +544,6 @@
|
||||||
"member_joined_call": "<bold>{{user}}</bold> joined the call"
|
"member_joined_call": "<bold>{{user}}</bold> joined the call"
|
||||||
},
|
},
|
||||||
"Inbox": {
|
"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",
|
"invite_title": "Invite",
|
||||||
"user_id": "User ID",
|
"user_id": "User ID",
|
||||||
"user_id_placeholder": "@username:server",
|
"user_id_placeholder": "@username:server",
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,8 @@
|
||||||
"enable": "Включить",
|
"enable": "Включить",
|
||||||
"notification_sound": "Звук уведомлений",
|
"notification_sound": "Звук уведомлений",
|
||||||
"notification_sound_desc": "Воспроизводить звук при получении нового сообщения.",
|
"notification_sound_desc": "Воспроизводить звук при получении нового сообщения.",
|
||||||
|
"invite_spam_filter": "Фильтр спам-приглашений",
|
||||||
|
"invite_spam_filter_desc": "Скрывать входящие приглашения в чаты, похожие на спам (массовые рассылки, заблокированные отправители, подозрительные слова). Выключите, чтобы видеть все приглашения как есть.",
|
||||||
"push_notifications": "Фоновые уведомления",
|
"push_notifications": "Фоновые уведомления",
|
||||||
"push_description": "Получать уведомления даже когда Vojo свёрнут или закрыт.",
|
"push_description": "Получать уведомления даже когда Vojo свёрнут или закрыт.",
|
||||||
"push_permission_blocked": "Разрешение на push-уведомления отклонено. Включите его в настройках устройства.",
|
"push_permission_blocked": "Разрешение на push-уведомления отклонено. Включите его в настройках устройства.",
|
||||||
|
|
@ -365,6 +367,23 @@
|
||||||
"no_direct_messages": "Нет личных сообщений",
|
"no_direct_messages": "Нет личных сообщений",
|
||||||
"no_direct_messages_desc": "У вас ещё нет личных сообщений.",
|
"no_direct_messages_desc": "У вас ещё нет личных сообщений.",
|
||||||
"direct_message": "Новый чат",
|
"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": "Новый чат",
|
||||||
"create_chat_subtitle": "Начните приватный зашифрованный чат, указав имя пользователя.",
|
"create_chat_subtitle": "Начните приватный зашифрованный чат, указав имя пользователя.",
|
||||||
"start_first_chat": "Начать чат",
|
"start_first_chat": "Начать чат",
|
||||||
|
|
@ -529,49 +548,6 @@
|
||||||
"member_joined_call": "<bold>{{user}}</bold> теперь в звонке"
|
"member_joined_call": "<bold>{{user}}</bold> теперь в звонке"
|
||||||
},
|
},
|
||||||
"Inbox": {
|
"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": "Пригласить",
|
"invite_title": "Пригласить",
|
||||||
"user_id": "ID пользователя",
|
"user_id": "ID пользователя",
|
||||||
"user_id_placeholder": "@username:server",
|
"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 './RoomNavItem';
|
||||||
export * from './RoomNavCategoryButton';
|
export * from './RoomNavCategoryButton';
|
||||||
export * from './DmStreamRow';
|
export * from './DmStreamRow';
|
||||||
|
export * from './DirectInviteRow';
|
||||||
|
|
|
||||||
|
|
@ -175,6 +175,7 @@ export function SystemNotification() {
|
||||||
settingsAtom,
|
settingsAtom,
|
||||||
'isNotificationSounds'
|
'isNotificationSounds'
|
||||||
);
|
);
|
||||||
|
const [inviteSpamFilter, setInviteSpamFilter] = useSetting(settingsAtom, 'inviteSpamFilter');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
|
|
@ -199,6 +200,18 @@ export function SystemNotification() {
|
||||||
after={<Switch value={isNotificationSounds} onChange={setIsNotificationSounds} />}
|
after={<Switch value={isNotificationSounds} onChange={setIsNotificationSounds} />}
|
||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</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
|
<SequenceCard
|
||||||
className={SequenceCardStyle}
|
className={SequenceCardStyle}
|
||||||
variant="SurfaceVariant"
|
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,
|
unregisterPusher,
|
||||||
urlBase64ToUint8Array,
|
urlBase64ToUint8Array,
|
||||||
} from '../utils/push';
|
} from '../utils/push';
|
||||||
import { getDirectRoomPath, getInboxInvitesPath } from '../pages/pathUtils';
|
import { getDirectPath, getDirectRoomPath } from '../pages/pathUtils';
|
||||||
import { pendingCallActionAtom } from '../state/pendingCallAction';
|
import { pendingCallActionAtom } from '../state/pendingCallAction';
|
||||||
import { useRoomNavigate } from './useRoomNavigate';
|
import { useRoomNavigate } from './useRoomNavigate';
|
||||||
|
|
||||||
|
|
@ -303,7 +303,14 @@ export function usePushNotificationsLifecycle(): void {
|
||||||
// hops accumulate as N+ entries in our app back-stack (see
|
// hops accumulate as N+ entries in our app back-stack (see
|
||||||
// useAndroidBackButton) — user presses back many times to exit one chat.
|
// useAndroidBackButton) — user presses back many times to exit one chat.
|
||||||
if (detail?.isInvite) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (detail?.roomId) navigateRoom(detail.roomId, undefined, { replace: true });
|
if (detail?.roomId) navigateRoom(detail.roomId, undefined, { replace: true });
|
||||||
|
|
@ -343,8 +350,25 @@ export function usePushNotificationsLifecycle(): void {
|
||||||
room_id?: string;
|
room_id?: string;
|
||||||
call_action?: 'answer' | 'decline';
|
call_action?: 'answer' | 'decline';
|
||||||
notif_event_id?: string;
|
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
|
// Native CallStyle Answer → open the room and queue a JS-side
|
||||||
// switch/start via
|
// switch/start via
|
||||||
// pendingCallActionAtom. The consumer hook picks it up once the
|
// pendingCallActionAtom. The consumer hook picks it up once the
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import { useMatch } from 'react-router-dom';
|
import { useMatch } from 'react-router-dom';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
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 = {
|
type MobileFriendlyClientNavProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
|
@ -12,12 +12,11 @@ export function MobileFriendlyClientNav({ children }: MobileFriendlyClientNavPro
|
||||||
const directMatch = useMatch({ path: DIRECT_PATH, caseSensitive: true, end: true });
|
const directMatch = useMatch({ path: DIRECT_PATH, caseSensitive: true, end: true });
|
||||||
const spaceMatch = useMatch({ path: SPACE_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 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 });
|
const botsMatch = useMatch({ path: BOTS_PATH, caseSensitive: true, end: true });
|
||||||
|
|
||||||
if (
|
if (
|
||||||
screenSize === ScreenSize.Mobile &&
|
screenSize === ScreenSize.Mobile &&
|
||||||
!(homeMatch || directMatch || spaceMatch || exploreMatch || inboxMatch || botsMatch)
|
!(homeMatch || directMatch || spaceMatch || exploreMatch || botsMatch)
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,16 +18,13 @@ import {
|
||||||
EXPLORE_PATH,
|
EXPLORE_PATH,
|
||||||
HOME_PATH,
|
HOME_PATH,
|
||||||
LOGIN_PATH,
|
LOGIN_PATH,
|
||||||
INBOX_PATH,
|
|
||||||
REGISTER_PATH,
|
REGISTER_PATH,
|
||||||
RESET_PASSWORD_PATH,
|
RESET_PASSWORD_PATH,
|
||||||
SPACE_PATH,
|
SPACE_PATH,
|
||||||
_CREATE_PATH,
|
_CREATE_PATH,
|
||||||
_FEATURED_PATH,
|
_FEATURED_PATH,
|
||||||
_INVITES_PATH,
|
|
||||||
_JOIN_PATH,
|
_JOIN_PATH,
|
||||||
_LOBBY_PATH,
|
_LOBBY_PATH,
|
||||||
_NOTIFICATIONS_PATH,
|
|
||||||
_ROOM_PATH,
|
_ROOM_PATH,
|
||||||
_SEARCH_PATH,
|
_SEARCH_PATH,
|
||||||
_SERVER_PATH,
|
_SERVER_PATH,
|
||||||
|
|
@ -41,7 +38,6 @@ import {
|
||||||
getDirectCreatePath,
|
getDirectCreatePath,
|
||||||
getExploreFeaturedPath,
|
getExploreFeaturedPath,
|
||||||
getHomePath,
|
getHomePath,
|
||||||
getInboxNotificationsPath,
|
|
||||||
getLoginPath,
|
getLoginPath,
|
||||||
getOriginBaseUrl,
|
getOriginBaseUrl,
|
||||||
getSpaceLobbyPath,
|
getSpaceLobbyPath,
|
||||||
|
|
@ -54,7 +50,6 @@ import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct';
|
||||||
import { BotExperienceHost, Bots } from './client/bots';
|
import { BotExperienceHost, Bots } from './client/bots';
|
||||||
import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space';
|
import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space';
|
||||||
import { Explore, FeaturedRooms, PublicRooms } from './client/explore';
|
import { Explore, FeaturedRooms, PublicRooms } from './client/explore';
|
||||||
import { Notifications, Inbox, Invites } from './client/inbox';
|
|
||||||
import { setAfterLoginRedirectPath } from './afterLoginRedirectPath';
|
import { setAfterLoginRedirectPath } from './afterLoginRedirectPath';
|
||||||
import { Room } from '../features/room';
|
import { Room } from '../features/room';
|
||||||
import { Lobby } from '../features/lobby';
|
import { Lobby } from '../features/lobby';
|
||||||
|
|
@ -330,30 +325,10 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
||||||
</Route>
|
</Route>
|
||||||
<Route path={CREATE_PATH} element={<Create />} />
|
<Route path={CREATE_PATH} element={<Create />} />
|
||||||
<Route path={USER_LINK_PATH} element={<UserLinkRedirect />} />
|
<Route path={USER_LINK_PATH} element={<UserLinkRedirect />} />
|
||||||
<Route
|
{/* Legacy /inbox/ tree — invites moved inline into the Direct list,
|
||||||
path={INBOX_PATH}
|
the Notifications aggregator was removed. Keep the route as a
|
||||||
element={
|
redirect so old push deep-links and bookmarks resolve cleanly. */}
|
||||||
<PageRoot
|
<Route path="/inbox/*" element={<Navigate to={DIRECT_PATH} replace />} />
|
||||||
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>
|
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/*" element={<p>Page not found</p>} />
|
<Route path="/*" element={<p>Page not found</p>} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { getNotificationType, getUnreadInfo, isNotificationEvent } from '../../utils/room';
|
import { getNotificationType, getUnreadInfo, isNotificationEvent } from '../../utils/room';
|
||||||
import { NotificationType, UnreadInfo } from '../../../types/matrix/room';
|
import { NotificationType, UnreadInfo } from '../../../types/matrix/room';
|
||||||
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
||||||
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
|
||||||
import { usePushNotificationsLifecycle } from '../../hooks/usePushNotifications';
|
import { usePushNotificationsLifecycle } from '../../hooks/usePushNotifications';
|
||||||
import { PushPermissionPrompt } from '../../components/push-permission-prompt';
|
import { PushPermissionPrompt } from '../../components/push-permission-prompt';
|
||||||
import { FullScreenIntentPrompt } from '../../components/full-screen-intent-prompt';
|
import { FullScreenIntentPrompt } from '../../components/full-screen-intent-prompt';
|
||||||
|
|
@ -80,7 +79,6 @@ function MessageNotifications() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
|
const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
|
||||||
|
|
||||||
const notificationSelected = useInboxNotificationsSelected();
|
|
||||||
const selectedRoomId = useSelectedRoom();
|
const selectedRoomId = useSelectedRoom();
|
||||||
|
|
||||||
const playSound = useCallback(() => {
|
const playSound = useCallback(() => {
|
||||||
|
|
@ -97,7 +95,7 @@ function MessageNotifications() {
|
||||||
data
|
data
|
||||||
) => {
|
) => {
|
||||||
if (mx.getSyncState() !== 'SYNCING') return;
|
if (mx.getSyncState() !== 'SYNCING') return;
|
||||||
if (document.hasFocus() && (selectedRoomId === room?.roomId || notificationSelected)) return;
|
if (document.hasFocus() && selectedRoomId === room?.roomId) return;
|
||||||
if (
|
if (
|
||||||
!room ||
|
!room ||
|
||||||
!data.liveEvent ||
|
!data.liveEvent ||
|
||||||
|
|
@ -131,7 +129,7 @@ function MessageNotifications() {
|
||||||
return () => {
|
return () => {
|
||||||
mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
|
mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
|
||||||
};
|
};
|
||||||
}, [mx, notificationSound, notificationSelected, playSound, selectedRoomId]);
|
}, [mx, notificationSound, playSound, selectedRoomId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import {
|
||||||
import {
|
import {
|
||||||
DirectTab,
|
DirectTab,
|
||||||
SpaceTabs,
|
SpaceTabs,
|
||||||
InboxTab,
|
|
||||||
ExploreTab,
|
ExploreTab,
|
||||||
SettingsTab,
|
SettingsTab,
|
||||||
UnverifiedTab,
|
UnverifiedTab,
|
||||||
|
|
@ -43,7 +42,6 @@ export function SidebarNav() {
|
||||||
<SidebarStack>
|
<SidebarStack>
|
||||||
<SearchTab />
|
<SearchTab />
|
||||||
<UnverifiedTab />
|
<UnverifiedTab />
|
||||||
<InboxTab />
|
|
||||||
<SettingsTab />
|
<SettingsTab />
|
||||||
</SidebarStack>
|
</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 { useTranslation } from 'react-i18next';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { Box, Button, Icon, Icons, Text, color, config, toRem } from 'folds';
|
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 { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
|
||||||
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
||||||
import { VirtualTile } from '../../../components/virtualizer';
|
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 { roomToUnreadAtom } from '../../../state/room/roomToUnread';
|
||||||
import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
|
import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
|
||||||
import { useDirectRooms } from './useDirectRooms';
|
import { useDirectRooms } from './useDirectRooms';
|
||||||
|
import { useDirectInvites, DirectInviteEntry } from './useDirectInvites';
|
||||||
import { PageNav, PageNavContent } from '../../../components/page';
|
import { PageNav, PageNavContent } from '../../../components/page';
|
||||||
import {
|
import {
|
||||||
getRoomNotificationMode,
|
getRoomNotificationMode,
|
||||||
|
|
@ -26,6 +27,12 @@ import { DirectSelfRow } from './DirectSelfRow';
|
||||||
|
|
||||||
const MONO_FONT = '"JetBrains Mono Variable", ui-monospace, monospace';
|
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() {
|
function DirectEmpty() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
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() {
|
export function Direct() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
useNavToActivePathMapper('direct');
|
useNavToActivePathMapper('direct');
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const directs = useDirectRooms();
|
const directs = useDirectRooms();
|
||||||
|
const invites = useDirectInvites();
|
||||||
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
||||||
|
const [spamExpanded, setSpamExpanded] = useState(false);
|
||||||
// roomToUnreadAtom only changes on read/unread transitions and ignores own
|
// roomToUnreadAtom only changes on read/unread transitions and ignores own
|
||||||
// events — covers incoming notifying messages but not own sends or muted
|
// events — covers incoming notifying messages but not own sends or muted
|
||||||
// incoming. Kept as a subscribe-only re-render trigger.
|
// incoming. Kept as a subscribe-only re-render trigger.
|
||||||
|
|
@ -130,16 +177,56 @@ export function Direct() {
|
||||||
}, [mx, directs]);
|
}, [mx, directs]);
|
||||||
|
|
||||||
const selectedRoomId = useSelectedRoom();
|
const selectedRoomId = useSelectedRoom();
|
||||||
const noRoomToDisplay = directs.length === 0;
|
|
||||||
|
|
||||||
// Sort each render — small list, getLastActiveTimestamp changes outside
|
const items = useMemo<ListItem[]>(() => {
|
||||||
// React's dep model so memoising would need a manual trigger anyway.
|
const list: ListItem[] = [];
|
||||||
const sortedDirects = Array.from(directs).sort(factoryRoomIdByActivity(mx));
|
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({
|
const virtualizer = useVirtualizer({
|
||||||
count: sortedDirects.length,
|
count: items.length,
|
||||||
getScrollElement: () => scrollRef.current,
|
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,
|
overscan: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -159,15 +246,52 @@ export function Direct() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{virtualizer.getVirtualItems().map((vItem) => {
|
{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);
|
const room = mx.getRoom(roomId);
|
||||||
if (!room) return null;
|
if (!room) return null;
|
||||||
const selected = selectedRoomId === roomId;
|
const selected = selectedRoomId === roomId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VirtualTile
|
<VirtualTile
|
||||||
virtualItem={vItem}
|
virtualItem={vItem}
|
||||||
key={vItem.index}
|
key={`direct-${roomId}`}
|
||||||
ref={virtualizer.measureElement}
|
ref={virtualizer.measureElement}
|
||||||
>
|
>
|
||||||
<DmStreamRow
|
<DmStreamRow
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import React, { ReactNode } from 'react';
|
import React, { ReactNode } from 'react';
|
||||||
import { Room } from 'matrix-js-sdk';
|
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 { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
||||||
import { IsOneOnOneProvider, RoomProvider } from '../../../hooks/useRoom';
|
import { IsOneOnOneProvider, RoomProvider } from '../../../hooks/useRoom';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
|
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
|
||||||
import { isSpace } from '../../../utils/room';
|
import { isSpace } from '../../../utils/room';
|
||||||
import { useIsOneOnOneRoom } from '../../../hooks/useIsOneOnOneRoom';
|
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
|
// 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`
|
// 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 roomId = useSelectedRoom();
|
||||||
const room = mx.getRoom(roomId);
|
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
|
// After P3c the Direct tab is universal — any joined non-space room renders
|
||||||
// here. Spaces (= future Channels) keep their own /{spaceId}/ route.
|
// here. Spaces (= future Channels) keep their own /{spaceId}/ route.
|
||||||
if (!room || isSpace(room)) {
|
if (!room || isSpace(room)) {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
export * from './Direct';
|
export * from './Direct';
|
||||||
export * from './RoomProvider';
|
export * from './RoomProvider';
|
||||||
export * from './DirectCreate';
|
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 './DirectTab';
|
||||||
export * from './SpaceTabs';
|
export * from './SpaceTabs';
|
||||||
export * from './InboxTab';
|
|
||||||
export * from './ExploreTab';
|
export * from './ExploreTab';
|
||||||
export * from './SettingsTab';
|
export * from './SettingsTab';
|
||||||
export * from './UnverifiedTab';
|
export * from './UnverifiedTab';
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,6 @@ import {
|
||||||
HOME_ROOM_PATH,
|
HOME_ROOM_PATH,
|
||||||
HOME_SEARCH_PATH,
|
HOME_SEARCH_PATH,
|
||||||
LOGIN_PATH,
|
LOGIN_PATH,
|
||||||
INBOX_INVITES_PATH,
|
|
||||||
INBOX_NOTIFICATIONS_PATH,
|
|
||||||
INBOX_PATH,
|
|
||||||
REGISTER_PATH,
|
REGISTER_PATH,
|
||||||
RESET_PASSWORD_PATH,
|
RESET_PASSWORD_PATH,
|
||||||
ROOT_PATH,
|
ROOT_PATH,
|
||||||
|
|
@ -157,10 +154,6 @@ export const getExploreServerPath = (server: string): string => {
|
||||||
|
|
||||||
export const getCreatePath = (): string => CREATE_PATH;
|
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 getBotsPath = (): string => BOTS_PATH;
|
||||||
export const getBotPath = (botId: string): string =>
|
export const getBotPath = (botId: string): string =>
|
||||||
generatePath(BOTS_BOT_PATH, { botId: encodeURIComponent(botId) });
|
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_PATH = '/bots/';
|
||||||
export const BOTS_BOT_PATH = '/bots/:botId/';
|
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 SPACE_SETTINGS_PATH = '/space-settings/';
|
||||||
|
|
||||||
export const ROOM_SETTINGS_PATH = '/room-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;
|
showHiddenEvents: boolean;
|
||||||
|
|
||||||
isNotificationSounds: boolean;
|
isNotificationSounds: boolean;
|
||||||
|
inviteSpamFilter: boolean;
|
||||||
|
|
||||||
hour24Clock: boolean;
|
hour24Clock: boolean;
|
||||||
dateFormatString: string;
|
dateFormatString: string;
|
||||||
|
|
@ -63,6 +64,7 @@ const defaultSettings: Settings = {
|
||||||
showHiddenEvents: false,
|
showHiddenEvents: false,
|
||||||
|
|
||||||
isNotificationSounds: true,
|
isNotificationSounds: true,
|
||||||
|
inviteSpamFilter: true,
|
||||||
|
|
||||||
hour24Clock: false,
|
hour24Clock: false,
|
||||||
dateFormatString: 'D MMM YYYY',
|
dateFormatString: 'D MMM YYYY',
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import {
|
||||||
DIRECT_PATH,
|
DIRECT_PATH,
|
||||||
EXPLORE_PATH,
|
EXPLORE_PATH,
|
||||||
HOME_PATH,
|
HOME_PATH,
|
||||||
INBOX_PATH,
|
|
||||||
SPACE_PATH,
|
SPACE_PATH,
|
||||||
} from '../pages/paths';
|
} from '../pages/paths';
|
||||||
import {
|
import {
|
||||||
|
|
@ -12,7 +11,6 @@ import {
|
||||||
getDirectPath,
|
getDirectPath,
|
||||||
getExplorePath,
|
getExplorePath,
|
||||||
getHomePath,
|
getHomePath,
|
||||||
getInboxPath,
|
|
||||||
getSpacePath,
|
getSpacePath,
|
||||||
} from '../pages/pathUtils';
|
} 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(EXPLORE_PATH)) return atRoot(EXPLORE_PATH) ? null : getExplorePath();
|
||||||
if (under(INBOX_PATH)) return atRoot(INBOX_PATH) ? null : getInboxPath();
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
26
src/sw.ts
26
src/sw.ts
|
|
@ -738,11 +738,15 @@ self.addEventListener('push', (event: PushEvent) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isInvite) {
|
if (isInvite) {
|
||||||
// Invite notifications route to /inbox/invites, so we carry `isInvite`
|
// Invite notifications route to the bare /direct/ panel — the row
|
||||||
// in data for the click handler. Distinct tag namespace keeps invite
|
// lives inline in the Direct list and the user picks Accept/Decline
|
||||||
// banners from clobbering a message notification for the same room
|
// there. We intentionally don't deep-link into /direct/{roomId}/ for
|
||||||
// (rare, but possible if the invite event and a decrypted preview
|
// invite-state rooms (Room.tsx has no useful render path for them
|
||||||
// arrive close together).
|
// 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, {
|
await self.registration.showNotification(title, {
|
||||||
body,
|
body,
|
||||||
icon: '/res/android/android-chrome-192x192.png',
|
icon: '/res/android/android-chrome-192x192.png',
|
||||||
|
|
@ -789,11 +793,15 @@ self.addEventListener('notificationclick', (event) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Cold-start path: no live window to hand off to, so pick the
|
// 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
|
// destination up-front. Invites always land on the bare /direct/ panel
|
||||||
// to drop the user into a room they haven't joined); everything else
|
// (their row sits at the top until the user accepts/declines) — we
|
||||||
// opens the room directly.
|
// 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 = '/';
|
let path = '/';
|
||||||
if (isInvite) path = '/inbox/invites';
|
if (isInvite) path = '/direct/';
|
||||||
else if (roomId) path = `/home/${encodeURIComponent(roomId)}/`;
|
else if (roomId) path = `/home/${encodeURIComponent(roomId)}/`;
|
||||||
await self.clients.openWindow(path);
|
await self.clients.openWindow(path);
|
||||||
})()
|
})()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue