diff --git a/package-lock.json b/package-lock.json
index 5fd5b68..e553add 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -21,6 +21,7 @@
"@vanilla-extract/recipes": "0.3.0",
"@vanilla-extract/vite-plugin": "3.7.1",
"await-to-js": "3.0.0",
+ "badwords-list": "2.0.1-4",
"blurhash": "2.0.4",
"browser-encrypt-attachment": "0.3.0",
"chroma-js": "3.1.2",
@@ -5436,6 +5437,12 @@
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
}
},
+ "node_modules/badwords-list": {
+ "version": "2.0.1-4",
+ "resolved": "https://registry.npmjs.org/badwords-list/-/badwords-list-2.0.1-4.tgz",
+ "integrity": "sha512-FxfZUp7B9yCnesNtFQS9v6PvZdxTYa14Q60JR6vhjdQdWI4naTjJIyx22JzoER8ooeT8SAAKoHLjKfCV7XgYUQ==",
+ "license": "MIT"
+ },
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
diff --git a/package.json b/package.json
index 983cbe4..01ba964 100644
--- a/package.json
+++ b/package.json
@@ -32,6 +32,7 @@
"@vanilla-extract/recipes": "0.3.0",
"@vanilla-extract/vite-plugin": "3.7.1",
"await-to-js": "3.0.0",
+ "badwords-list": "2.0.1-4",
"blurhash": "2.0.4",
"browser-encrypt-attachment": "0.3.0",
"chroma-js": "3.1.2",
diff --git a/src/app/components/page/Page.tsx b/src/app/components/page/Page.tsx
index 55ccece..a545638 100644
--- a/src/app/components/page/Page.tsx
+++ b/src/app/components/page/Page.tsx
@@ -105,6 +105,20 @@ export const PageContent = as<'div'>(({ className, ...props }, ref) => (
));
+export function PageHeroEmpty({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
export const PageHeroSection = as<'div', ComponentProps>(
({ className, ...props }, ref) => (
{!msgSearchParams.term && status === 'pending' && (
-
+
}
@@ -241,7 +230,7 @@ export function MessageSearch({
subTitle="Find helpful messages in your community by searching with related keywords."
/>
-
+
)}
{msgSearchParams.term && groups.length === 0 && status === 'success' && (
diff --git a/src/app/features/settings/account/Account.tsx b/src/app/features/settings/account/Account.tsx
index bfdb0ef..c4b56e4 100644
--- a/src/app/features/settings/account/Account.tsx
+++ b/src/app/features/settings/account/Account.tsx
@@ -1,396 +1,10 @@
-import React, {
- ChangeEventHandler,
- FormEventHandler,
- useCallback,
- useEffect,
- useMemo,
- useState,
-} from 'react';
-import {
- Box,
- Text,
- IconButton,
- Icon,
- Icons,
- Scroll,
- Input,
- Avatar,
- Button,
- Chip,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
- Modal,
- Dialog,
- Header,
- config,
- Spinner,
-} from 'folds';
-import FocusTrap from 'focus-trap-react';
+import React from 'react';
+import { Box, Text, IconButton, Icon, Icons, Scroll } from 'folds';
import { Page, PageContent, PageHeader } from '../../../components/page';
-import { SequenceCard } from '../../../components/sequence-card';
-import { SequenceCardStyle } from '../styles.css';
-import { SettingTile } from '../../../components/setting-tile';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
-import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
-import { UserAvatar } from '../../../components/user-avatar';
-import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
-import { nameInitials } from '../../../utils/common';
-import { copyToClipboard } from '../../../utils/dom';
-import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
-import { useFilePicker } from '../../../hooks/useFilePicker';
-import { useObjectURL } from '../../../hooks/useObjectURL';
-import { stopPropagation } from '../../../utils/keyboard';
-import { ImageEditor } from '../../../components/image-editor';
-import { ModalWide } from '../../../styles/Modal.css';
-import { createUploadAtom, UploadSuccess } from '../../../state/upload';
-import { CompactUploadCardRenderer } from '../../../components/upload-card';
-import { useCapabilities } from '../../../hooks/useCapabilities';
-
-function MatrixId() {
- const mx = useMatrixClient();
- const userId = mx.getUserId()!;
-
- return (
-
- Matrix ID
-
- copyToClipboard(userId)}>
- Copy
-
- }
- />
-
-
- );
-}
-
-type ProfileProps = {
- profile: UserProfile;
- userId: string;
-};
-function ProfileAvatar({ profile, userId }: ProfileProps) {
- const mx = useMatrixClient();
- const useAuthentication = useMediaAuthentication();
- const capabilities = useCapabilities();
- const [alertRemove, setAlertRemove] = useState(false);
- const disableSetAvatar = capabilities['m.set_avatar_url']?.enabled === false;
-
- const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
- const avatarUrl = profile.avatarUrl
- ? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
- : undefined;
-
- const [imageFile, setImageFile] = useState();
- const imageFileURL = useObjectURL(imageFile);
- const uploadAtom = useMemo(() => {
- if (imageFile) return createUploadAtom(imageFile);
- return undefined;
- }, [imageFile]);
-
- const pickFile = useFilePicker(setImageFile, false);
-
- const handleRemoveUpload = useCallback(() => {
- setImageFile(undefined);
- }, []);
-
- const handleUploaded = useCallback(
- (upload: UploadSuccess) => {
- const { mxc } = upload;
- mx.setAvatarUrl(mxc);
- handleRemoveUpload();
- },
- [mx, handleRemoveUpload]
- );
-
- const handleRemoveAvatar = () => {
- mx.setAvatarUrl('');
- setAlertRemove(false);
- };
-
- return (
-
- Avatar
-
- }
- after={
-
- {nameInitials(defaultDisplayName)}}
- />
-
- }
- >
- {uploadAtom ? (
-
-
-
- ) : (
-
-
- {avatarUrl && (
-
- )}
-
- )}
-
- {imageFileURL && (
- }>
-
-
-
-
-
-
-
-
- )}
-
- }>
-
- setAlertRemove(false),
- clickOutsideDeactivates: true,
- escapeDeactivates: stopPropagation,
- }}
- >
-
-
-
-
-
- );
-}
-
-function ProfileDisplayName({ profile, userId }: ProfileProps) {
- const mx = useMatrixClient();
- const capabilities = useCapabilities();
- const disableSetDisplayname = capabilities['m.set_displayname']?.enabled === false;
-
- const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
- const [displayName, setDisplayName] = useState(defaultDisplayName);
-
- const [changeState, changeDisplayName] = useAsyncCallback(
- useCallback((name: string) => mx.setDisplayName(name), [mx])
- );
- const changingDisplayName = changeState.status === AsyncStatus.Loading;
-
- useEffect(() => {
- setDisplayName(defaultDisplayName);
- }, [defaultDisplayName]);
-
- const handleChange: ChangeEventHandler = (evt) => {
- const name = evt.currentTarget.value;
- setDisplayName(name);
- };
-
- const handleReset = () => {
- setDisplayName(defaultDisplayName);
- };
-
- const handleSubmit: FormEventHandler = (evt) => {
- evt.preventDefault();
- if (changingDisplayName) return;
-
- const target = evt.target as HTMLFormElement | undefined;
- const displayNameInput = target?.displayNameInput as HTMLInputElement | undefined;
- const name = displayNameInput?.value;
- if (!name) return;
-
- changeDisplayName(name);
- };
-
- const hasChanges = displayName !== defaultDisplayName;
- return (
-
- Display Name
-
- }
- >
-
-
-
-
-
-
- )
- }
- />
-
-
-
-
-
- );
-}
-
-function Profile() {
- const mx = useMatrixClient();
- const userId = mx.getUserId()!;
- const profile = useUserProfile(userId);
-
- return (
-
- Profile
-
-
-
-
-
- );
-}
-
-function ContactInformation() {
- const mx = useMatrixClient();
- const [threePIdsState, loadThreePIds] = useAsyncCallback(
- useCallback(() => mx.getThreePids(), [mx])
- );
- const threePIds =
- threePIdsState.status === AsyncStatus.Success ? threePIdsState.data.threepids : undefined;
-
- const emailIds = threePIds?.filter((id) => id.medium === 'email');
-
- useEffect(() => {
- loadThreePIds();
- }, [loadThreePIds]);
-
- return (
-
- Contact Information
-
-
-
- {emailIds?.map((email) => (
-
- {email.address}
-
- ))}
-
- {/* */}
-
-
-
- );
-}
+import { MatrixId } from './MatrixId';
+import { Profile } from './Profile';
+import { ContactInformation } from './ContactInfo';
+import { IgnoredUserList } from './IgnoredUserList';
type AccountProps = {
requestClose: () => void;
@@ -419,6 +33,7 @@ export function Account({ requestClose }: AccountProps) {
+
diff --git a/src/app/features/settings/account/ContactInfo.tsx b/src/app/features/settings/account/ContactInfo.tsx
new file mode 100644
index 0000000..cfde7e2
--- /dev/null
+++ b/src/app/features/settings/account/ContactInfo.tsx
@@ -0,0 +1,45 @@
+import React, { useCallback, useEffect } from 'react';
+import { Box, Text, Chip } from 'folds';
+import { SequenceCard } from '../../../components/sequence-card';
+import { SequenceCardStyle } from '../styles.css';
+import { SettingTile } from '../../../components/setting-tile';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+
+export function ContactInformation() {
+ const mx = useMatrixClient();
+ const [threePIdsState, loadThreePIds] = useAsyncCallback(
+ useCallback(() => mx.getThreePids(), [mx])
+ );
+ const threePIds =
+ threePIdsState.status === AsyncStatus.Success ? threePIdsState.data.threepids : undefined;
+
+ const emailIds = threePIds?.filter((id) => id.medium === 'email');
+
+ useEffect(() => {
+ loadThreePIds();
+ }, [loadThreePIds]);
+
+ return (
+
+ Contact Information
+
+
+
+ {emailIds?.map((email) => (
+
+ {email.address}
+
+ ))}
+
+ {/* */}
+
+
+
+ );
+}
diff --git a/src/app/features/settings/notifications/IgnoredUserList.tsx b/src/app/features/settings/account/IgnoredUserList.tsx
similarity index 91%
rename from src/app/features/settings/notifications/IgnoredUserList.tsx
rename to src/app/features/settings/account/IgnoredUserList.tsx
index 0ff3015..98db945 100644
--- a/src/app/features/settings/notifications/IgnoredUserList.tsx
+++ b/src/app/features/settings/account/IgnoredUserList.tsx
@@ -7,16 +7,17 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { isUserId } from '../../../utils/matrix';
import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers';
+import { useAlive } from '../../../hooks/useAlive';
function IgnoreUserInput({ userList }: { userList: string[] }) {
const mx = useMatrixClient();
const [userId, setUserId] = useState('');
+ const alive = useAlive();
const [ignoreState, ignore] = useAsyncCallback(
useCallback(
async (uId: string) => {
- mx.setIgnoredUsers([...userList, uId]);
- setUserId('');
+ await mx.setIgnoredUsers([...userList, uId]);
},
[mx, userList]
)
@@ -43,7 +44,11 @@ function IgnoreUserInput({ userList }: { userList: string[] }) {
if (!isUserId(uId)) return;
- ignore(uId);
+ ignore(uId).then(() => {
+ if (alive()) {
+ setUserId('');
+ }
+ });
};
return (
@@ -129,7 +134,7 @@ export function IgnoredUserList() {
return (
- Block Messages
+ Blocked Users
{ignoredUsers.length > 0 && (
- Blocklist
+ Users
{ignoredUsers.map((userId) => (
diff --git a/src/app/features/settings/account/MatrixId.tsx b/src/app/features/settings/account/MatrixId.tsx
new file mode 100644
index 0000000..ac9b1fb
--- /dev/null
+++ b/src/app/features/settings/account/MatrixId.tsx
@@ -0,0 +1,33 @@
+import React from 'react';
+import { Box, Text, Chip } from 'folds';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { SequenceCard } from '../../../components/sequence-card';
+import { SequenceCardStyle } from '../styles.css';
+import { SettingTile } from '../../../components/setting-tile';
+import { copyToClipboard } from '../../../../util/common';
+
+export function MatrixId() {
+ const mx = useMatrixClient();
+ const userId = mx.getUserId()!;
+
+ return (
+
+ Matrix ID
+
+ copyToClipboard(userId)}>
+ Copy
+
+ }
+ />
+
+
+ );
+}
diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx
new file mode 100644
index 0000000..e982a79
--- /dev/null
+++ b/src/app/features/settings/account/Profile.tsx
@@ -0,0 +1,325 @@
+import React, {
+ ChangeEventHandler,
+ FormEventHandler,
+ useCallback,
+ useEffect,
+ useMemo,
+ useState,
+} from 'react';
+import {
+ Box,
+ Text,
+ IconButton,
+ Icon,
+ Icons,
+ Input,
+ Avatar,
+ Button,
+ Overlay,
+ OverlayBackdrop,
+ OverlayCenter,
+ Modal,
+ Dialog,
+ Header,
+ config,
+ Spinner,
+} from 'folds';
+import FocusTrap from 'focus-trap-react';
+import { SequenceCard } from '../../../components/sequence-card';
+import { SequenceCardStyle } from '../styles.css';
+import { SettingTile } from '../../../components/setting-tile';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
+import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
+import { UserAvatar } from '../../../components/user-avatar';
+import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
+import { nameInitials } from '../../../utils/common';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { useFilePicker } from '../../../hooks/useFilePicker';
+import { useObjectURL } from '../../../hooks/useObjectURL';
+import { stopPropagation } from '../../../utils/keyboard';
+import { ImageEditor } from '../../../components/image-editor';
+import { ModalWide } from '../../../styles/Modal.css';
+import { createUploadAtom, UploadSuccess } from '../../../state/upload';
+import { CompactUploadCardRenderer } from '../../../components/upload-card';
+import { useCapabilities } from '../../../hooks/useCapabilities';
+
+type ProfileProps = {
+ profile: UserProfile;
+ userId: string;
+};
+function ProfileAvatar({ profile, userId }: ProfileProps) {
+ const mx = useMatrixClient();
+ const useAuthentication = useMediaAuthentication();
+ const capabilities = useCapabilities();
+ const [alertRemove, setAlertRemove] = useState(false);
+ const disableSetAvatar = capabilities['m.set_avatar_url']?.enabled === false;
+
+ const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
+ const avatarUrl = profile.avatarUrl
+ ? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
+ : undefined;
+
+ const [imageFile, setImageFile] = useState();
+ const imageFileURL = useObjectURL(imageFile);
+ const uploadAtom = useMemo(() => {
+ if (imageFile) return createUploadAtom(imageFile);
+ return undefined;
+ }, [imageFile]);
+
+ const pickFile = useFilePicker(setImageFile, false);
+
+ const handleRemoveUpload = useCallback(() => {
+ setImageFile(undefined);
+ }, []);
+
+ const handleUploaded = useCallback(
+ (upload: UploadSuccess) => {
+ const { mxc } = upload;
+ mx.setAvatarUrl(mxc);
+ handleRemoveUpload();
+ },
+ [mx, handleRemoveUpload]
+ );
+
+ const handleRemoveAvatar = () => {
+ mx.setAvatarUrl('');
+ setAlertRemove(false);
+ };
+
+ return (
+
+ Avatar
+
+ }
+ after={
+
+ {nameInitials(defaultDisplayName)}}
+ />
+
+ }
+ >
+ {uploadAtom ? (
+
+
+
+ ) : (
+
+
+ {avatarUrl && (
+
+ )}
+
+ )}
+
+ {imageFileURL && (
+ }>
+
+
+
+
+
+
+
+
+ )}
+
+ }>
+
+ setAlertRemove(false),
+ clickOutsideDeactivates: true,
+ escapeDeactivates: stopPropagation,
+ }}
+ >
+
+
+
+
+
+ );
+}
+
+function ProfileDisplayName({ profile, userId }: ProfileProps) {
+ const mx = useMatrixClient();
+ const capabilities = useCapabilities();
+ const disableSetDisplayname = capabilities['m.set_displayname']?.enabled === false;
+
+ const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
+ const [displayName, setDisplayName] = useState(defaultDisplayName);
+
+ const [changeState, changeDisplayName] = useAsyncCallback(
+ useCallback((name: string) => mx.setDisplayName(name), [mx])
+ );
+ const changingDisplayName = changeState.status === AsyncStatus.Loading;
+
+ useEffect(() => {
+ setDisplayName(defaultDisplayName);
+ }, [defaultDisplayName]);
+
+ const handleChange: ChangeEventHandler = (evt) => {
+ const name = evt.currentTarget.value;
+ setDisplayName(name);
+ };
+
+ const handleReset = () => {
+ setDisplayName(defaultDisplayName);
+ };
+
+ const handleSubmit: FormEventHandler = (evt) => {
+ evt.preventDefault();
+ if (changingDisplayName) return;
+
+ const target = evt.target as HTMLFormElement | undefined;
+ const displayNameInput = target?.displayNameInput as HTMLInputElement | undefined;
+ const name = displayNameInput?.value;
+ if (!name) return;
+
+ changeDisplayName(name);
+ };
+
+ const hasChanges = displayName !== defaultDisplayName;
+ return (
+
+ Display Name
+
+ }
+ >
+
+
+
+
+
+
+ )
+ }
+ />
+
+
+
+
+
+ );
+}
+
+export function Profile() {
+ const mx = useMatrixClient();
+ const userId = mx.getUserId()!;
+ const profile = useUserProfile(userId);
+
+ return (
+
+ Profile
+
+
+
+
+
+ );
+}
diff --git a/src/app/features/settings/notifications/Notifications.tsx b/src/app/features/settings/notifications/Notifications.tsx
index aa339a0..095a9bb 100644
--- a/src/app/features/settings/notifications/Notifications.tsx
+++ b/src/app/features/settings/notifications/Notifications.tsx
@@ -5,7 +5,9 @@ import { SystemNotification } from './SystemNotification';
import { AllMessagesNotifications } from './AllMessages';
import { SpecialMessagesNotifications } from './SpecialMessages';
import { KeywordMessagesNotifications } from './KeywordMessages';
-import { IgnoredUserList } from './IgnoredUserList';
+import { SequenceCard } from '../../../components/sequence-card';
+import { SequenceCardStyle } from '../styles.css';
+import { SettingTile } from '../../../components/setting-tile';
type NotificationsProps = {
requestClose: () => void;
@@ -35,7 +37,19 @@ export function Notifications({ requestClose }: NotificationsProps) {
-
+
+ Block Messages
+
+ Block Users" section.'}
+ />
+
+
diff --git a/src/app/hooks/useReportRoomSupported.ts b/src/app/hooks/useReportRoomSupported.ts
new file mode 100644
index 0000000..198172c
--- /dev/null
+++ b/src/app/hooks/useReportRoomSupported.ts
@@ -0,0 +1,10 @@
+import { useSpecVersions } from './useSpecVersions';
+
+export const useReportRoomSupported = (): boolean => {
+ const { versions, unstable_features: unstableFeatures } = useSpecVersions();
+
+ // report room is introduced in spec version 1.13
+ const supported = unstableFeatures?.['org.matrix.msc4151'] || versions.includes('v1.13');
+
+ return supported;
+};
diff --git a/src/app/pages/client/inbox/Inbox.tsx b/src/app/pages/client/inbox/Inbox.tsx
index 686296b..67d6021 100644
--- a/src/app/pages/client/inbox/Inbox.tsx
+++ b/src/app/pages/client/inbox/Inbox.tsx
@@ -32,7 +32,7 @@ function InvitesNavItem() {
- Invitations
+ Invites
{inviteCount > 0 && }
diff --git a/src/app/pages/client/inbox/Invites.tsx b/src/app/pages/client/inbox/Invites.tsx
index 8dcfa1c..63fd21e 100644
--- a/src/app/pages/client/inbox/Invites.tsx
+++ b/src/app/pages/client/inbox/Invites.tsx
@@ -1,8 +1,10 @@
-import React, { useCallback, useRef, useState } from 'react';
+import React, { useCallback, useMemo, useRef, useState } from 'react';
import {
Avatar,
+ Badge,
Box,
Button,
+ Chip,
Icon,
IconButton,
Icons,
@@ -16,56 +18,129 @@ import {
config,
} from 'folds';
import { useAtomValue } from 'jotai';
+import { RoomTopicEventContent } from 'matrix-js-sdk/lib/types';
import FocusTrap from 'focus-trap-react';
-import { MatrixError, Room } from 'matrix-js-sdk';
-import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
-import { useDirectInvites, useRoomInvites, useSpaceInvites } from '../../../state/hooks/inviteList';
+import { MatrixClient, MatrixError, Room } from 'matrix-js-sdk';
+import {
+ Page,
+ PageContent,
+ PageContentCenter,
+ PageHeader,
+ PageHero,
+ PageHeroEmpty,
+ PageHeroSection,
+} from '../../../components/page';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { allInvitesAtom } from '../../../state/room-list/inviteList';
-import { mDirectAtom } from '../../../state/mDirectList';
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, getMxIdLocalPart, guessDmRoomUserId } from '../../../utils/matrix';
+import {
+ addRoomIdToMDirect,
+ getMxIdLocalPart,
+ guessDmRoomUserId,
+ rateLimitedActions,
+} from '../../../utils/matrix';
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 { useRoomTopic } from '../../../hooks/useRoomMeta';
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';
const COMPACT_CARD_WIDTH = 548;
-type InviteCardProps = {
+type InviteData = {
room: Room;
- userId: string;
- direct?: boolean;
- compact?: boolean;
- onNavigate: (roomId: string) => void;
+ roomId: string;
+ roomName: string;
+ roomAvatar?: string;
+ roomTopic?: string;
+ roomAlias?: string;
+
+ senderId: string;
+ senderName: string;
+ inviteTs?: number;
+
+ isSpace: boolean;
+ isDirect: boolean;
+ isEncrypted: boolean;
};
-function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardProps) {
- const mx = useMatrixClient();
- const useAuthentication = useMediaAuthentication();
+
+const makeInviteData = (mx: MatrixClient, room: Room, useAuthentication: boolean): InviteData => {
+ const userId = mx.getSafeUserId();
+ const direct = isDirectInvite(room, userId);
+
+ const roomAvatar = direct
+ ? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
+ : getRoomAvatarUrl(mx, room, 96, useAuthentication);
const roomName = room.name || room.getCanonicalAlias() || room.roomId;
+ const roomTopic =
+ getStateEvent(room, StateEvent.RoomTopic)?.getContent()?.topic ??
+ undefined;
+
const member = room.getMember(userId);
const memberEvent = member?.events.member;
- const memberTs = memberEvent?.getTs() ?? 0;
+
const senderId = memberEvent?.getSender();
const senderName = senderId
? getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId
: undefined;
+ const inviteTs = memberEvent?.getTs() ?? 0;
- const topic = useRoomTopic(room);
+ return {
+ room,
+ roomId: room.roomId,
+ roomAvatar,
+ roomName,
+ roomTopic,
+ roomAlias: room.getCanonicalAlias() ?? undefined,
+
+ senderId: senderId ?? 'Unknown',
+ senderName: senderName ?? 'Unknown',
+ inviteTs,
+
+ 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);
+
+type NavigateHandler = (roomId: string, space: boolean) => void;
+
+type InviteCardProps = {
+ invite: InviteData;
+ compact?: boolean;
+ onNavigate: NavigateHandler;
+ hideAvatar: boolean;
+};
+function InviteCard({ invite, compact, onNavigate, hideAvatar }: InviteCardProps) {
+ const mx = useMatrixClient();
+ const userId = mx.getSafeUserId();
const [viewTopic, setViewTopic] = useState(false);
const closeTopic = () => setViewTopic(false);
@@ -73,17 +148,19 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
const [joinState, join] = useAsyncCallback(
useCallback(async () => {
- const dmUserId = isDirectInvite(room, userId) ? guessDmRoomUserId(room, userId) : undefined;
+ const dmUserId = isDirectInvite(invite.room, userId)
+ ? guessDmRoomUserId(invite.room, userId)
+ : undefined;
- await mx.joinRoom(room.roomId);
+ await mx.joinRoom(invite.roomId);
if (dmUserId) {
- await addRoomIdToMDirect(mx, room.roomId, dmUserId);
+ await addRoomIdToMDirect(mx, invite.roomId, dmUserId);
}
- onNavigate(room.roomId);
- }, [mx, room, userId, onNavigate])
+ onNavigate(invite.roomId, invite.isSpace);
+ }, [mx, invite, userId, onNavigate])
);
const [leaveState, leave] = useAsyncCallback, MatrixError, []>(
- useCallback(() => mx.leave(room.roomId), [mx, room])
+ useCallback(() => mx.leave(invite.roomId), [mx, invite])
);
const joining =
@@ -95,28 +172,43 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
-
-
-
- Invited by {senderName}
-
+ {(invite.isEncrypted || invite.isDirect || invite.isSpace) && (
+
+ {invite.isEncrypted && (
+
+
+ Encrypted
+
+
+ )}
+ {invite.isDirect && (
+
+
+ Direct Message
+
+
+ )}
+ {invite.isSpace && (
+
+
+ Space
+
+
+ )}
-
-
-
-
+ )}
(
- {nameInitials(roomName)}
+ {nameInitials(hideAvatar && invite.roomAvatar ? undefined : invite.roomName)}
)}
/>
@@ -125,9 +217,9 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
- {roomName}
+ {invite.roomName}
- {topic && (
+ {invite.roomTopic && (
- {topic}
+ {invite.roomTopic}
)}
}>
@@ -149,8 +241,8 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
}}
>
@@ -173,6 +265,7 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
onClick={leave}
size="300"
variant="Secondary"
+ radii="300"
fill="Soft"
disabled={joining || leaving}
before={leaving ? : undefined}
@@ -182,28 +275,392 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
: undefined}
+ before={joining ? : undefined}
>
Accept
+
+
+
+ From: {invite.senderId}
+
+
+ {invite.inviteTs && (
+
+
+
+ )}
+
);
}
+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 isKnown = filter === InviteFilter.Known;
+ const isUnknown = filter === InviteFilter.Unknown;
+ const isSpam = filter === InviteFilter.Spam;
+
+ return (
+
+ onFilter(InviteFilter.Known)}
+ before={isKnown && }
+ after={
+ knownInvites.length > 0 && (
+
+ {knownInvites.length}
+
+ )
+ }
+ >
+ Primary
+
+ onFilter(InviteFilter.Unknown)}
+ before={isUnknown && }
+ after={
+ unknownInvites.length > 0 && (
+
+ {unknownInvites.length}
+
+ )
+ }
+ >
+ Public
+
+ onFilter(InviteFilter.Spam)}
+ before={isSpam && }
+ after={
+ spamInvites.length > 0 && (
+
+ {spamInvites.length}
+
+ )
+ }
+ >
+ Spam
+
+
+ );
+}
+
+type KnownInvitesProps = {
+ invites: InviteData[];
+ handleNavigate: NavigateHandler;
+ compact: boolean;
+};
+function KnownInvites({ invites, handleNavigate, compact }: KnownInvitesProps) {
+ return (
+
+ Primary
+ {invites.length > 0 ? (
+
+ {invites.map((invite) => (
+
+ ))}
+
+ ) : (
+
+
+ }
+ title="No Invites"
+ subTitle="When someone you share a room with sends you an invite, it’ll show up here."
+ />
+
+
+ )}
+
+ );
+}
+
+type UnknownInvitesProps = {
+ invites: InviteData[];
+ handleNavigate: NavigateHandler;
+ compact: boolean;
+};
+function UnknownInvites({ invites, handleNavigate, compact }: UnknownInvitesProps) {
+ 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 (
+
+
+ Public
+
+ }
+ disabled={declining}
+ radii="Pill"
+ >
+ Decline All
+
+
+
+ {invites.length > 0 ? (
+
+ {invites.map((invite) => (
+
+ ))}
+
+ ) : (
+
+
+ }
+ title="No Invites"
+ subTitle="Invites from people outside your rooms will appear here."
+ />
+
+
+ )}
+
+ );
+}
+
+type SpamInvitesProps = {
+ invites: InviteData[];
+ handleNavigate: NavigateHandler;
+ compact: boolean;
+};
+function SpamInvites({ invites, handleNavigate, compact }: SpamInvitesProps) {
+ 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 (
+
+ Spam
+ {invites.length > 0 ? (
+
+
+
+ }
+ title={`${invites.length} Spam Invites`}
+ subTitle="Some of the following invites may contain harmful content or have been sent by banned users."
+ >
+
+ }
+ disabled={loading}
+ >
+
+ Decline All
+
+
+ {reportRoomSupported && reportAllStatus.status !== AsyncStatus.Success && (
+ }
+ disabled={loading}
+ >
+
+ Report All
+
+
+ )}
+ {unignoredUsers.length > 0 && (
+ }
+ >
+
+ Block All
+
+
+ )}
+
+
+
+
+
+ }
+ onClick={() => setShowInvites(!showInvites)}
+ >
+ {showInvites ? 'Hide All' : 'View All'}
+
+
+
+
+ {showInvites &&
+ invites.map((invite) => (
+
+ ))}
+
+ ) : (
+
+
+ }
+ title="No Spam Invites"
+ subTitle="Invites detected as spam appear here."
+ />
+
+
+ )}
+
+ );
+}
+
export function Invites() {
const mx = useMatrixClient();
- const userId = mx.getUserId()!;
- const mDirects = useAtomValue(mDirectAtom);
- const directInvites = useDirectInvites(mx, allInvitesAtom, mDirects);
- const spaceInvites = useSpaceInvites(mx, allInvitesAtom);
- const roomInvites = useRoomInvites(mx, allInvitesAtom, mDirects);
+ const useAuthentication = useMediaAuthentication();
+ const { navigateRoom, navigateSpace } = useRoomNavigate();
+ const allRooms = useAtomValue(allRoomsAtom);
+ const allInviteIds = useAtomValue(allInvitesAtom);
+
+ const [filter, setFilter] = useState(InviteFilter.Known);
+
+ const invitesData = allInviteIds
+ .map((inviteId) => mx.getRoom(inviteId))
+ .filter((inviteRoom) => !!inviteRoom)
+ .map((inviteRoom) => makeInviteData(mx, inviteRoom, useAuthentication));
+
+ const [knownInvites, unknownInvites, spamInvites] = useMemo(() => {
+ const known: InviteData[] = [];
+ const unknown: InviteData[] = [];
+ const spam: InviteData[] = [];
+ invitesData.forEach((invite) => {
+ if (hasBadWords(invite) || bannedInRooms(mx, allRooms, invite.senderId)) {
+ spam.push(invite);
+ return;
+ }
+
+ if (getCommonRooms(mx, allRooms, invite.senderId).length === 0) {
+ unknown.push(invite);
+ return;
+ }
+
+ known.push(invite);
+ });
+
+ return [known, unknown, spam];
+ }, [mx, allRooms, invitesData]);
+
const containerRef = useRef(null);
const [compact, setCompact] = useState(document.body.clientWidth <= COMPACT_CARD_WIDTH);
useElementSizeObserver(
@@ -212,21 +669,12 @@ export function Invites() {
);
const screenSize = useScreenSizeContext();
- const { navigateRoom, navigateSpace } = useRoomNavigate();
-
- const renderInvite = (roomId: string, direct: boolean, handleNavigate: (rId: string) => void) => {
- const room = mx.getRoom(roomId);
- if (!room) return null;
- return (
-
- );
+ const handleNavigate = (roomId: string, space: boolean) => {
+ if (space) {
+ navigateSpace(roomId);
+ return;
+ }
+ navigateRoom(roomId);
};
return (
@@ -247,7 +695,7 @@ export function Invites() {
{screenSize !== ScreenSize.Mobile && }
- Invitations
+ Invites
@@ -258,47 +706,40 @@ export function Invites() {
- {directInvites.length > 0 && (
-
- Direct Messages
-
- {directInvites.map((roomId) => renderInvite(roomId, true, navigateRoom))}
-
-
+
+
+ Filter
+
+
+ {filter === InviteFilter.Known && (
+
)}
- {spaceInvites.length > 0 && (
-
- Spaces
-
- {spaceInvites.map((roomId) => renderInvite(roomId, false, navigateSpace))}
-
-
+
+ {filter === InviteFilter.Unknown && (
+
)}
- {roomInvites.length > 0 && (
-
- Rooms
-
- {roomInvites.map((roomId) => renderInvite(roomId, false, navigateRoom))}
-
-
+
+ {filter === InviteFilter.Spam && (
+
)}
- {directInvites.length === 0 &&
- spaceInvites.length === 0 &&
- roomInvites.length === 0 && (
-
-
- No Pending Invitations
-
- You don't have any new pending invitations to display yet.
-
-
-
- )}
diff --git a/src/app/plugins/bad-words.ts b/src/app/plugins/bad-words.ts
new file mode 100644
index 0000000..a7ca468
--- /dev/null
+++ b/src/app/plugins/bad-words.ts
@@ -0,0 +1,15 @@
+import * as badWords from 'badwords-list';
+import { sanitizeForRegex } from '../utils/regex';
+
+const additionalBadWords: string[] = ['Torture', 'T0rture'];
+
+const fullBadWordList = additionalBadWords.concat(
+ badWords.array.filter((word) => !additionalBadWords.includes(word))
+);
+
+export const BAD_WORDS_REGEX = new RegExp(
+ `(\\b|_)(${fullBadWordList.map((word) => sanitizeForRegex(word)).join('|')})(\\b|_)`,
+ 'g'
+);
+
+export const testBadWords = (str: string): boolean => !!str.toLowerCase().match(BAD_WORDS_REGEX);
diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts
index 75430c2..810f720 100644
--- a/src/app/utils/matrix.ts
+++ b/src/app/utils/matrix.ts
@@ -304,6 +304,14 @@ export const rateLimitedActions = async (
maxRetryCount?: number
) => {
let retryCount = 0;
+
+ let actionInterval = 0;
+
+ const sleepForMs = (ms: number) =>
+ new Promise((resolve) => {
+ setTimeout(resolve, ms);
+ });
+
const performAction = async (dataItem: T) => {
const [err] = await to(callback(dataItem));
@@ -312,10 +320,9 @@ export const rateLimitedActions = async (
return;
}
- const waitMS = err.getRetryAfterMs() ?? 200;
- await new Promise((resolve) => {
- setTimeout(resolve, waitMS);
- });
+ const waitMS = err.getRetryAfterMs() ?? 3000;
+ actionInterval = waitMS + 500;
+ await sleepForMs(waitMS);
retryCount += 1;
await performAction(dataItem);
@@ -327,5 +334,9 @@ export const rateLimitedActions = async (
retryCount = 0;
// eslint-disable-next-line no-await-in-loop
await performAction(dataItem);
+ if (actionInterval > 0) {
+ // eslint-disable-next-line no-await-in-loop
+ await sleepForMs(actionInterval);
+ }
}
};
diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts
index 3bf8cd5..79dcff9 100644
--- a/src/app/utils/room.ts
+++ b/src/app/utils/room.ts
@@ -19,6 +19,7 @@ import {
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
import { AccountDataEvent } from '../../types/matrix/accountData';
import {
+ Membership,
MessageEvent,
NotificationType,
RoomToParents,
@@ -171,7 +172,7 @@ export const getNotificationType = (mx: MatrixClient, roomId: string): Notificat
}
if (!roomPushRule) {
- const overrideRules = mx.getAccountData('m.push_rules')?.getContent()
+ const overrideRules = mx.getAccountData(EventType.PushRules)?.getContent()
?.global?.override;
if (!overrideRules) return NotificationType.Default;
@@ -443,3 +444,32 @@ export const getMentionContent = (userIds: string[], room: boolean): IMentions =
return mMentions;
};
+
+export const getCommonRooms = (
+ mx: MatrixClient,
+ rooms: string[],
+ otherUserId: string
+): string[] => {
+ const commonRooms: string[] = [];
+
+ rooms.forEach((roomId) => {
+ const room = mx.getRoom(roomId);
+ if (!room || room.getMyMembership() !== Membership.Join) return;
+
+ const common = room.hasMembershipState(otherUserId, Membership.Join);
+ if (common) {
+ commonRooms.push(roomId);
+ }
+ });
+
+ return commonRooms;
+};
+
+export const bannedInRooms = (mx: MatrixClient, rooms: string[], otherUserId: string): boolean =>
+ rooms.some((roomId) => {
+ const room = mx.getRoom(roomId);
+ if (!room || room.getMyMembership() !== Membership.Join) return false;
+
+ const banned = room.hasMembershipState(otherUserId, Membership.Ban);
+ return banned;
+ });