From b3b5c11b1b894cdaa1e7e355bdb58c126db864e4 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Sat, 23 Mar 2024 22:14:08 +0530 Subject: [PATCH] add invitations --- src/app/organisms/room/RoomView.jsx | 1 + src/app/pages/App.tsx | 4 +- src/app/pages/client/inbox/Invites.tsx | 266 +++++++++++++++++++++++++ src/app/pages/client/inbox/index.ts | 1 + src/app/state/room-list/utils.ts | 4 +- src/app/utils/matrix.ts | 78 ++++++++ 6 files changed, 351 insertions(+), 3 deletions(-) create mode 100644 src/app/pages/client/inbox/Invites.tsx diff --git a/src/app/organisms/room/RoomView.jsx b/src/app/organisms/room/RoomView.jsx index 9d97cb60..1c040313 100644 --- a/src/app/organisms/room/RoomView.jsx +++ b/src/app/organisms/room/RoomView.jsx @@ -35,6 +35,7 @@ function RoomView({ room, eventId }) { const canMessage = myUserId ? canSendEvent(EventType.RoomMessage, getPowerLevel(myUserId)) : false; + // FIXME: decide can message on membership based also useEffect(() => { const settingsToggle = (isVisible) => { diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx index 8406ef48..d3360370 100644 --- a/src/app/pages/App.tsx +++ b/src/app/pages/App.tsx @@ -44,7 +44,7 @@ import { RoomViewer } from '../organisms/room/Room'; import { Direct } from './client/direct'; import { SpaceViewer } from './client/space'; import { Explore, ExploreRedirect, FeaturedRooms, PublicRooms } from './client/explore'; -import { Notifications, Inbox, InboxRedirect } from './client/inbox'; +import { Notifications, Inbox, InboxRedirect, Invites } from './client/inbox'; import { setAfterLoginRedirectPath } from './afterLoginRedirectPath'; const queryClient = new QueryClient(); @@ -108,7 +108,7 @@ const createRouter = (clientConfig: ClientConfig) => { }> } /> } /> - invites

} /> + } />
diff --git a/src/app/pages/client/inbox/Invites.tsx b/src/app/pages/client/inbox/Invites.tsx new file mode 100644 index 00000000..c8760117 --- /dev/null +++ b/src/app/pages/client/inbox/Invites.tsx @@ -0,0 +1,266 @@ +import React, { useCallback, useRef, useState } from 'react'; +import { + Avatar, + Box, + Button, + Icon, + Icons, + Overlay, + OverlayBackdrop, + OverlayCenter, + Scroll, + Spinner, + Text, + color, + config, +} from 'folds'; +import { useAtomValue } from 'jotai'; +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 { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { allInvitesAtom } from '../../../state/room-list/inviteList'; +import { mDirectAtom } from '../../../state/mDirectList'; +import { SequenceCard } from '../../../components/sequence-card'; +import { getMemberDisplayName, getRoomAvatarUrl, isDirectInvite } from '../../../utils/room'; +import { nameInitials } from '../../../utils/common'; +import { RoomAvatar } from '../../../components/room-avatar'; +import { addRoomIdToMDirect, getMxIdLocalPart, guessDmRoomUserId } from '../../../utils/matrix'; +import { Time } from '../../../components/message'; +import { StateEvent } from '../../../../types/matrix/room'; +import { useStateEvent } from '../../../hooks/useStateEvent'; +import { useElementSizeObserver } from '../../../hooks/useElementSizeObserver'; +import { onEnterOrSpace } from '../../../utils/keyboard'; +import { RoomTopicViewer } from '../../../components/room-topic-viewer'; +import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; + +const COMPACT_CARD_WIDTH = 548; + +type InviteCardProps = { + room: Room; + userId: string; + compact?: boolean; + onNavigate: (roomId: string) => void; +}; +function InviteCard({ room, userId, compact, onNavigate }: InviteCardProps) { + const mx = useMatrixClient(); + const roomName = room.name || room.getCanonicalAlias() || room.roomId; + 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 topicEvent = useStateEvent(room, StateEvent.RoomTopic); + const topic = (topicEvent?.getContent().topic as string) || undefined; + + const [viewTopic, setViewTopic] = useState(false); + const closeTopic = () => setViewTopic(false); + const openTopic = () => setViewTopic(true); + + const [joinState, join] = useAsyncCallback( + useCallback(async () => { + const dmUserId = isDirectInvite(room, userId) ? guessDmRoomUserId(room, userId) : undefined; + + await mx.joinRoom(room.roomId); + if (dmUserId) { + await addRoomIdToMDirect(mx, room.roomId, dmUserId); + } + onNavigate(room.roomId); + }, [mx, room, userId, onNavigate]) + ); + const [leaveState, leave] = useAsyncCallback, MatrixError, []>( + useCallback(() => mx.leave(room.roomId), [mx, room]) + ); + + const joining = + joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success; + const leaving = + leaveState.status === AsyncStatus.Loading || leaveState.status === AsyncStatus.Success; + + return ( + + + + + Invited by {senderName} + + + + + + + + ( + + {nameInitials(roomName)} + + )} + /> + + + + + + {roomName} + + {topic && ( + + {topic} + + )} + }> + + + + + + + + {joinState.status === AsyncStatus.Error && ( + + {joinState.error.message} + + )} + {leaveState.status === AsyncStatus.Error && ( + + {leaveState.error.message} + + )} + + + + + + + + + ); +} + +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 containerRef = useRef(null); + const [compact, setCompact] = useState(document.body.clientWidth <= COMPACT_CARD_WIDTH); + useElementSizeObserver( + useCallback(() => containerRef.current, []), + useCallback((width) => setCompact(width <= COMPACT_CARD_WIDTH), []) + ); + + const { navigateRoom, navigateSpace } = useRoomNavigate(); + + const renderInvite = (roomId: string, handleNavigate: (rId: string) => void) => { + const room = mx.getRoom(roomId); + if (!room) return null; + return ( + + ); + }; + + return ( + + + + + + Invitations + + + + + + + + + {directInvites.length > 0 && ( + + Direct Messages + + {directInvites.map((roomId) => renderInvite(roomId, navigateRoom))} + + + )} + {spaceInvites.length > 0 && ( + + Spaces + + {spaceInvites.map((roomId) => renderInvite(roomId, navigateSpace))} + + + )} + {roomInvites.length > 0 && ( + + Rooms + + {roomInvites.map((roomId) => renderInvite(roomId, navigateRoom))} + + + )} + + + + + + + ); +} diff --git a/src/app/pages/client/inbox/index.ts b/src/app/pages/client/inbox/index.ts index 98ed96bd..c8036b47 100644 --- a/src/app/pages/client/inbox/index.ts +++ b/src/app/pages/client/inbox/index.ts @@ -1,2 +1,3 @@ export * from './Inbox'; export * from './Notifications'; +export * from './Invites'; diff --git a/src/app/state/room-list/utils.ts b/src/app/state/room-list/utils.ts index 0e2c4a9f..1ca7e7de 100644 --- a/src/app/state/room-list/utils.ts +++ b/src/app/state/room-list/utils.ts @@ -38,7 +38,9 @@ export const useBindRoomsWithMembershipsAtom = ( }; const handleMembershipChange = (room: Room) => { - if (!satisfyMembership(room)) { + if (satisfyMembership(room)) { + setRoomsAtom({ type: 'PUT', roomId: room.roomId }); + } else { setRoomsAtom({ type: 'DELETE', roomId: room.roomId }); } }; diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index f733de44..55e16a95 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -8,10 +8,12 @@ import { MatrixError, MatrixEvent, Room, + RoomMember, UploadProgress, UploadResponse, } from 'matrix-js-sdk'; import { IImageInfo, IThumbnailContent, IVideoInfo } from '../../types/matrix/common'; +import { AccountDataEvent } from '../../types/matrix/accountData'; export const matchMxId = (id: string): RegExpMatchArray | null => id.match(/^([@!$+#])(\S+):(\S+)$/); @@ -172,3 +174,79 @@ export const getDMRoomFor = (mx: MatrixClient, userId: string): Room | undefined return dmLikeRooms.find((room) => room.getMember(userId)); }; + +export const guessDmRoomUserId = (room: Room, myUserId: string): string => { + const getOldestMember = (members: RoomMember[]): RoomMember | undefined => { + let oldestMemberTs: number | undefined; + let oldestMember: RoomMember | undefined; + + const pickOldestMember = (member: RoomMember) => { + if (member.userId === myUserId) return; + + if ( + oldestMemberTs === undefined || + (member.events.member && member.events.member.getTs() < oldestMemberTs) + ) { + oldestMember = member; + oldestMemberTs = member.events.member?.getTs(); + } + }; + + members.forEach(pickOldestMember); + + return oldestMember; + }; + + // Pick the joined user who's been here longest (and isn't us), + const member = getOldestMember(room.getJoinedMembers()); + if (member) return member.userId; + + // if there are no joined members other than us, use the oldest member + const member1 = getOldestMember(room.currentState.getMembers()); + return member1?.userId ?? myUserId; +}; + +export const addRoomIdToMDirect = async ( + mx: MatrixClient, + roomId: string, + userId: string +): Promise => { + const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct); + const userIdToRoomIds: Record = mDirectsEvent?.getContent() ?? {}; + + // remove it from the lists of any others users + // (it can only be a DM room for one person) + Object.keys(userIdToRoomIds).forEach((targetUserId) => { + const roomIds = userIdToRoomIds[targetUserId]; + + if (targetUserId !== userId) { + const indexOfRoomId = roomIds.indexOf(roomId); + if (indexOfRoomId > -1) { + roomIds.splice(indexOfRoomId, 1); + } + } + }); + + const roomIds = userIdToRoomIds[userId] || []; + if (roomIds.indexOf(roomId) === -1) { + roomIds.push(roomId); + } + userIdToRoomIds[userId] = roomIds; + + await mx.setAccountData(AccountDataEvent.Direct, userIdToRoomIds); +}; + +export const removeRoomIdFromMDirect = async (mx: MatrixClient, roomId: string): Promise => { + const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct); + const userIdToRoomIds: Record = mDirectsEvent?.getContent() ?? {}; + + Object.keys(userIdToRoomIds).forEach((targetUserId) => { + const roomIds = userIdToRoomIds[targetUserId]; + const indexOfRoomId = roomIds.indexOf(roomId); + if (indexOfRoomId > -1) { + roomIds.splice(indexOfRoomId, 1); + } + }); + + await mx.setAccountData(AccountDataEvent.Direct, userIdToRoomIds); +};