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, - }} - > - -
- - Remove Avatar - - setAlertRemove(false)} radii="300"> - - -
- - - Are you sure you want to remove profile avatar? - - - -
-
-
-
-
- ); -} - -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, + }} + > + +
+ + Remove Avatar + + setAlertRemove(false)} radii="300"> + + +
+ + + Are you sure you want to remove profile avatar? + + + +
+
+
+
+
+ ); +} + +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 + + + + 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." + > + + + {reportRoomSupported && reportAllStatus.status !== AsyncStatus.Success && ( + + )} + {unignoredUsers.length > 0 && ( + + )} + + + + + + + + + {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; + });