From 958ae8945d6f3e039e82471f594dd83f23a05e64 Mon Sep 17 00:00:00 2001 From: Krishan <33421343+kfiven@users.noreply.github.com> Date: Mon, 29 Sep 2025 14:34:38 +1000 Subject: [PATCH 001/206] Release v4.10.1 (#2495) --- package-lock.json | 4 ++-- package.json | 2 +- src/app/features/settings/about/About.tsx | 2 +- src/app/pages/auth/AuthFooter.tsx | 2 +- src/app/pages/client/WelcomePage.tsx | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 39cc6c22..d032ace2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cinny", - "version": "4.10.0", + "version": "4.10.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cinny", - "version": "4.10.0", + "version": "4.10.1", "license": "AGPL-3.0-only", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "1.1.6", diff --git a/package.json b/package.json index 495d34f5..577e129d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cinny", - "version": "4.10.0", + "version": "4.10.1", "description": "Yet another matrix client", "main": "index.js", "type": "module", diff --git a/src/app/features/settings/about/About.tsx b/src/app/features/settings/about/About.tsx index 4fba564e..295fc3fe 100644 --- a/src/app/features/settings/about/About.tsx +++ b/src/app/features/settings/about/About.tsx @@ -46,7 +46,7 @@ export function About({ requestClose }: AboutProps) { Cinny - v4.10.0 + v4.10.1 Yet another matrix client. diff --git a/src/app/pages/auth/AuthFooter.tsx b/src/app/pages/auth/AuthFooter.tsx index 25c70e6e..6ac0d4e9 100644 --- a/src/app/pages/auth/AuthFooter.tsx +++ b/src/app/pages/auth/AuthFooter.tsx @@ -15,7 +15,7 @@ export function AuthFooter() { target="_blank" rel="noreferrer" > - v4.10.0 + v4.10.1 Twitter diff --git a/src/app/pages/client/WelcomePage.tsx b/src/app/pages/client/WelcomePage.tsx index 37ca10fa..4b6dd992 100644 --- a/src/app/pages/client/WelcomePage.tsx +++ b/src/app/pages/client/WelcomePage.tsx @@ -24,7 +24,7 @@ export function WelcomePage() { target="_blank" rel="noreferrer noopener" > - v4.10.0 + v4.10.1 } From e13d97aa989fed1271a8896ec1fa2f1dd07f7038 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Wed, 15 Oct 2025 17:27:11 +1100 Subject: [PATCH 002/206] Fix member are not sorted correctly after last js-sdk update (#2504) --- src/app/features/common-settings/members/Members.tsx | 5 +++-- src/app/features/room/MembersDrawer.tsx | 5 +++-- src/app/hooks/useMemberSort.ts | 7 +++++-- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/app/features/common-settings/members/Members.tsx b/src/app/features/common-settings/members/Members.tsx index 9940a751..15cd1d08 100644 --- a/src/app/features/common-settings/members/Members.tsx +++ b/src/app/features/common-settings/members/Members.tsx @@ -27,7 +27,7 @@ import { Page, PageContent, PageHeader } from '../../../components/page'; import { useRoom } from '../../../hooks/useRoom'; import { useRoomMembers } from '../../../hooks/useRoomMembers'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { usePowerLevels } from '../../../hooks/usePowerLevels'; +import { useGetMemberPowerLevel, usePowerLevels } from '../../../hooks/usePowerLevels'; import { VirtualTile } from '../../../components/virtualizer'; import { MemberTile } from '../../../components/member-tile'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; @@ -87,12 +87,13 @@ export function Members({ requestClose }: MembersProps) { const powerLevels = usePowerLevels(room); const creators = useRoomCreators(room); const getPowerTag = useGetMemberPowerTag(room, creators, powerLevels); + const getPowerLevel = useGetMemberPowerLevel(powerLevels); const [membershipFilterIndex, setMembershipFilterIndex] = useState(0); const [sortFilterIndex, setSortFilterIndex] = useSetting(settingsAtom, 'memberSortFilterIndex'); const membershipFilter = useMembershipFilter(membershipFilterIndex, useMembershipFilterMenu()); const memberSort = useMemberSort(sortFilterIndex, useMemberSortMenu()); - const memberPowerSort = useMemberPowerSort(creators); + const memberPowerSort = useMemberPowerSort(creators, getPowerLevel); const scrollRef = useRef(null); const searchInputRef = useRef(null); diff --git a/src/app/features/room/MembersDrawer.tsx b/src/app/features/room/MembersDrawer.tsx index 46d2238a..d9205076 100644 --- a/src/app/features/room/MembersDrawer.tsx +++ b/src/app/features/room/MembersDrawer.tsx @@ -51,7 +51,7 @@ import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMembershipFilter, useMembershipFilterMenu } from '../../hooks/useMemberFilter'; import { useMemberPowerSort, useMemberSort, useMemberSortMenu } from '../../hooks/useMemberSort'; -import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; +import { useGetMemberPowerLevel, usePowerLevelsContext } from '../../hooks/usePowerLevels'; import { MembershipFilterMenu } from '../../components/MembershipFilterMenu'; import { MemberSortMenu } from '../../components/MemberSortMenu'; import { useOpenUserRoomProfile, useUserRoomProfileState } from '../../state/hooks/userRoomProfile'; @@ -185,6 +185,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) { const powerLevels = usePowerLevelsContext(); const creators = useRoomCreators(room); const getPowerTag = useGetMemberPowerTag(room, creators, powerLevels); + const getPowerLevel = useGetMemberPowerLevel(powerLevels); const fetchingMembers = members.length < room.getJoinedMemberCount(); const openUserRoomProfile = useOpenUserRoomProfile(); @@ -198,7 +199,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) { const membershipFilter = useMembershipFilter(membershipFilterIndex, membershipFilterMenu); const memberSort = useMemberSort(sortFilterIndex, sortFilterMenu); - const memberPowerSort = useMemberPowerSort(creators); + const memberPowerSort = useMemberPowerSort(creators, getPowerLevel); const typingMembers = useRoomTypingMember(room.roomId); diff --git a/src/app/hooks/useMemberSort.ts b/src/app/hooks/useMemberSort.ts index d8e403c5..48d2f605 100644 --- a/src/app/hooks/useMemberSort.ts +++ b/src/app/hooks/useMemberSort.ts @@ -47,7 +47,10 @@ export const useMemberSort = (index: number, memberSort: MemberSortItem[]): Memb return item; }; -export const useMemberPowerSort = (creators: Set): MemberSortFn => { +export const useMemberPowerSort = ( + creators: Set, + getPowerLevel: (userId: string) => number +): MemberSortFn => { const sort: MemberSortFn = useCallback( (a, b) => { if (creators.has(a.userId) && creators.has(b.userId)) { @@ -56,7 +59,7 @@ export const useMemberPowerSort = (creators: Set): MemberSortFn => { if (creators.has(a.userId)) return -1; if (creators.has(b.userId)) return 1; - return b.powerLevel - a.powerLevel; + return getPowerLevel(b.userId) - getPowerLevel(a.userId); }, [creators] ); From 46c02b89deda5a766f3b34ccad0c1eb0dc58d029 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Wed, 15 Oct 2025 17:30:03 +1100 Subject: [PATCH 003/206] Update folds to fix broken scrollbar color (#2505) --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index d032ace2..6db1e02d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "emojibase-data": "15.3.2", "file-saver": "2.0.5", "focus-trap-react": "10.0.2", - "folds": "2.3.0", + "folds": "2.4.0", "html-dom-parser": "4.0.0", "html-react-parser": "4.2.0", "i18next": "23.12.2", @@ -7157,9 +7157,9 @@ } }, "node_modules/folds": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/folds/-/folds-2.3.0.tgz", - "integrity": "sha512-1KoM21jrg5daxvKrmSY0V04wa946KlNT0z6h017Rsnw2fdtNC6J0f34Ce5GF46Tzi00gZ/7SvCDXMzW/7e5s0w==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/folds/-/folds-2.4.0.tgz", + "integrity": "sha512-Q5xCmvU3SIM8etQ9qLF6Y5Jtv01c9JpG3QcnF+Z3nlbMvtktfE13Pj7p0XgSPBcA3OuoU0zXiRwiTlMcbU7KhA==", "license": "Apache-2.0", "peerDependencies": { "@vanilla-extract/css": "1.9.2", diff --git a/package.json b/package.json index 577e129d..33fe68c1 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "emojibase-data": "15.3.2", "file-saver": "2.0.5", "focus-trap-react": "10.0.2", - "folds": "2.3.0", + "folds": "2.4.0", "html-dom-parser": "4.0.0", "html-react-parser": "4.2.0", "i18next": "23.12.2", From c88cb4bca93e3a376836b8880e5e4c1fa5c7eba2 Mon Sep 17 00:00:00 2001 From: Krishan <33421343+kfiven@users.noreply.github.com> Date: Wed, 5 Nov 2025 17:49:56 +1100 Subject: [PATCH 004/206] Release v4.10.2 (#2528) --- package-lock.json | 4 ++-- package.json | 2 +- src/app/features/settings/about/About.tsx | 2 +- src/app/pages/auth/AuthFooter.tsx | 2 +- src/app/pages/client/WelcomePage.tsx | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6db1e02d..158a6112 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cinny", - "version": "4.10.1", + "version": "4.10.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cinny", - "version": "4.10.1", + "version": "4.10.2", "license": "AGPL-3.0-only", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "1.1.6", diff --git a/package.json b/package.json index 33fe68c1..7316dcce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cinny", - "version": "4.10.1", + "version": "4.10.2", "description": "Yet another matrix client", "main": "index.js", "type": "module", diff --git a/src/app/features/settings/about/About.tsx b/src/app/features/settings/about/About.tsx index 295fc3fe..f2cabf1d 100644 --- a/src/app/features/settings/about/About.tsx +++ b/src/app/features/settings/about/About.tsx @@ -46,7 +46,7 @@ export function About({ requestClose }: AboutProps) { Cinny - v4.10.1 + v4.10.2 Yet another matrix client. diff --git a/src/app/pages/auth/AuthFooter.tsx b/src/app/pages/auth/AuthFooter.tsx index 6ac0d4e9..d53ef9a6 100644 --- a/src/app/pages/auth/AuthFooter.tsx +++ b/src/app/pages/auth/AuthFooter.tsx @@ -15,7 +15,7 @@ export function AuthFooter() { target="_blank" rel="noreferrer" > - v4.10.1 + v4.10.2 Twitter diff --git a/src/app/pages/client/WelcomePage.tsx b/src/app/pages/client/WelcomePage.tsx index 4b6dd992..6eb33a16 100644 --- a/src/app/pages/client/WelcomePage.tsx +++ b/src/app/pages/client/WelcomePage.tsx @@ -24,7 +24,7 @@ export function WelcomePage() { target="_blank" rel="noreferrer noopener" > - v4.10.1 + v4.10.2 } From 0f61f2f3285371d13ec61591554d616f21c1e3f2 Mon Sep 17 00:00:00 2001 From: willow <42willow@pm.me> Date: Thu, 27 Nov 2025 16:01:40 +1100 Subject: [PATCH 005/206] Fix typo: change "Advance Options" to "Advanced Options" (#2537) --- src/app/features/create-room/CreateRoom.tsx | 2 +- src/app/features/create-space/CreateSpace.tsx | 2 +- src/app/features/room-settings/general/General.tsx | 2 +- src/app/features/space-settings/general/General.tsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/features/create-room/CreateRoom.tsx b/src/app/features/create-room/CreateRoom.tsx index 6ad469c9..05c5c2e3 100644 --- a/src/app/features/create-room/CreateRoom.tsx +++ b/src/app/features/create-room/CreateRoom.tsx @@ -183,7 +183,7 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP onClick={() => setAdvance(!advance)} type="button" > - Advance Options + Advanced Options diff --git a/src/app/features/create-space/CreateSpace.tsx b/src/app/features/create-space/CreateSpace.tsx index b1f9f649..530145af 100644 --- a/src/app/features/create-space/CreateSpace.tsx +++ b/src/app/features/create-space/CreateSpace.tsx @@ -184,7 +184,7 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor onClick={() => setAdvance(!advance)} type="button" > - Advance Options + Advanced Options diff --git a/src/app/features/room-settings/general/General.tsx b/src/app/features/room-settings/general/General.tsx index d9c16c90..15157304 100644 --- a/src/app/features/room-settings/general/General.tsx +++ b/src/app/features/room-settings/general/General.tsx @@ -59,7 +59,7 @@ export function General({ requestClose }: GeneralProps) { - Advance Options + Advanced Options diff --git a/src/app/features/space-settings/general/General.tsx b/src/app/features/space-settings/general/General.tsx index 641bfa7a..72040632 100644 --- a/src/app/features/space-settings/general/General.tsx +++ b/src/app/features/space-settings/general/General.tsx @@ -55,7 +55,7 @@ export function General({ requestClose }: GeneralProps) { - Advance Options + Advanced Options From 17d4bceb429d607edbc416d6aa911c3fae5f01c9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Nov 2025 16:16:20 +1100 Subject: [PATCH 006/206] Bump nginx from 1.29.1-alpine to 1.29.3-alpine (#2525) Bumps nginx from 1.29.1-alpine to 1.29.3-alpine. --- updated-dependencies: - dependency-name: nginx dependency-version: 1.29.3-alpine dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 46d0db6e..6489cb86 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ RUN npm run build ## App -FROM nginx:1.29.1-alpine +FROM nginx:1.29.3-alpine COPY --from=builder /src/dist /app COPY --from=builder /src/docker-nginx.conf /etc/nginx/conf.d/default.conf From 655c1c9aff6e1a31a1a632bd4fce4b14bfbd4779 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Nov 2025 16:30:39 +1100 Subject: [PATCH 007/206] Bump docker/login-action from 3.5.0 to 3.6.0 (#2496) Bumps [docker/login-action](https://github.com/docker/login-action) from 3.5.0 to 3.6.0. - [Release notes](https://github.com/docker/login-action/releases) - [Commits](https://github.com/docker/login-action/compare/v3.5.0...v3.6.0) --- updated-dependencies: - dependency-name: docker/login-action dependency-version: 3.6.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/prod-deploy.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/prod-deploy.yml b/.github/workflows/prod-deploy.yml index 7b58f56f..fe8a722f 100644 --- a/.github/workflows/prod-deploy.yml +++ b/.github/workflows/prod-deploy.yml @@ -72,12 +72,12 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3.11.1 - name: Login to Docker Hub - uses: docker/login-action@v3.5.0 + uses: docker/login-action@v3.6.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Login to the Container registry - uses: docker/login-action@v3.5.0 + uses: docker/login-action@v3.6.0 with: registry: ghcr.io username: ${{ github.actor }} From 073a9f5786d676b9e98b98bd3f03e49d0324bb3b Mon Sep 17 00:00:00 2001 From: Santhoshkumar044 Date: Mon, 12 Jan 2026 17:51:00 +0530 Subject: [PATCH 008/206] Fix room alias mention triggering room-wide notifications (#2562) * fix: prevent room alias mentions from triggering @room notifications * fix: Simplify room mention to exact match on @room --- src/app/components/editor/output.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/components/editor/output.ts b/src/app/components/editor/output.ts index dbdd51f3..5310012b 100644 --- a/src/app/components/editor/output.ts +++ b/src/app/components/editor/output.ts @@ -212,9 +212,10 @@ export const getMentions = (mx: MatrixClient, roomId: string, editor: Editor): M if (node.type === BlockType.CodeBlock) return; if (node.type === BlockType.Mention) { - if (node.id === getCanonicalAliasOrRoomId(mx, roomId)) { + if (node.name === '@room') { mentionData.room = true; } + if (isUserId(node.id) && node.id !== mx.getUserId()) { mentionData.users.add(node.id); } From 8ce33ee6ff6a9ec76dea47e458d4a24f50ffbe5e Mon Sep 17 00:00:00 2001 From: Zach Date: Wed, 11 Feb 2026 17:39:58 -0600 Subject: [PATCH 009/206] Replace envs.net with unredacted.org in config (#2601) * Replace 'envs.net' with 'unredacted.org' in config https://envs.net/ is shutting down their Matrix server * Update defaultHomeserver and reorder servers list * Remove 'monero.social' from homeserver list --- config.json | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/config.json b/config.json index de6015a1..644f0eb6 100644 --- a/config.json +++ b/config.json @@ -1,11 +1,10 @@ { - "defaultHomeserver": 2, + "defaultHomeserver": 1, "homeserverList": [ "converser.eu", - "envs.net", "matrix.org", - "monero.social", "mozilla.org", + "unredacted.org", "xmr.se" ], "allowCustomHomeservers": true, @@ -15,7 +14,7 @@ "spaces": [ "#cinny-space:matrix.org", "#community:matrix.org", - "#space:envs.net", + "#space:unredacted.org", "#science-space:matrix.org", "#libregaming-games:tchncs.de", "#mathematics-on:matrix.org" @@ -28,7 +27,7 @@ "#PrivSec.dev:arcticfoxes.net", "#disroot:aria-net.org" ], - "servers": ["envs.net", "matrix.org", "monero.social", "mozilla.org"] + "servers": [ "matrix.org", "mozilla.org", "unredacted.org" ] }, "hashRouter": { From ca82aa283a43faf65b43bcd150e156d4fe615581 Mon Sep 17 00:00:00 2001 From: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com> Date: Thu, 12 Feb 2026 06:27:17 +0100 Subject: [PATCH 010/206] Hide "Delete Message" if it is forbidden (#2602) hide "Delete Message" if it is forbidden --- src/app/features/room/RoomTimeline.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 2281b59d..25061dac 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -471,6 +471,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli const permissions = useRoomPermissions(creators, powerLevels); const canRedact = permissions.action('redact', mx.getSafeUserId()); + const canDeleteOwn = permissions.event(MessageEvent.RoomRedaction, mx.getSafeUserId()); const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId()); const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId()); const [editId, setEditId] = useState(); @@ -1047,7 +1048,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli collapse={collapse} highlight={highlighted} edit={editId === mEventId} - canDelete={canRedact || mEvent.getSender() === mx.getUserId()} + canDelete={canRedact || canDeleteOwn} canSendReaction={canSendReaction} canPinEvent={canPinEvent} imagePackRooms={imagePackRooms} @@ -1129,7 +1130,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli collapse={collapse} highlight={highlighted} edit={editId === mEventId} - canDelete={canRedact || mEvent.getSender() === mx.getUserId()} + canDelete={canRedact || canDeleteOwn} canSendReaction={canSendReaction} canPinEvent={canPinEvent} imagePackRooms={imagePackRooms} @@ -1247,7 +1248,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli messageLayout={messageLayout} collapse={collapse} highlight={highlighted} - canDelete={canRedact || mEvent.getSender() === mx.getUserId()} + canDelete={canRedact || canDeleteOwn} canSendReaction={canSendReaction} canPinEvent={canPinEvent} imagePackRooms={imagePackRooms} From 83e5125b37d3c3ea84918291f2c96c96f2d33f6b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 12 Feb 2026 16:56:47 +1100 Subject: [PATCH 011/206] fix(deps): update dependency folds to v2.5.0 (#2606) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 158a6112..4c12e9db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "emojibase-data": "15.3.2", "file-saver": "2.0.5", "focus-trap-react": "10.0.2", - "folds": "2.4.0", + "folds": "2.5.0", "html-dom-parser": "4.0.0", "html-react-parser": "4.2.0", "i18next": "23.12.2", @@ -7157,9 +7157,9 @@ } }, "node_modules/folds": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/folds/-/folds-2.4.0.tgz", - "integrity": "sha512-Q5xCmvU3SIM8etQ9qLF6Y5Jtv01c9JpG3QcnF+Z3nlbMvtktfE13Pj7p0XgSPBcA3OuoU0zXiRwiTlMcbU7KhA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/folds/-/folds-2.5.0.tgz", + "integrity": "sha512-UJhvXAQ1XnZ9w10KJwSW+frvzzWE/zcF0dH3fDVCD70RFHAxwEi0UkkVS8CaZGxZF2Wvt3qTJyTS5LW3LwwUAw==", "license": "Apache-2.0", "peerDependencies": { "@vanilla-extract/css": "1.9.2", diff --git a/package.json b/package.json index 7316dcce..00535bd8 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "emojibase-data": "15.3.2", "file-saver": "2.0.5", "focus-trap-react": "10.0.2", - "folds": "2.4.0", + "folds": "2.5.0", "html-dom-parser": "4.0.0", "html-react-parser": "4.2.0", "i18next": "23.12.2", From 1ce6ca2b07ceeff5e0def4cb531d864dc672839f Mon Sep 17 00:00:00 2001 From: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com> Date: Thu, 12 Feb 2026 11:40:11 +0100 Subject: [PATCH 012/206] Re-add `mEvent.getSender() === mx.getUserId()` check for deletion of messages (#2607) * hide "Delete Message" if it is forbidden * Fix the stuff I broke :/ --- src/app/features/room/RoomTimeline.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 25061dac..d1678b65 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -1048,7 +1048,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli collapse={collapse} highlight={highlighted} edit={editId === mEventId} - canDelete={canRedact || canDeleteOwn} + canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())} canSendReaction={canSendReaction} canPinEvent={canPinEvent} imagePackRooms={imagePackRooms} @@ -1130,7 +1130,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli collapse={collapse} highlight={highlighted} edit={editId === mEventId} - canDelete={canRedact || canDeleteOwn} + canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())} canSendReaction={canSendReaction} canPinEvent={canPinEvent} imagePackRooms={imagePackRooms} @@ -1248,7 +1248,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli messageLayout={messageLayout} collapse={collapse} highlight={highlighted} - canDelete={canRedact || canDeleteOwn} + canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())} canSendReaction={canSendReaction} canPinEvent={canPinEvent} imagePackRooms={imagePackRooms} From fd37dfe3f9203cfb836b741b49ac232a6b3f890c Mon Sep 17 00:00:00 2001 From: Andrew Murphy Date: Thu, 12 Feb 2026 21:45:37 +1100 Subject: [PATCH 013/206] Fix muted rooms showing unread badges (#2581) fix: detect muted rooms with empty actions array The mute detection was checking for `actions[0] === "dont_notify"` but Cinny sets `actions: []` (empty array) when muting a room, which is the correct behavior per Matrix spec where empty actions means no notification. This caused muted rooms to still show unread badges and contribute to space badge counts. Fixes the isMutedRule check to handle both: - Empty actions array (current Matrix spec) - "dont_notify" string (deprecated but may exist in older rules) --- src/app/utils/room.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index b4bba2ad..c282a0a2 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -160,7 +160,8 @@ export const getOrphanParents = (roomToParents: RoomToParents, roomId: string): }; export const isMutedRule = (rule: IPushRule) => - rule.actions[0] === 'dont_notify' && rule.conditions?.[0]?.kind === 'event_match'; + // Check for empty actions (new spec) or dont_notify (deprecated) + (rule.actions.length === 0 || rule.actions[0] === 'dont_notify') && rule.conditions?.[0]?.kind === 'event_match'; export const findMutedRule = (overrideRules: IPushRule[], roomId: string) => overrideRules.find((rule) => rule.rule_id === roomId && isMutedRule(rule)); From 206a927f30f8b29eea4445f4f599b1f16f66b85e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 14 Feb 2026 17:10:43 +1100 Subject: [PATCH 014/206] fix(deps): update dependency react-router-dom to v6.30.3 (#2612) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 29 ++++++++++++++++------------- package.json | 2 +- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4c12e9db..823c38b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,7 +56,7 @@ "react-google-recaptcha": "2.1.0", "react-i18next": "15.0.0", "react-range": "1.8.14", - "react-router-dom": "6.20.0", + "react-router-dom": "6.30.3", "sanitize-html": "2.12.1", "slate": "0.112.0", "slate-dom": "0.112.2", @@ -3699,9 +3699,10 @@ } }, "node_modules/@remix-run/router": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.13.0.tgz", - "integrity": "sha512-5dMOnVnefRsl4uRnAdoWjtVTdh8e6aZqgM4puy9nmEADH72ck+uXwzpJLEKE9Q6F8ZljNewLgmTfkxUrBdv4WA==", + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", "engines": { "node": ">=14.0.0" } @@ -9605,11 +9606,12 @@ } }, "node_modules/react-router": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.20.0.tgz", - "integrity": "sha512-pVvzsSsgUxxtuNfTHC4IxjATs10UaAtvLGVSA1tbUE4GDaOSU1Esu2xF5nWLz7KPiMuW8BJWuPFdlGYJ7/rW0w==", + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", "dependencies": { - "@remix-run/router": "1.13.0" + "@remix-run/router": "1.23.2" }, "engines": { "node": ">=14.0.0" @@ -9619,12 +9621,13 @@ } }, "node_modules/react-router-dom": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.20.0.tgz", - "integrity": "sha512-CbcKjEyiSVpA6UtCHOIYLUYn/UJfwzp55va4yEfpk7JBN3GPqWfHrdLkAvNCcpXr8QoihcDMuk0dzWZxtlB/mQ==", + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", "dependencies": { - "@remix-run/router": "1.13.0", - "react-router": "6.20.0" + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" }, "engines": { "node": ">=14.0.0" diff --git a/package.json b/package.json index 00535bd8..9438330a 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "react-google-recaptcha": "2.1.0", "react-i18next": "15.0.0", "react-range": "1.8.14", - "react-router-dom": "6.20.0", + "react-router-dom": "6.30.3", "sanitize-html": "2.12.1", "slate": "0.112.0", "slate-dom": "0.112.2", From 074c5552943206ff47df0aa0f85b50b4d79af656 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Sat, 14 Feb 2026 11:41:36 +0530 Subject: [PATCH 015/206] Post session info to service worker instead of asking from sw (#2605) post session info to service worker instead of asking from sw on each request --- src/app/pages/Router.tsx | 5 ++- src/client/initMatrix.ts | 2 + src/index.tsx | 15 +++---- src/sw-session.ts | 10 +++++ src/sw.ts | 94 +++++++++++++++++++++++++++------------- 5 files changed, 84 insertions(+), 42 deletions(-) create mode 100644 src/sw-session.ts diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 18591f29..04d14a07 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -68,6 +68,7 @@ import { Create } from './client/create'; import { CreateSpaceModalRenderer } from '../features/create-space'; import { SearchModalRenderer } from '../features/search'; import { getFallbackSession } from '../state/sessions'; +import { pushSessionToSW } from '../../sw-session'; export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => { const { hashRouter } = clientConfig; @@ -106,7 +107,8 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) { - if (!getFallbackSession()) { + const session = getFallbackSession(); + if (!session) { const afterLoginPath = getAppPathFromHref( getOriginBaseUrl(hashRouter), window.location.href @@ -114,6 +116,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) if (afterLoginPath) setAfterLoginRedirectPath(afterLoginPath); return redirect(getLoginPath()); } + pushSessionToSW(session.baseUrl, session.accessToken); return null; }} element={ diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 487c3f13..498d4f75 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -2,6 +2,7 @@ import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from import { cryptoCallbacks } from './secretStorageKeys'; import { clearNavToActivePathStore } from '../app/state/navToActivePath'; +import { pushSessionToSW } from '../sw-session'; type Session = { baseUrl: string; @@ -53,6 +54,7 @@ export const clearCacheAndReload = async (mx: MatrixClient) => { }; export const logoutClient = async (mx: MatrixClient) => { + pushSessionToSW(); mx.stopClient(); try { await mx.logout(); diff --git a/src/index.tsx b/src/index.tsx index ddfd30b4..0019a224 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -15,6 +15,8 @@ import App from './app/pages/App'; // import i18n (needs to be bundled ;)) import './app/i18n'; +import { pushSessionToSW } from './sw-session'; +import { getFallbackSession } from './app/state/sessions'; document.body.classList.add(configClass, varsClass); @@ -25,16 +27,9 @@ if ('serviceWorker' in navigator) { ? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js` : `/dev-sw.js?dev-sw`; - navigator.serviceWorker.register(swUrl); - navigator.serviceWorker.addEventListener('message', (event) => { - if (event.data?.type === 'token' && event.data?.responseKey) { - // Get the token for SW. - const token = localStorage.getItem('cinny_access_token') ?? undefined; - event.source!.postMessage({ - responseKey: event.data.responseKey, - token, - }); - } + navigator.serviceWorker.register(swUrl).then(() => { + const session = getFallbackSession(); + pushSessionToSW(session?.baseUrl, session?.accessToken); }); } diff --git a/src/sw-session.ts b/src/sw-session.ts new file mode 100644 index 00000000..4b2ec055 --- /dev/null +++ b/src/sw-session.ts @@ -0,0 +1,10 @@ +export function pushSessionToSW(baseUrl?: string, accessToken?: string) { + if (!('serviceWorker' in navigator)) return; + if (!navigator.serviceWorker.controller) return; + + navigator.serviceWorker.controller.postMessage({ + type: 'setSession', + accessToken, + baseUrl, + }); +} diff --git a/src/sw.ts b/src/sw.ts index 2179dfcb..d4eebc02 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -3,22 +3,64 @@ export type {}; declare const self: ServiceWorkerGlobalScope; -async function askForAccessToken(client: Client): Promise { - return new Promise((resolve) => { - const responseKey = Math.random().toString(36); - const listener = (event: ExtendableMessageEvent) => { - if (event.data.responseKey !== responseKey) return; - resolve(event.data.token); - self.removeEventListener('message', listener); - }; - self.addEventListener('message', listener); - client.postMessage({ responseKey, type: 'token' }); +self.addEventListener('install', () => { + self.skipWaiting(); +}); + +self.addEventListener('activate', (event: ExtendableEvent) => { + event.waitUntil(self.clients.claim()); +}); + +type SessionInfo = { + accessToken: string; + baseUrl: string; +}; + +/** + * Store session per client (tab) + */ +const sessions = new Map(); + +async function cleanupDeadClients() { + const activeClients = await self.clients.matchAll(); + const activeIds = new Set(activeClients.map((c) => c.id)); + + Array.from(sessions.keys()).forEach((id) => { + if (!activeIds.has(id)) { + sessions.delete(id); + } }); } -function fetchConfig(token?: string): RequestInit | undefined { - if (!token) return undefined; +/** + * Receive session updates from clients + */ +self.addEventListener('message', (event: ExtendableMessageEvent) => { + const client = event.source as Client | null; + if (!client) return; + const { type, accessToken, baseUrl } = event.data || {}; + + if (type !== 'setSession') return; + + cleanupDeadClients(); + + if (typeof accessToken === 'string' && typeof baseUrl === 'string') { + sessions.set(client.id, { accessToken, baseUrl }); + } else { + // Logout or invalid session + sessions.delete(client.id); + } +}); + +function validMediaRequest(url: string, baseUrl: string): boolean { + const downloadUrl = new URL('/_matrix/client/v1/media/download', baseUrl); + const thumbnailUrl = new URL('/_matrix/client/v1/media/thumbnail', baseUrl); + + return url.startsWith(downloadUrl.href) || url.startsWith(thumbnailUrl.href); +} + +function fetchConfig(token: string): RequestInit { return { headers: { Authorization: `Bearer ${token}`, @@ -27,26 +69,16 @@ function fetchConfig(token?: string): RequestInit | undefined { }; } -self.addEventListener('activate', (event: ExtendableEvent) => { - event.waitUntil(clients.claim()); -}); - self.addEventListener('fetch', (event: FetchEvent) => { const { url, method } = event.request; - if (method !== 'GET') return; - if ( - !url.includes('/_matrix/client/v1/media/download') && - !url.includes('/_matrix/client/v1/media/thumbnail') - ) { - return; - } - event.respondWith( - (async (): Promise => { - const client = await self.clients.get(event.clientId); - let token: string | undefined; - if (client) token = await askForAccessToken(client); - return fetch(url, fetchConfig(token)); - })() - ); + if (method !== 'GET') return; + if (!event.clientId) return; + + const session = sessions.get(event.clientId); + if (!session) return; + + if (!validMediaRequest(url, session.baseUrl)) return; + + event.respondWith(fetch(url, fetchConfig(session.accessToken))); }); From 3522751a15ae194e1d9cf49512796ee5f4f3d74a Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Sat, 14 Feb 2026 11:42:28 +0530 Subject: [PATCH 016/206] Prevent invalid mxc from getting used (#2609) --- .../editor/autocomplete/EmoticonAutocomplete.tsx | 6 ++++-- src/app/components/emoji-board/EmojiBoard.tsx | 5 ++--- src/app/components/emoji-board/components/Item.tsx | 4 ++-- src/app/components/message/FileHeader.tsx | 3 ++- src/app/components/message/content/AudioContent.tsx | 3 ++- src/app/components/message/content/FileContent.tsx | 9 ++++++--- src/app/components/message/content/ImageContent.tsx | 3 ++- src/app/components/message/content/ThumbnailContent.tsx | 3 ++- src/app/components/message/content/VideoContent.tsx | 3 ++- 9 files changed, 24 insertions(+), 15 deletions(-) diff --git a/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx b/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx index cc0dff19..d358ff7d 100644 --- a/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx +++ b/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx @@ -88,6 +88,8 @@ export function EmoticonAutocomplete({ {autoCompleteEmoticon.map((emoticon) => { const isCustomEmoji = 'url' in emoticon; const key = isCustomEmoji ? emoticon.url : emoticon.unicode; + const customEmojiUrl = mxcUrlToHttp(mx, key, useAuthentication); + return ( handleAutocomplete(key, emoticon.shortcode)} before={ - isCustomEmoji ? ( + isCustomEmoji && customEmojiUrl ? ( diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx index 3db27e2a..d5a76c71 100644 --- a/src/app/components/emoji-board/EmojiBoard.tsx +++ b/src/app/components/emoji-board/EmojiBoard.tsx @@ -202,8 +202,7 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name; const url = - mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) || - pack.meta.avatar; + mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ?? undefined; return ( ); @@ -98,7 +98,7 @@ export function StickerItem({ mx, useAuthentication, image }: StickerItemProps) loading="lazy" className={css.StickerImg} alt={image.body || image.shortcode} - src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? image.url} + src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? ''} /> ); diff --git a/src/app/components/message/FileHeader.tsx b/src/app/components/message/FileHeader.tsx index 0248862d..2ffc9ec4 100644 --- a/src/app/components/message/FileHeader.tsx +++ b/src/app/components/message/FileHeader.tsx @@ -27,7 +27,8 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow const [downloadState, download] = useAsyncCallback( useCallback(async () => { - const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; + const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); + if (!mediaUrl) throw new Error('Invalid media URL'); const fileContent = encInfo ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) : await downloadMedia(mediaUrl); diff --git a/src/app/components/message/content/AudioContent.tsx b/src/app/components/message/content/AudioContent.tsx index 71551b12..478486a4 100644 --- a/src/app/components/message/content/AudioContent.tsx +++ b/src/app/components/message/content/AudioContent.tsx @@ -54,7 +54,8 @@ export function AudioContent({ const [srcState, loadSrc] = useAsyncCallback( useCallback(async () => { - const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; + const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); + if (!mediaUrl) throw new Error('Invalid media URL'); const fileContent = encInfo ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) : await downloadMedia(mediaUrl); diff --git a/src/app/components/message/content/FileContent.tsx b/src/app/components/message/content/FileContent.tsx index ad54c531..7e127f2a 100644 --- a/src/app/components/message/content/FileContent.tsx +++ b/src/app/components/message/content/FileContent.tsx @@ -86,7 +86,8 @@ export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: Rea const [textState, loadText] = useAsyncCallback( useCallback(async () => { - const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; + const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); + if (!mediaUrl) throw new Error('Invalid media URL'); const fileContent = encInfo ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) : await downloadMedia(mediaUrl); @@ -176,7 +177,8 @@ export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: Read const [pdfState, loadPdf] = useAsyncCallback( useCallback(async () => { - const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; + const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); + if (!mediaUrl) throw new Error('Invalid media URL'); const fileContent = encInfo ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) : await downloadMedia(mediaUrl); @@ -253,7 +255,8 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil const [downloadState, download] = useAsyncCallback( useCallback(async () => { - const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; + const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); + if (!mediaUrl) throw new Error('Invalid media URL'); const fileContent = encInfo ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) : await downloadMedia(mediaUrl); diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx index 84e3709e..4f1d9c75 100644 --- a/src/app/components/message/content/ImageContent.tsx +++ b/src/app/components/message/content/ImageContent.tsx @@ -87,7 +87,8 @@ export const ImageContent = as<'div', ImageContentProps>( const [srcState, loadSrc] = useAsyncCallback( useCallback(async () => { - const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; + const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); + if (!mediaUrl) throw new Error('Invalid media URL'); if (encInfo) { const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo) diff --git a/src/app/components/message/content/ThumbnailContent.tsx b/src/app/components/message/content/ThumbnailContent.tsx index 0746137c..aa9171aa 100644 --- a/src/app/components/message/content/ThumbnailContent.tsx +++ b/src/app/components/message/content/ThumbnailContent.tsx @@ -23,7 +23,8 @@ export function ThumbnailContent({ info, renderImage }: ThumbnailContentProps) { throw new Error('Failed to load thumbnail'); } - const mediaUrl = mxcUrlToHttp(mx, thumbMxcUrl, useAuthentication) ?? thumbMxcUrl; + const mediaUrl = mxcUrlToHttp(mx, thumbMxcUrl, useAuthentication); + if (!mediaUrl) throw new Error('Invalid media URL'); if (encInfo) { const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, thumbInfo.mimetype ?? FALLBACK_MIMETYPE, encInfo) diff --git a/src/app/components/message/content/VideoContent.tsx b/src/app/components/message/content/VideoContent.tsx index 52073ac1..b33ec272 100644 --- a/src/app/components/message/content/VideoContent.tsx +++ b/src/app/components/message/content/VideoContent.tsx @@ -81,7 +81,8 @@ export const VideoContent = as<'div', VideoContentProps>( const [srcState, loadSrc] = useAsyncCallback( useCallback(async () => { - const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; + const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); + if (!mediaUrl) throw new Error('Invalid media URL'); const fileContent = encInfo ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo) From 9d49418a1f81d3b368bba8e8454d33a9ee588770 Mon Sep 17 00:00:00 2001 From: Andrew Murphy Date: Sat, 14 Feb 2026 17:32:10 +1100 Subject: [PATCH 017/206] Set m.fully_read marker when marking rooms as read (#2587) Previously markAsRead() only sent m.read receipts via sendReadReceipt(). This meant the read position was not persisted across page refreshes, especially noticeable in bridged rooms. Now uses setRoomReadMarkers() which sets both: - m.fully_read marker (persistent read position) - m.read receipt Fixes issue where rooms would still show as unread after refresh. --- src/app/utils/notifications.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/app/utils/notifications.ts b/src/app/utils/notifications.ts index a23bd1a4..edab9196 100644 --- a/src/app/utils/notifications.ts +++ b/src/app/utils/notifications.ts @@ -1,6 +1,6 @@ import { MatrixClient, ReceiptType } from 'matrix-js-sdk'; -export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt: boolean) { +export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt?: boolean) { const room = mx.getRoom(roomId); if (!room) return; @@ -19,8 +19,15 @@ export async function markAsRead(mx: MatrixClient, roomId: string, privateReceip const latestEvent = getLatestValidEvent(); if (latestEvent === null) return; - await mx.sendReadReceipt( - latestEvent, - privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read + const latestEventId = latestEvent.getId(); + if (!latestEventId) return; + + // Set both the read receipt AND the fully_read marker + // The fully_read marker is what persists your read position across sessions + await mx.setRoomReadMarkers( + roomId, + latestEventId, // m.fully_read marker + latestEvent, // m.read receipt event + privateReceipt ? { receiptType: ReceiptType.ReadPrivate } : undefined ); } From 4ba7b9162da75615eb1c4850b8eb4de5c063ab55 Mon Sep 17 00:00:00 2001 From: Krishan <33421343+kfiven@users.noreply.github.com> Date: Mon, 16 Feb 2026 06:03:37 +1100 Subject: [PATCH 018/206] Revert "fix: set m.fully_read marker when marking rooms as read" (#2629) Revert "Set m.fully_read marker when marking rooms as read (#2587)" This reverts commit 9d49418a1f81d3b368bba8e8454d33a9ee588770. --- src/app/utils/notifications.ts | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/app/utils/notifications.ts b/src/app/utils/notifications.ts index edab9196..a23bd1a4 100644 --- a/src/app/utils/notifications.ts +++ b/src/app/utils/notifications.ts @@ -1,6 +1,6 @@ import { MatrixClient, ReceiptType } from 'matrix-js-sdk'; -export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt?: boolean) { +export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt: boolean) { const room = mx.getRoom(roomId); if (!room) return; @@ -19,15 +19,8 @@ export async function markAsRead(mx: MatrixClient, roomId: string, privateReceip const latestEvent = getLatestValidEvent(); if (latestEvent === null) return; - const latestEventId = latestEvent.getId(); - if (!latestEventId) return; - - // Set both the read receipt AND the fully_read marker - // The fully_read marker is what persists your read position across sessions - await mx.setRoomReadMarkers( - roomId, - latestEventId, // m.fully_read marker - latestEvent, // m.read receipt event - privateReceipt ? { receiptType: ReceiptType.ReadPrivate } : undefined + await mx.sendReadReceipt( + latestEvent, + privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read ); } From fbde1a20306f2a6f4d270aa94f673b3b5c96a615 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Mon, 16 Feb 2026 14:21:09 +0530 Subject: [PATCH 019/206] fix: image not loading on mobile after lock/unlock (#2631) image not loading on mobile after lock/unlock --- src/index.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/index.tsx b/src/index.tsx index 0019a224..71e723ab 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -27,10 +27,24 @@ if ('serviceWorker' in navigator) { ? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js` : `/dev-sw.js?dev-sw`; - navigator.serviceWorker.register(swUrl).then(() => { + const sendSessionToSW = () => { const session = getFallbackSession(); pushSessionToSW(session?.baseUrl, session?.accessToken); + }; + + navigator.serviceWorker.register(swUrl).then(sendSessionToSW); + navigator.serviceWorker.ready.then(sendSessionToSW); + window.addEventListener('load', sendSessionToSW); + + // When returning from background + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + sendSessionToSW(); + } }); + + // When restored from bfcache (important on iOS) + window.addEventListener('pageshow', sendSessionToSW); } const mountApp = () => { From d866c1b903cdba58804dc16e2a964e7a25965f18 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Mon, 16 Feb 2026 14:21:55 +0530 Subject: [PATCH 020/206] fix room back button not working after router update (#2630) --- src/app/components/BackRouteHandler.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/app/components/BackRouteHandler.tsx b/src/app/components/BackRouteHandler.tsx index fa3d7592..3b13e487 100644 --- a/src/app/components/BackRouteHandler.tsx +++ b/src/app/components/BackRouteHandler.tsx @@ -51,8 +51,12 @@ export function BackRouteHandler({ children }: BackRouteHandlerProps) { }, location.pathname ); - if (spaceMatch?.params.spaceIdOrAlias) { - navigate(getSpacePath(spaceMatch.params.spaceIdOrAlias)); + const encodedSpaceIdOrAlias = spaceMatch?.params.spaceIdOrAlias; + const decodedSpaceIdOrAlias = + encodedSpaceIdOrAlias && decodeURIComponent(encodedSpaceIdOrAlias); + + if (decodedSpaceIdOrAlias) { + navigate(getSpacePath(decodedSpaceIdOrAlias)); return; } if ( From 0f220f50d67a09ed401c37dabc3fe852a350ef47 Mon Sep 17 00:00:00 2001 From: Rin Date: Mon, 16 Feb 2026 15:54:05 +0700 Subject: [PATCH 021/206] fix: add noreferrer to sanitized links for improved privacy consistency (#2628) Enhance privacy by adding noreferrer to sanitized links --- src/app/utils/sanitize.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/utils/sanitize.ts b/src/app/utils/sanitize.ts index 985c47b1..28e0db59 100644 --- a/src/app/utils/sanitize.ts +++ b/src/app/utils/sanitize.ts @@ -100,7 +100,7 @@ const transformATag: Transformer = (tagName, attribs) => ({ tagName, attribs: { ...attribs, - rel: 'noopener', + rel: 'noreferrer noopener', target: '_blank', }, }); @@ -112,7 +112,7 @@ const transformImgTag: Transformer = (tagName, attribs) => { tagName: 'a', attribs: { href: src, - rel: 'noopener', + rel: 'noreferrer noopener', target: '_blank', }, text: attribs.alt || src, From 29ec172c8be7e72c1b2f0ef85bc212ac9c73231d Mon Sep 17 00:00:00 2001 From: Krishan <33421343+kfiven@users.noreply.github.com> Date: Mon, 16 Feb 2026 22:19:21 +1100 Subject: [PATCH 022/206] Release v4.10.3 (#2608) --- package-lock.json | 4 ++-- package.json | 2 +- src/app/features/settings/about/About.tsx | 2 +- src/app/pages/auth/AuthFooter.tsx | 2 +- src/app/pages/client/WelcomePage.tsx | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 823c38b1..582f02f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cinny", - "version": "4.10.2", + "version": "4.10.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cinny", - "version": "4.10.2", + "version": "4.10.3", "license": "AGPL-3.0-only", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "1.1.6", diff --git a/package.json b/package.json index 9438330a..601323ad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cinny", - "version": "4.10.2", + "version": "4.10.3", "description": "Yet another matrix client", "main": "index.js", "type": "module", diff --git a/src/app/features/settings/about/About.tsx b/src/app/features/settings/about/About.tsx index f2cabf1d..7cf121d5 100644 --- a/src/app/features/settings/about/About.tsx +++ b/src/app/features/settings/about/About.tsx @@ -46,7 +46,7 @@ export function About({ requestClose }: AboutProps) { Cinny - v4.10.2 + v4.10.3 Yet another matrix client. diff --git a/src/app/pages/auth/AuthFooter.tsx b/src/app/pages/auth/AuthFooter.tsx index d53ef9a6..124b0384 100644 --- a/src/app/pages/auth/AuthFooter.tsx +++ b/src/app/pages/auth/AuthFooter.tsx @@ -15,7 +15,7 @@ export function AuthFooter() { target="_blank" rel="noreferrer" > - v4.10.2 + v4.10.3 Twitter diff --git a/src/app/pages/client/WelcomePage.tsx b/src/app/pages/client/WelcomePage.tsx index 6eb33a16..969a5cd4 100644 --- a/src/app/pages/client/WelcomePage.tsx +++ b/src/app/pages/client/WelcomePage.tsx @@ -24,7 +24,7 @@ export function WelcomePage() { target="_blank" rel="noreferrer noopener" > - v4.10.2 + v4.10.3 } From 170f5cd4730333add2ed5c3b058a021f003ee898 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Sat, 21 Feb 2026 12:21:27 +0530 Subject: [PATCH 023/206] Request session info from sw if missing (#2664) * request session info from sw if missing * fix async session request in fetch * respond fetch synchronously and add early check for non media requests (#2670) * make sure we call respondWith synchronously * simplify isMediaRequest in sw * improve naming in sw * get back baseUrl check into validMediaRequest * pass original request into fetch in sw * extract mediaPath util and performs checks properly --------- Co-authored-by: mmmykhailo <35040944+mmmykhailo@users.noreply.github.com> --- src/app/pages/Router.tsx | 2 - src/index.tsx | 11 ++-- src/sw.ts | 128 ++++++++++++++++++++++++++++++--------- 3 files changed, 105 insertions(+), 36 deletions(-) diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 04d14a07..f8647d18 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -68,7 +68,6 @@ import { Create } from './client/create'; import { CreateSpaceModalRenderer } from '../features/create-space'; import { SearchModalRenderer } from '../features/search'; import { getFallbackSession } from '../state/sessions'; -import { pushSessionToSW } from '../../sw-session'; export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => { const { hashRouter } = clientConfig; @@ -116,7 +115,6 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) if (afterLoginPath) setAfterLoginRedirectPath(afterLoginPath); return redirect(getLoginPath()); } - pushSessionToSW(session.baseUrl, session.accessToken); return null; }} element={ diff --git a/src/index.tsx b/src/index.tsx index 71e723ab..6cc0ca38 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -34,17 +34,14 @@ if ('serviceWorker' in navigator) { navigator.serviceWorker.register(swUrl).then(sendSessionToSW); navigator.serviceWorker.ready.then(sendSessionToSW); - window.addEventListener('load', sendSessionToSW); - // When returning from background - document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'visible') { + navigator.serviceWorker.addEventListener('message', (ev) => { + const { type } = ev.data ?? {}; + + if (type === 'requestSession') { sendSessionToSW(); } }); - - // When restored from bfcache (important on iOS) - window.addEventListener('pageshow', sendSessionToSW); } const mountApp = () => { diff --git a/src/sw.ts b/src/sw.ts index d4eebc02..69293b1d 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -3,14 +3,6 @@ export type {}; declare const self: ServiceWorkerGlobalScope; -self.addEventListener('install', () => { - self.skipWaiting(); -}); - -self.addEventListener('activate', (event: ExtendableEvent) => { - event.waitUntil(self.clients.claim()); -}); - type SessionInfo = { accessToken: string; baseUrl: string; @@ -21,6 +13,9 @@ type SessionInfo = { */ const sessions = new Map(); +const clientToResolve = new Map void>(); +const clientToSessionPromise = new Map>(); + async function cleanupDeadClients() { const activeClients = await self.clients.matchAll(); const activeIds = new Set(activeClients.map((c) => c.id)); @@ -28,10 +23,72 @@ async function cleanupDeadClients() { Array.from(sessions.keys()).forEach((id) => { if (!activeIds.has(id)) { sessions.delete(id); + clientToResolve.delete(id); + clientToSessionPromise.delete(id); } }); } +function setSession(clientId: string, accessToken: any, baseUrl: any) { + if (typeof accessToken === 'string' && typeof baseUrl === 'string') { + sessions.set(clientId, { accessToken, baseUrl }); + } else { + // Logout or invalid session + sessions.delete(clientId); + } + + const resolveSession = clientToResolve.get(clientId); + if (resolveSession) { + resolveSession(sessions.get(clientId)); + clientToResolve.delete(clientId); + clientToSessionPromise.delete(clientId); + } +} + +function requestSession(client: Client): Promise { + const promise = + clientToSessionPromise.get(client.id) ?? + new Promise((resolve) => { + clientToResolve.set(client.id, resolve); + client.postMessage({ type: 'requestSession' }); + }); + + if (!clientToSessionPromise.has(client.id)) { + clientToSessionPromise.set(client.id, promise); + } + + return promise; +} + +async function requestSessionWithTimeout( + clientId: string, + timeoutMs = 3000 +): Promise { + const client = await self.clients.get(clientId); + if (!client) return undefined; + + const sessionPromise = requestSession(client); + + const timeout = new Promise((resolve) => { + setTimeout(() => resolve(undefined), timeoutMs); + }); + + return Promise.race([sessionPromise, timeout]); +} + +self.addEventListener('install', () => { + self.skipWaiting(); +}); + +self.addEventListener('activate', (event: ExtendableEvent) => { + event.waitUntil( + (async () => { + await self.clients.claim(); + await cleanupDeadClients(); + })() + ); +}); + /** * Receive session updates from clients */ @@ -41,23 +98,28 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { const { type, accessToken, baseUrl } = event.data || {}; - if (type !== 'setSession') return; - - cleanupDeadClients(); - - if (typeof accessToken === 'string' && typeof baseUrl === 'string') { - sessions.set(client.id, { accessToken, baseUrl }); - } else { - // Logout or invalid session - sessions.delete(client.id); + if (type === 'setSession') { + setSession(client.id, accessToken, baseUrl); + cleanupDeadClients(); } }); -function validMediaRequest(url: string, baseUrl: string): boolean { - const downloadUrl = new URL('/_matrix/client/v1/media/download', baseUrl); - const thumbnailUrl = new URL('/_matrix/client/v1/media/thumbnail', baseUrl); +const MEDIA_PATHS = ['/_matrix/client/v1/media/download', '/_matrix/client/v1/media/thumbnail']; - return url.startsWith(downloadUrl.href) || url.startsWith(thumbnailUrl.href); +function mediaPath(url: string): boolean { + try { + const { pathname } = new URL(url); + return MEDIA_PATHS.some((p) => pathname.startsWith(p)); + } catch { + return false; + } +} + +function validMediaRequest(url: string, baseUrl: string): boolean { + return MEDIA_PATHS.some((p) => { + const validUrl = new URL(p, baseUrl); + return url.startsWith(validUrl.href); + }); } function fetchConfig(token: string): RequestInit { @@ -72,13 +134,25 @@ function fetchConfig(token: string): RequestInit { self.addEventListener('fetch', (event: FetchEvent) => { const { url, method } = event.request; - if (method !== 'GET') return; - if (!event.clientId) return; + if (method !== 'GET' || !mediaPath(url)) return; - const session = sessions.get(event.clientId); - if (!session) return; + const { clientId } = event; + if (!clientId) return; - if (!validMediaRequest(url, session.baseUrl)) return; + const session = sessions.get(clientId); + if (session) { + if (validMediaRequest(url, session.baseUrl)) { + event.respondWith(fetch(url, fetchConfig(session.accessToken))); + } + return; + } - event.respondWith(fetch(url, fetchConfig(session.accessToken))); + event.respondWith( + requestSessionWithTimeout(clientId).then((s) => { + if (s && validMediaRequest(url, s.baseUrl)) { + return fetch(url, fetchConfig(s.accessToken)); + } + return fetch(event.request); + }) + ); }); From 9ff15b8b03a5717096e4ba7ab9e111366c45b20a Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Sun, 22 Feb 2026 09:44:04 +0530 Subject: [PATCH 024/206] fix space lobby / search selected hook not working (#2675) --- src/app/hooks/router/useSelectedSpace.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/hooks/router/useSelectedSpace.ts b/src/app/hooks/router/useSelectedSpace.ts index 2e8e73a0..b891fde7 100644 --- a/src/app/hooks/router/useSelectedSpace.ts +++ b/src/app/hooks/router/useSelectedSpace.ts @@ -18,7 +18,7 @@ export const useSelectedSpace = (): string | undefined => { export const useSpaceLobbySelected = (spaceIdOrAlias: string): boolean => { const match = useMatch({ - path: getSpaceLobbyPath(spaceIdOrAlias), + path: decodeURIComponent(getSpaceLobbyPath(spaceIdOrAlias)), caseSensitive: true, end: false, }); @@ -28,7 +28,7 @@ export const useSpaceLobbySelected = (spaceIdOrAlias: string): boolean => { export const useSpaceSearchSelected = (spaceIdOrAlias: string): boolean => { const match = useMatch({ - path: getSpaceSearchPath(spaceIdOrAlias), + path: decodeURIComponent(getSpaceSearchPath(spaceIdOrAlias)), caseSensitive: true, end: false, }); From 1f03891b258f996c5c85bddfecd05a2b3efca4aa Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 22 Feb 2026 15:43:30 +1100 Subject: [PATCH 025/206] fix(deps): update dependency folds to v2.6.1 (#2679) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 582f02f4..7d48d644 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "emojibase-data": "15.3.2", "file-saver": "2.0.5", "focus-trap-react": "10.0.2", - "folds": "2.5.0", + "folds": "2.6.1", "html-dom-parser": "4.0.0", "html-react-parser": "4.2.0", "i18next": "23.12.2", @@ -7158,9 +7158,9 @@ } }, "node_modules/folds": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/folds/-/folds-2.5.0.tgz", - "integrity": "sha512-UJhvXAQ1XnZ9w10KJwSW+frvzzWE/zcF0dH3fDVCD70RFHAxwEi0UkkVS8CaZGxZF2Wvt3qTJyTS5LW3LwwUAw==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/folds/-/folds-2.6.1.tgz", + "integrity": "sha512-0L1ZSqwjFSg2fesa//C4DgP47Vp/KqDuzjAaOEYN21AvoptyVI+6OEXWrtIdE8DPQCZYr0bV+tqbrLyA6uAhaw==", "license": "Apache-2.0", "peerDependencies": { "@vanilla-extract/css": "1.9.2", diff --git a/package.json b/package.json index 601323ad..84383522 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "emojibase-data": "15.3.2", "file-saver": "2.0.5", "focus-trap-react": "10.0.2", - "folds": "2.5.0", + "folds": "2.6.1", "html-dom-parser": "4.0.0", "html-react-parser": "4.2.0", "i18next": "23.12.2", From 91c8731940d028a832f05fbf05508220e2519481 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Sun, 22 Feb 2026 10:18:23 +0530 Subject: [PATCH 026/206] Add permission for managing emojis & stickers (#2678) add permission for managing emojis & stickers --- .../room-settings/permissions/usePermissionItems.ts | 7 +++++++ .../space-settings/permissions/usePermissionItems.ts | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/src/app/features/room-settings/permissions/usePermissionItems.ts b/src/app/features/room-settings/permissions/usePermissionItems.ts index 513f82b4..f3b45424 100644 --- a/src/app/features/room-settings/permissions/usePermissionItems.ts +++ b/src/app/features/room-settings/permissions/usePermissionItems.ts @@ -177,6 +177,13 @@ export const usePermissionGroups = (): PermissionGroup[] => { const otherSettingsGroup: PermissionGroup = { name: 'Other', items: [ + { + location: { + state: true, + key: StateEvent.PoniesRoomEmotes, + }, + name: 'Manage Emojis & Stickers', + }, { location: { state: true, diff --git a/src/app/features/space-settings/permissions/usePermissionItems.ts b/src/app/features/space-settings/permissions/usePermissionItems.ts index 8192e7bc..a02cf420 100644 --- a/src/app/features/space-settings/permissions/usePermissionItems.ts +++ b/src/app/features/space-settings/permissions/usePermissionItems.ts @@ -125,6 +125,13 @@ export const usePermissionGroups = (): PermissionGroup[] => { const otherSettingsGroup: PermissionGroup = { name: 'Other', items: [ + { + location: { + state: true, + key: StateEvent.PoniesRoomEmotes, + }, + name: 'Manage Emojis & Stickers', + }, { location: { state: true, From b6cc0e307795c68b779e5d5dc2b61e1ed4e8ce4d Mon Sep 17 00:00:00 2001 From: Krishan <33421343+kfiven@users.noreply.github.com> Date: Sun, 22 Feb 2026 18:15:23 +1100 Subject: [PATCH 027/206] Update node to v24.13.1 LTS (#2622) * Update node to v24.13.1 LTS * Fix dockerfile node version * Simplify node and nginx version, bump nginx * Fix casing --- .github/workflows/build-pull-request.yml | 2 +- .github/workflows/netlify-dev.yml | 2 +- .github/workflows/prod-deploy.yml | 2 +- Dockerfile | 4 ++-- README.md | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-pull-request.yml b/.github/workflows/build-pull-request.yml index 450e4e29..e6ecb709 100644 --- a/.github/workflows/build-pull-request.yml +++ b/.github/workflows/build-pull-request.yml @@ -16,7 +16,7 @@ jobs: - name: Setup node uses: actions/setup-node@v4.4.0 with: - node-version: 20.12.2 + node-version: 24.13.1 cache: 'npm' - name: Install dependencies run: npm ci diff --git a/.github/workflows/netlify-dev.yml b/.github/workflows/netlify-dev.yml index 66cd5ad5..bf7264c6 100644 --- a/.github/workflows/netlify-dev.yml +++ b/.github/workflows/netlify-dev.yml @@ -15,7 +15,7 @@ jobs: - name: Setup node uses: actions/setup-node@v4.4.0 with: - node-version: 20.12.2 + node-version: 24.13.1 cache: 'npm' - name: Install dependencies run: npm ci diff --git a/.github/workflows/prod-deploy.yml b/.github/workflows/prod-deploy.yml index fe8a722f..e9e4de8b 100644 --- a/.github/workflows/prod-deploy.yml +++ b/.github/workflows/prod-deploy.yml @@ -14,7 +14,7 @@ jobs: - name: Setup node uses: actions/setup-node@v4.4.0 with: - node-version: 20.12.2 + node-version: 24.13.1 cache: 'npm' - name: Install dependencies run: npm ci diff --git a/Dockerfile b/Dockerfile index 6489cb86..0e89e4e7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ ## Builder -FROM node:20.12.2-alpine3.18 as builder +FROM node:24.13.1-alpine AS builder WORKDIR /src @@ -11,7 +11,7 @@ RUN npm run build ## App -FROM nginx:1.29.3-alpine +FROM nginx:1.29.5-alpine COPY --from=builder /src/dist /app COPY --from=builder /src/docker-nginx.conf /etc/nginx/conf.d/default.conf diff --git a/README.md b/README.md index 427898f9..f1c36082 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ mxFo+ioe/ABCufSmyqFye0psX3Sp ## Local development > [!TIP] -> We recommend using a version manager as versions change very quickly. You will likely need to switch between multiple Node.js versions based on the needs of different projects you're working on. [NVM on windows](https://github.com/coreybutler/nvm-windows#installation--upgrades) on Windows and [nvm](https://github.com/nvm-sh/nvm) on Linux/macOS are pretty good choices. Recommended nodejs version is Iron LTS (v20). +> We recommend using a version manager as versions change very quickly. You will likely need to switch between multiple Node.js versions based on the needs of different projects you're working on. [NVM on windows](https://github.com/coreybutler/nvm-windows#installation--upgrades) on Windows and [nvm](https://github.com/nvm-sh/nvm) on Linux/macOS are pretty good choices. Recommended nodejs version is Krypton LTS (v24.13.1). Execute the following commands to start a development server: ```sh From 2d6730de56d920b7aef0e291bdfe874ce996acac Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 22 Feb 2026 18:26:08 +1100 Subject: [PATCH 028/206] Bump actions/checkout from 4.2.0 to 6.0.2 (#2640) Bumps [actions/checkout](https://github.com/actions/checkout) from 4.2.0 to 6.0.2. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4.2.0...v6.0.2) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 6.0.2 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build-pull-request.yml | 2 +- .github/workflows/docker-pr.yml | 2 +- .github/workflows/lockfile.yml | 2 +- .github/workflows/netlify-dev.yml | 2 +- .github/workflows/prod-deploy.yml | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-pull-request.yml b/.github/workflows/build-pull-request.yml index e6ecb709..ef4563db 100644 --- a/.github/workflows/build-pull-request.yml +++ b/.github/workflows/build-pull-request.yml @@ -12,7 +12,7 @@ jobs: PR_NUMBER: ${{github.event.number}} steps: - name: Checkout repository - uses: actions/checkout@v4.2.0 + uses: actions/checkout@v6.0.2 - name: Setup node uses: actions/setup-node@v4.4.0 with: diff --git a/.github/workflows/docker-pr.yml b/.github/workflows/docker-pr.yml index 398785ab..bc68fe2a 100644 --- a/.github/workflows/docker-pr.yml +++ b/.github/workflows/docker-pr.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4.2.0 + uses: actions/checkout@v6.0.2 - name: Build Docker image uses: docker/build-push-action@v6.18.0 with: diff --git a/.github/workflows/lockfile.yml b/.github/workflows/lockfile.yml index be52eb50..8a57a3b5 100644 --- a/.github/workflows/lockfile.yml +++ b/.github/workflows/lockfile.yml @@ -14,7 +14,7 @@ jobs: pull-requests: write steps: - name: Checkout - uses: actions/checkout@v4.2.0 + uses: actions/checkout@v6.0.2 - name: NPM Lockfile Changes uses: codepunkt/npm-lockfile-changes@b40543471c36394409466fdb277a73a0856d7891 with: diff --git a/.github/workflows/netlify-dev.yml b/.github/workflows/netlify-dev.yml index bf7264c6..fe3706a2 100644 --- a/.github/workflows/netlify-dev.yml +++ b/.github/workflows/netlify-dev.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4.2.0 + uses: actions/checkout@v6.0.2 - name: Setup node uses: actions/setup-node@v4.4.0 with: diff --git a/.github/workflows/prod-deploy.yml b/.github/workflows/prod-deploy.yml index e9e4de8b..bc7e77d2 100644 --- a/.github/workflows/prod-deploy.yml +++ b/.github/workflows/prod-deploy.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4.2.0 + uses: actions/checkout@v6.0.2 - name: Setup node uses: actions/setup-node@v4.4.0 with: @@ -66,7 +66,7 @@ jobs: packages: write steps: - name: Checkout repository - uses: actions/checkout@v4.2.0 + uses: actions/checkout@v6.0.2 - name: Set up QEMU uses: docker/setup-qemu-action@v3.6.0 - name: Set up Docker Buildx From 2e6c5f7c044c3f85c1b284d2fdaff86e2c491a2a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 22 Feb 2026 18:26:54 +1100 Subject: [PATCH 029/206] Bump actions/upload-artifact from 4.6.2 to 6.0.0 (#2644) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.2 to 6.0.0. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4.6.2...v6.0.0) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: 6.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build-pull-request.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-pull-request.yml b/.github/workflows/build-pull-request.yml index ef4563db..fae87b2c 100644 --- a/.github/workflows/build-pull-request.yml +++ b/.github/workflows/build-pull-request.yml @@ -25,7 +25,7 @@ jobs: NODE_OPTIONS: '--max_old_space_size=4096' run: npm run build - name: Upload artifact - uses: actions/upload-artifact@v4.6.2 + uses: actions/upload-artifact@v6.0.0 with: name: preview path: dist @@ -33,7 +33,7 @@ jobs: - name: Save pr number run: echo ${PR_NUMBER} > ./pr.txt - name: Upload pr number - uses: actions/upload-artifact@v4.6.2 + uses: actions/upload-artifact@v6.0.0 with: name: pr path: ./pr.txt From 826b3c299748ea5bb7a5593875440b955ad74a65 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 22 Feb 2026 18:57:23 +1100 Subject: [PATCH 030/206] chore(deps): update actions/setup-node action to v6 (#2681) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/build-pull-request.yml | 2 +- .github/workflows/netlify-dev.yml | 2 +- .github/workflows/prod-deploy.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-pull-request.yml b/.github/workflows/build-pull-request.yml index fae87b2c..b7646e58 100644 --- a/.github/workflows/build-pull-request.yml +++ b/.github/workflows/build-pull-request.yml @@ -14,7 +14,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v6.0.2 - name: Setup node - uses: actions/setup-node@v4.4.0 + uses: actions/setup-node@v6.2.0 with: node-version: 24.13.1 cache: 'npm' diff --git a/.github/workflows/netlify-dev.yml b/.github/workflows/netlify-dev.yml index fe3706a2..06e0abda 100644 --- a/.github/workflows/netlify-dev.yml +++ b/.github/workflows/netlify-dev.yml @@ -13,7 +13,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v6.0.2 - name: Setup node - uses: actions/setup-node@v4.4.0 + uses: actions/setup-node@v6.2.0 with: node-version: 24.13.1 cache: 'npm' diff --git a/.github/workflows/prod-deploy.yml b/.github/workflows/prod-deploy.yml index bc7e77d2..4c88ad0f 100644 --- a/.github/workflows/prod-deploy.yml +++ b/.github/workflows/prod-deploy.yml @@ -12,7 +12,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v6.0.2 - name: Setup node - uses: actions/setup-node@v4.4.0 + uses: actions/setup-node@v6.2.0 with: node-version: 24.13.1 cache: 'npm' From a9022184fc20547c8480461c1c221b6985921512 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:27:39 +0530 Subject: [PATCH 031/206] Set message power to moderator in space (#2684) --- src/app/components/create-room/utils.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/app/components/create-room/utils.ts b/src/app/components/create-room/utils.ts index a0ca7488..d0a45c3d 100644 --- a/src/app/components/create-room/utils.ts +++ b/src/app/components/create-room/utils.ts @@ -74,6 +74,10 @@ export const createRoomParentState = (parent: Room) => ({ }, }); +const createSpacePowerLevelsOverride = () => ({ + events_default: 50, +}); + export const createRoomEncryptionState = () => ({ type: 'm.room.encryption', state_key: '', @@ -121,6 +125,10 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis initial_state: initialState, }; + if (data.type === RoomType.Space) { + options.power_level_content_override = createSpacePowerLevelsOverride(); + } + const result = await mx.createRoom(options); if (data.parent) { From 7a9f6d222305ecceca142a92fffc1083c3ee4c18 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 17:43:15 +1100 Subject: [PATCH 032/206] Bump linkifyjs and linkify-react from 4.1.3 to 4.3.2 (#2682) * Bump linkifyjs from 4.1.3 to 4.3.2 Bumps [linkifyjs](https://github.com/nfrasser/linkifyjs/tree/HEAD/packages/linkifyjs) from 4.1.3 to 4.3.2. - [Release notes](https://github.com/nfrasser/linkifyjs/releases) - [Changelog](https://github.com/nfrasser/linkifyjs/blob/main/CHANGELOG.md) - [Commits](https://github.com/nfrasser/linkifyjs/commits/v4.3.2/packages/linkifyjs) --- updated-dependencies: - dependency-name: linkifyjs dependency-version: 4.3.2 dependency-type: direct:production ... Signed-off-by: dependabot[bot] * update linkify react --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Ajay Bura <32841439+ajbura@users.noreply.github.com> --- package-lock.json | 18 ++++++++++-------- package.json | 4 ++-- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7d48d644..78604792 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,8 +41,8 @@ "immer": "9.0.16", "is-hotkey": "0.2.0", "jotai": "2.6.0", - "linkify-react": "4.1.3", - "linkifyjs": "4.1.3", + "linkify-react": "4.3.2", + "linkifyjs": "4.3.2", "matrix-js-sdk": "38.2.0", "millify": "6.1.0", "pdfjs-dist": "4.2.67", @@ -8492,18 +8492,20 @@ } }, "node_modules/linkify-react": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/linkify-react/-/linkify-react-4.1.3.tgz", - "integrity": "sha512-rhI3zM/fxn5BfRPHfi4r9N7zgac4vOIxub1wHIWXLA5ENTMs+BGaIaFO1D1PhmxgwhIKmJz3H7uCP0Dg5JwSlA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/linkify-react/-/linkify-react-4.3.2.tgz", + "integrity": "sha512-mi744h1hf+WDsr+paJgSBBgYNLMWNSHyM9V9LVUo03RidNGdw1VpI7Twnt+K3pEh3nIzB4xiiAgZxpd61ItKpQ==", + "license": "MIT", "peerDependencies": { "linkifyjs": "^4.0.0", "react": ">= 15.0.0" } }, "node_modules/linkifyjs": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.1.3.tgz", - "integrity": "sha512-auMesunaJ8yfkHvK4gfg1K0SaKX/6Wn9g2Aac/NwX+l5VdmFZzo/hdPGxEOETj+ryRa4/fiOPjeeKURSAJx1sg==" + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz", + "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==", + "license": "MIT" }, "node_modules/locate-path": { "version": "6.0.0", diff --git a/package.json b/package.json index 84383522..631ced3d 100644 --- a/package.json +++ b/package.json @@ -52,8 +52,8 @@ "immer": "9.0.16", "is-hotkey": "0.2.0", "jotai": "2.6.0", - "linkify-react": "4.1.3", - "linkifyjs": "4.1.3", + "linkify-react": "4.3.2", + "linkifyjs": "4.3.2", "matrix-js-sdk": "38.2.0", "millify": "6.1.0", "pdfjs-dist": "4.2.67", From b2cb7171782e49514b9f90e8cc7b5d2ca09e9766 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Mon, 23 Feb 2026 12:26:14 +0530 Subject: [PATCH 033/206] fix noreferrer typo in url preview link (#2685) --- src/app/components/url-preview/UrlPreviewCard.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index 2a51969a..cbe85df2 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -30,7 +30,15 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>( if (previewStatus.status === AsyncStatus.Error) return null; const renderContent = (prev: IPreviewUrlResponse) => { - const imgUrl = mxcUrlToHttp(mx, prev['og:image'] || '', useAuthentication, 256, 256, 'scale', false); + const imgUrl = mxcUrlToHttp( + mx, + prev['og:image'] || '', + useAuthentication, + 256, + 256, + 'scale', + false + ); return ( <> @@ -42,7 +50,7 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>( as="a" href={url} target="_blank" - rel="no-referrer" + rel="noreferrer" size="T200" priority="300" > From ed0ad61bc42b05d56b6dda38a3b34b6dec0256c1 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Mon, 23 Feb 2026 12:38:25 +0530 Subject: [PATCH 034/206] Verify SSO window message origin (#2686) --- src/app/components/uia-stages/SSOStage.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/app/components/uia-stages/SSOStage.tsx b/src/app/components/uia-stages/SSOStage.tsx index f85bcb30..e3d6126a 100644 --- a/src/app/components/uia-stages/SSOStage.tsx +++ b/src/app/components/uia-stages/SSOStage.tsx @@ -26,7 +26,12 @@ export function SSOStage({ useEffect(() => { const handleMessage = (evt: MessageEvent) => { - if (ssoWindow && evt.data === 'authDone' && evt.source === ssoWindow) { + if ( + evt.origin === new URL(ssoRedirectURL).origin && + ssoWindow && + evt.data === 'authDone' && + evt.source === ssoWindow + ) { ssoWindow.close(); setSSOWindow(undefined); handleSubmit(); @@ -37,7 +42,7 @@ export function SSOStage({ return () => { window.removeEventListener('message', handleMessage); }; - }, [ssoWindow, handleSubmit]); + }, [ssoWindow, handleSubmit, ssoRedirectURL]); return ( From dab44edef2f5d90b4849f9aaba2cce5985a677bd Mon Sep 17 00:00:00 2001 From: Krishan <33421343+kfiven@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:12:37 +1100 Subject: [PATCH 035/206] Add prod-deploy.yml to Docker PR workflow paths (#2687) --- .github/workflows/docker-pr.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/docker-pr.yml b/.github/workflows/docker-pr.yml index bc68fe2a..e2dd0943 100644 --- a/.github/workflows/docker-pr.yml +++ b/.github/workflows/docker-pr.yml @@ -5,6 +5,7 @@ on: paths: - 'Dockerfile' - '.github/workflows/docker-pr.yml' + - '.github/workflows/prod-deploy.yml' jobs: docker-build: From cd80d4c9e8c29aacb7eb076d6846cdd5c2503882 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:13:17 +1100 Subject: [PATCH 036/206] Bump docker/build-push-action from 6.18.0 to 6.19.2 (#2642) Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.18.0 to 6.19.2. - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/v6.18.0...v6.19.2) --- updated-dependencies: - dependency-name: docker/build-push-action dependency-version: 6.19.2 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docker-pr.yml | 2 +- .github/workflows/prod-deploy.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-pr.yml b/.github/workflows/docker-pr.yml index e2dd0943..38f6a4fe 100644 --- a/.github/workflows/docker-pr.yml +++ b/.github/workflows/docker-pr.yml @@ -14,7 +14,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v6.0.2 - name: Build Docker image - uses: docker/build-push-action@v6.18.0 + uses: docker/build-push-action@v6.19.2 with: context: . push: false diff --git a/.github/workflows/prod-deploy.yml b/.github/workflows/prod-deploy.yml index 4c88ad0f..72a0fe62 100644 --- a/.github/workflows/prod-deploy.yml +++ b/.github/workflows/prod-deploy.yml @@ -90,7 +90,7 @@ jobs: ${{ secrets.DOCKER_USERNAME }}/cinny ghcr.io/${{ github.repository }} - name: Build and push Docker image - uses: docker/build-push-action@v6.18.0 + uses: docker/build-push-action@v6.19.2 with: context: . platforms: linux/amd64,linux/arm64 From df3a3ba789824a6771f93c6692c42aef30eaa804 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:18:01 +1100 Subject: [PATCH 037/206] Bump docker/setup-buildx-action from 3.11.1 to 3.12.0 (#2641) Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.11.1 to 3.12.0. - [Release notes](https://github.com/docker/setup-buildx-action/releases) - [Commits](https://github.com/docker/setup-buildx-action/compare/v3.11.1...v3.12.0) --- updated-dependencies: - dependency-name: docker/setup-buildx-action dependency-version: 3.12.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/prod-deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/prod-deploy.yml b/.github/workflows/prod-deploy.yml index 72a0fe62..f55ba58a 100644 --- a/.github/workflows/prod-deploy.yml +++ b/.github/workflows/prod-deploy.yml @@ -70,7 +70,7 @@ jobs: - name: Set up QEMU uses: docker/setup-qemu-action@v3.6.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.11.1 + uses: docker/setup-buildx-action@v3.12.0 - name: Login to Docker Hub uses: docker/login-action@v3.6.0 with: From 02106a99b99e607a1045eb5608874648d9f7d80a Mon Sep 17 00:00:00 2001 From: Krishan <33421343+kfiven@users.noreply.github.com> Date: Mon, 23 Feb 2026 22:32:06 +1100 Subject: [PATCH 038/206] Release v4.10.4 (#2688) --- package-lock.json | 4 ++-- package.json | 2 +- src/app/features/settings/about/About.tsx | 2 +- src/app/pages/auth/AuthFooter.tsx | 2 +- src/app/pages/client/WelcomePage.tsx | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 78604792..14e525d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cinny", - "version": "4.10.3", + "version": "4.10.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cinny", - "version": "4.10.3", + "version": "4.10.4", "license": "AGPL-3.0-only", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "1.1.6", diff --git a/package.json b/package.json index 631ced3d..2002bc30 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cinny", - "version": "4.10.3", + "version": "4.10.4", "description": "Yet another matrix client", "main": "index.js", "type": "module", diff --git a/src/app/features/settings/about/About.tsx b/src/app/features/settings/about/About.tsx index 7cf121d5..7c876a15 100644 --- a/src/app/features/settings/about/About.tsx +++ b/src/app/features/settings/about/About.tsx @@ -46,7 +46,7 @@ export function About({ requestClose }: AboutProps) { Cinny - v4.10.3 + v4.10.4 Yet another matrix client. diff --git a/src/app/pages/auth/AuthFooter.tsx b/src/app/pages/auth/AuthFooter.tsx index 124b0384..ad9da463 100644 --- a/src/app/pages/auth/AuthFooter.tsx +++ b/src/app/pages/auth/AuthFooter.tsx @@ -15,7 +15,7 @@ export function AuthFooter() { target="_blank" rel="noreferrer" > - v4.10.3 + v4.10.4 Twitter diff --git a/src/app/pages/client/WelcomePage.tsx b/src/app/pages/client/WelcomePage.tsx index 969a5cd4..23085286 100644 --- a/src/app/pages/client/WelcomePage.tsx +++ b/src/app/pages/client/WelcomePage.tsx @@ -24,7 +24,7 @@ export function WelcomePage() { target="_blank" rel="noreferrer noopener" > - v4.10.3 + v4.10.4 } From f64280993980439b71f326b329613d1f5f605e4a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 22:56:52 +1100 Subject: [PATCH 039/206] chore(deps): update docker/login-action action to v3.7.0 (#2689) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/prod-deploy.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/prod-deploy.yml b/.github/workflows/prod-deploy.yml index f55ba58a..39f0b671 100644 --- a/.github/workflows/prod-deploy.yml +++ b/.github/workflows/prod-deploy.yml @@ -72,12 +72,12 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3.12.0 - name: Login to Docker Hub - uses: docker/login-action@v3.6.0 + uses: docker/login-action@v3.7.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Login to the Container registry - uses: docker/login-action@v3.6.0 + uses: docker/login-action@v3.7.0 with: registry: ghcr.io username: ${{ github.actor }} From 739786d9abe1467d9c43dcc8ff8caadbfee62964 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 22:57:44 +1100 Subject: [PATCH 040/206] chore(deps): update docker/setup-qemu-action action to v3.7.0 (#2691) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/prod-deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/prod-deploy.yml b/.github/workflows/prod-deploy.yml index 39f0b671..182efbd2 100644 --- a/.github/workflows/prod-deploy.yml +++ b/.github/workflows/prod-deploy.yml @@ -68,7 +68,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v6.0.2 - name: Set up QEMU - uses: docker/setup-qemu-action@v3.6.0 + uses: docker/setup-qemu-action@v3.7.0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3.12.0 - name: Login to Docker Hub From f2d8ad0b6bdaf1ef904a7a4a553361685f404afd Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 22:58:20 +1100 Subject: [PATCH 041/206] chore(deps): update docker/metadata-action action to v5.10.0 (#2690) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/prod-deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/prod-deploy.yml b/.github/workflows/prod-deploy.yml index 182efbd2..8c3dcc56 100644 --- a/.github/workflows/prod-deploy.yml +++ b/.github/workflows/prod-deploy.yml @@ -84,7 +84,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@v5.8.0 + uses: docker/metadata-action@v5.10.0 with: images: | ${{ secrets.DOCKER_USERNAME }}/cinny From 6347640a35d85a60a4794879e11c8a11e065005a Mon Sep 17 00:00:00 2001 From: Krishan <33421343+kfiven@users.noreply.github.com> Date: Mon, 23 Feb 2026 23:05:01 +1100 Subject: [PATCH 042/206] Release v4.10.5 (#2692) --- package-lock.json | 4 ++-- package.json | 2 +- src/app/features/settings/about/About.tsx | 2 +- src/app/pages/auth/AuthFooter.tsx | 2 +- src/app/pages/client/WelcomePage.tsx | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 14e525d2..05466866 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cinny", - "version": "4.10.4", + "version": "4.10.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cinny", - "version": "4.10.4", + "version": "4.10.5", "license": "AGPL-3.0-only", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "1.1.6", diff --git a/package.json b/package.json index 2002bc30..de8ee72d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cinny", - "version": "4.10.4", + "version": "4.10.5", "description": "Yet another matrix client", "main": "index.js", "type": "module", diff --git a/src/app/features/settings/about/About.tsx b/src/app/features/settings/about/About.tsx index 7c876a15..f7af2d6a 100644 --- a/src/app/features/settings/about/About.tsx +++ b/src/app/features/settings/about/About.tsx @@ -46,7 +46,7 @@ export function About({ requestClose }: AboutProps) { Cinny - v4.10.4 + v4.10.5 Yet another matrix client. diff --git a/src/app/pages/auth/AuthFooter.tsx b/src/app/pages/auth/AuthFooter.tsx index ad9da463..146f039b 100644 --- a/src/app/pages/auth/AuthFooter.tsx +++ b/src/app/pages/auth/AuthFooter.tsx @@ -15,7 +15,7 @@ export function AuthFooter() { target="_blank" rel="noreferrer" > - v4.10.4 + v4.10.5 Twitter diff --git a/src/app/pages/client/WelcomePage.tsx b/src/app/pages/client/WelcomePage.tsx index 23085286..79630990 100644 --- a/src/app/pages/client/WelcomePage.tsx +++ b/src/app/pages/client/WelcomePage.tsx @@ -24,7 +24,7 @@ export function WelcomePage() { target="_blank" rel="noreferrer noopener" > - v4.10.4 + v4.10.5 } From 221bc047544555c1a849d8dee1c6e110618bf3b3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 17:54:57 +1100 Subject: [PATCH 043/206] Bump dawidd6/action-download-artifact from 11 to 15 (#2694) Bumps [dawidd6/action-download-artifact](https://github.com/dawidd6/action-download-artifact) from 11 to 15. - [Release notes](https://github.com/dawidd6/action-download-artifact/releases) - [Commits](https://github.com/dawidd6/action-download-artifact/compare/ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5...fe9d59ce33ce92db8a6ac90b2c8be6b6d90417c8) --- updated-dependencies: - dependency-name: dawidd6/action-download-artifact dependency-version: '15' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/deploy-pull-request.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-pull-request.yml b/.github/workflows/deploy-pull-request.yml index b330c3c1..aff9b75d 100644 --- a/.github/workflows/deploy-pull-request.yml +++ b/.github/workflows/deploy-pull-request.yml @@ -15,7 +15,7 @@ jobs: if: ${{ github.event.workflow_run.conclusion == 'success' }} steps: - name: Download pr number - uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 + uses: dawidd6/action-download-artifact@fe9d59ce33ce92db8a6ac90b2c8be6b6d90417c8 with: workflow: ${{ github.event.workflow.id }} run_id: ${{ github.event.workflow_run.id }} @@ -24,7 +24,7 @@ jobs: id: pr run: echo "id=$(> $GITHUB_OUTPUT - name: Download artifact - uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 + uses: dawidd6/action-download-artifact@fe9d59ce33ce92db8a6ac90b2c8be6b6d90417c8 with: workflow: ${{ github.event.workflow.id }} run_id: ${{ github.event.workflow_run.id }} From 85fcbd84fecc2070c9af10f128b5a31ed112e6b7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 18:29:40 +1100 Subject: [PATCH 044/206] chore(deps): update thollander/actions-comment-pull-request from 2.5.0 to 3.0.1 (#2698) * chore(deps): update thollander/actions-comment-pull-request digest to e4a76dd * pin to v3.0.1 --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Krishan <33421343+kfiven@users.noreply.github.com> --- .github/workflows/deploy-pull-request.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy-pull-request.yml b/.github/workflows/deploy-pull-request.yml index aff9b75d..af893120 100644 --- a/.github/workflows/deploy-pull-request.yml +++ b/.github/workflows/deploy-pull-request.yml @@ -45,12 +45,12 @@ jobs: NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_PR_CINNY }} timeout-minutes: 1 - name: Comment preview on PR - uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 + uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b #v3.0.1 env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + github-token: ${{ secrets.GITHUB_TOKEN }} with: - pr_number: ${{ steps.pr.outputs.id }} - comment_tag: ${{ steps.pr.outputs.id }} + pr-number: ${{ steps.pr.outputs.id }} + comment-tag: ${{ steps.pr.outputs.id }} message: | Preview: ${{ steps.netlify.outputs.deploy-url }} ⚠️ Exercise caution. Use test accounts. ⚠️ \ No newline at end of file From e0d5c63dc537d42b76afa5ec84b94b559bb8160a Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 3 Mar 2026 11:12:45 +0200 Subject: [PATCH 045/206] Fix invalid matrix.to event link generation (#2717) --- src/app/features/room/message/Message.tsx | 4 +--- src/app/plugins/matrix-to.ts | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 9324e1c2..2d87e9bd 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -327,11 +327,9 @@ export const MessageCopyLinkItem = as< const mx = useMatrixClient(); const handleCopy = () => { - const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId); const eventId = mEvent.getId(); - const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room); if (!eventId) return; - copyToClipboard(getMatrixToRoomEvent(roomIdOrAlias, eventId, viaServers)); + copyToClipboard(getMatrixToRoomEvent(room.roomId, eventId, getViaServers(room))); onClose?.(); }; diff --git a/src/app/plugins/matrix-to.ts b/src/app/plugins/matrix-to.ts index feeafe05..03a7d2c1 100644 --- a/src/app/plugins/matrix-to.ts +++ b/src/app/plugins/matrix-to.ts @@ -16,11 +16,11 @@ export const getMatrixToRoom = (roomIdOrAlias: string, viaServers?: string[]): s }; export const getMatrixToRoomEvent = ( - roomIdOrAlias: string, + roomId: string, eventId: string, viaServers?: string[] ): string => { - let fragment = `${roomIdOrAlias}/${eventId}`; + let fragment = `${roomId}/${eventId}`; if (Array.isArray(viaServers) && viaServers.length > 0) { fragment = withViaServers(fragment, viaServers); From 971f312b4636e2f6385a0796771f777ec16ed016 Mon Sep 17 00:00:00 2001 From: Krishan <33421343+kfiven@users.noreply.github.com> Date: Wed, 4 Mar 2026 12:31:36 +1100 Subject: [PATCH 046/206] Pin all the action deps to SHA (#2725) --- .github/workflows/build-pull-request.yml | 12 +++++------ .github/workflows/cla.yml | 2 +- .github/workflows/deploy-pull-request.yml | 6 +++--- .github/workflows/docker-pr.yml | 4 ++-- .github/workflows/lockfile.yml | 4 ++-- .github/workflows/netlify-dev.yml | 10 ++++----- .github/workflows/prod-deploy.yml | 26 +++++++++++------------ .node-version | 1 + 8 files changed, 33 insertions(+), 32 deletions(-) create mode 100644 .node-version diff --git a/.github/workflows/build-pull-request.yml b/.github/workflows/build-pull-request.yml index b7646e58..59ebe1f0 100644 --- a/.github/workflows/build-pull-request.yml +++ b/.github/workflows/build-pull-request.yml @@ -12,12 +12,12 @@ jobs: PR_NUMBER: ${{github.event.number}} steps: - name: Checkout repository - uses: actions/checkout@v6.0.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup node - uses: actions/setup-node@v6.2.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: - node-version: 24.13.1 - cache: 'npm' + node-version-file: ".node-version" + package-manager-cache: false - name: Install dependencies run: npm ci - name: Build app @@ -25,7 +25,7 @@ jobs: NODE_OPTIONS: '--max_old_space_size=4096' run: npm run build - name: Upload artifact - uses: actions/upload-artifact@v6.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: preview path: dist @@ -33,7 +33,7 @@ jobs: - name: Save pr number run: echo ${PR_NUMBER} > ./pr.txt - name: Upload pr number - uses: actions/upload-artifact@v6.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: pr path: ./pr.txt diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index 29fe7eb2..42eab7ef 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -12,7 +12,7 @@ jobs: - name: 'CLA Assistant' if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' # Beta Release - uses: cla-assistant/github-action@v2.6.1 + uses: cla-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # the below token should have repo scope and must be manually added by you in the repository's secret diff --git a/.github/workflows/deploy-pull-request.yml b/.github/workflows/deploy-pull-request.yml index af893120..1049f173 100644 --- a/.github/workflows/deploy-pull-request.yml +++ b/.github/workflows/deploy-pull-request.yml @@ -15,7 +15,7 @@ jobs: if: ${{ github.event.workflow_run.conclusion == 'success' }} steps: - name: Download pr number - uses: dawidd6/action-download-artifact@fe9d59ce33ce92db8a6ac90b2c8be6b6d90417c8 + uses: dawidd6/action-download-artifact@fe9d59ce33ce92db8a6ac90b2c8be6b6d90417c8 # v15 with: workflow: ${{ github.event.workflow.id }} run_id: ${{ github.event.workflow_run.id }} @@ -24,7 +24,7 @@ jobs: id: pr run: echo "id=$(> $GITHUB_OUTPUT - name: Download artifact - uses: dawidd6/action-download-artifact@fe9d59ce33ce92db8a6ac90b2c8be6b6d90417c8 + uses: dawidd6/action-download-artifact@fe9d59ce33ce92db8a6ac90b2c8be6b6d90417c8 # v15 with: workflow: ${{ github.event.workflow.id }} run_id: ${{ github.event.workflow_run.id }} @@ -32,7 +32,7 @@ jobs: path: dist - name: Deploy to Netlify id: netlify - uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 + uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0.0 with: publish-dir: dist deploy-message: "Deploy PR ${{ steps.pr.outputs.id }}" diff --git a/.github/workflows/docker-pr.yml b/.github/workflows/docker-pr.yml index 38f6a4fe..4f1dba31 100644 --- a/.github/workflows/docker-pr.yml +++ b/.github/workflows/docker-pr.yml @@ -12,9 +12,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6.0.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Build Docker image - uses: docker/build-push-action@v6.19.2 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: . push: false diff --git a/.github/workflows/lockfile.yml b/.github/workflows/lockfile.yml index 8a57a3b5..0e700bfc 100644 --- a/.github/workflows/lockfile.yml +++ b/.github/workflows/lockfile.yml @@ -14,9 +14,9 @@ jobs: pull-requests: write steps: - name: Checkout - uses: actions/checkout@v6.0.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: NPM Lockfile Changes - uses: codepunkt/npm-lockfile-changes@b40543471c36394409466fdb277a73a0856d7891 + uses: codepunkt/npm-lockfile-changes@b40543471c36394409466fdb277a73a0856d7891 # v1.0.0 with: token: ${{ secrets.GITHUB_TOKEN }} # Optional inputs, can be deleted safely if you are happy with default values. diff --git a/.github/workflows/netlify-dev.yml b/.github/workflows/netlify-dev.yml index 06e0abda..ed694b19 100644 --- a/.github/workflows/netlify-dev.yml +++ b/.github/workflows/netlify-dev.yml @@ -11,12 +11,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6.0.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup node - uses: actions/setup-node@v6.2.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: - node-version: 24.13.1 - cache: 'npm' + node-version-file: ".node-version" + package-manager-cache: false - name: Install dependencies run: npm ci - name: Build app @@ -24,7 +24,7 @@ jobs: NODE_OPTIONS: '--max_old_space_size=4096' run: npm run build - name: Deploy to Netlify - uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 + uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0.0 with: publish-dir: dist deploy-message: 'Dev deploy ${{ github.sha }}' diff --git a/.github/workflows/prod-deploy.yml b/.github/workflows/prod-deploy.yml index 8c3dcc56..b797b32c 100644 --- a/.github/workflows/prod-deploy.yml +++ b/.github/workflows/prod-deploy.yml @@ -10,12 +10,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6.0.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup node - uses: actions/setup-node@v6.2.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: - node-version: 24.13.1 - cache: 'npm' + node-version-file: ".node-version" + package-manager-cache: false - name: Install dependencies run: npm ci - name: Build app @@ -23,7 +23,7 @@ jobs: NODE_OPTIONS: '--max_old_space_size=4096' run: npm run build - name: Deploy to Netlify - uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 + uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0.0 with: publish-dir: dist deploy-message: 'Prod deploy ${{ github.ref_name }}' @@ -52,7 +52,7 @@ jobs: gpg --export | xxd -p echo '${{ secrets.GNUPG_PASSPHRASE }}' | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --armor --detach-sign cinny-${{ steps.vars.outputs.tag }}.tar.gz - name: Upload tagged release - uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 + uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 # v2.3.3 with: files: | cinny-${{ steps.vars.outputs.tag }}.tar.gz @@ -66,31 +66,31 @@ jobs: packages: write steps: - name: Checkout repository - uses: actions/checkout@v6.0.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up QEMU - uses: docker/setup-qemu-action@v3.7.0 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.12.0 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Login to Docker Hub - uses: docker/login-action@v3.7.0 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Login to the Container registry - uses: docker/login-action@v3.7.0 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@v5.10.0 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 with: images: | ${{ secrets.DOCKER_USERNAME }}/cinny ghcr.io/${{ github.repository }} - name: Build and push Docker image - uses: docker/build-push-action@v6.19.2 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: . platforms: linux/amd64,linux/arm64 diff --git a/.node-version b/.node-version new file mode 100644 index 00000000..f94d3c2e --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +24.13.1 \ No newline at end of file From bb8b9ab6da71e56987b1e96638a33dfa2789a1e3 Mon Sep 17 00:00:00 2001 From: Krishan <33421343+kfiven@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:31:54 +1100 Subject: [PATCH 047/206] Add more docker related action checks (#2724) * Pin all the action deps to SHA * Add more docker related action checks * Limit Docker build platforms to linux/amd64 Updated Docker build action to target only linux/amd64 platform. --- .github/workflows/docker-pr.yml | 43 ++++++++++++++++++++++++++++++- .github/workflows/prod-deploy.yml | 8 +++--- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docker-pr.yml b/.github/workflows/docker-pr.yml index 4f1dba31..822b8f3f 100644 --- a/.github/workflows/docker-pr.yml +++ b/.github/workflows/docker-pr.yml @@ -10,11 +10,52 @@ on: jobs: docker-build: runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Build Docker image + + - name: Set up QEMU + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - name: Login to Docker Hub #Do not update this action from a outside PR + if: github.event.pull_request.head.repo.fork == false + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Login to the Github Container registry #Do not update this action from a outside PR + if: github.event.pull_request.head.repo.fork == false + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker, GHCR + id: meta + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 + with: + images: | + ajbura/cinny + ghcr.io/${{ github.repository }} + + - name: Build Docker image (no push) uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: . + platforms: linux/amd64 push: false + load: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + - name: Show Docker images + run: docker images diff --git a/.github/workflows/prod-deploy.yml b/.github/workflows/prod-deploy.yml index b797b32c..2341e348 100644 --- a/.github/workflows/prod-deploy.yml +++ b/.github/workflows/prod-deploy.yml @@ -59,7 +59,7 @@ jobs: cinny-${{ steps.vars.outputs.tag }}.tar.gz.asc publish-image: - name: Push Docker image to Docker Hub, ghcr + name: Push Docker image to Docker Hub, GHCR runs-on: ubuntu-latest permissions: contents: read @@ -71,18 +71,18 @@ jobs: uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - - name: Login to Docker Hub + - name: Login to Docker Hub #Do not update this action from a outside PR uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - - name: Login to the Container registry + - name: Login to the Github Container registry #Do not update this action from a outside PR uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract metadata (tags, labels) for Docker + - name: Extract metadata (tags, labels) for Docker, GHCR id: meta uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 with: From 7b52c921d5737fffd2e7c44d33c2e967313c5acd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:50:22 +1100 Subject: [PATCH 048/206] Bump actions/upload-artifact from 6.0.0 to 7.0.0 (#2718) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6.0.0 to 7.0.0. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v6.0.0...v7.0.0) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build-pull-request.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-pull-request.yml b/.github/workflows/build-pull-request.yml index 59ebe1f0..49ab15cb 100644 --- a/.github/workflows/build-pull-request.yml +++ b/.github/workflows/build-pull-request.yml @@ -25,7 +25,7 @@ jobs: NODE_OPTIONS: '--max_old_space_size=4096' run: npm run build - name: Upload artifact - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: preview path: dist @@ -33,7 +33,7 @@ jobs: - name: Save pr number run: echo ${PR_NUMBER} > ./pr.txt - name: Upload pr number - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: pr path: ./pr.txt From 68b6a09697d38ff4277c7b3b33a80a1b5d87727a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 19:08:23 +1100 Subject: [PATCH 049/206] Bump dawidd6/action-download-artifact from 15 to 16 (#2719) Bumps [dawidd6/action-download-artifact](https://github.com/dawidd6/action-download-artifact) from 15 to 16. - [Release notes](https://github.com/dawidd6/action-download-artifact/releases) - [Commits](https://github.com/dawidd6/action-download-artifact/compare/fe9d59ce33ce92db8a6ac90b2c8be6b6d90417c8...2536c51d3d126276eb39f74d6bc9c72ac6ef30d3) --- updated-dependencies: - dependency-name: dawidd6/action-download-artifact dependency-version: '16' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/deploy-pull-request.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-pull-request.yml b/.github/workflows/deploy-pull-request.yml index 1049f173..cb0cafe1 100644 --- a/.github/workflows/deploy-pull-request.yml +++ b/.github/workflows/deploy-pull-request.yml @@ -15,7 +15,7 @@ jobs: if: ${{ github.event.workflow_run.conclusion == 'success' }} steps: - name: Download pr number - uses: dawidd6/action-download-artifact@fe9d59ce33ce92db8a6ac90b2c8be6b6d90417c8 # v15 + uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16 with: workflow: ${{ github.event.workflow.id }} run_id: ${{ github.event.workflow_run.id }} @@ -24,7 +24,7 @@ jobs: id: pr run: echo "id=$(> $GITHUB_OUTPUT - name: Download artifact - uses: dawidd6/action-download-artifact@fe9d59ce33ce92db8a6ac90b2c8be6b6d90417c8 # v15 + uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16 with: workflow: ${{ github.event.workflow.id }} run_id: ${{ github.event.workflow_run.id }} From 2bd1570d6b34d5e2c0ca4856b12762ae4cf97c4f Mon Sep 17 00:00:00 2001 From: Krishan <33421343+kfiven@users.noreply.github.com> Date: Wed, 4 Mar 2026 19:30:36 +1100 Subject: [PATCH 050/206] Update deploy PR workflow name to include PR number (#2726) --- .github/workflows/deploy-pull-request.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-pull-request.yml b/.github/workflows/deploy-pull-request.yml index cb0cafe1..16abf517 100644 --- a/.github/workflows/deploy-pull-request.yml +++ b/.github/workflows/deploy-pull-request.yml @@ -1,4 +1,5 @@ name: Deploy PR to Netlify +run-name: Deploy PR #${{ github.event.workflow_run.pull_requests[0].number }} on: workflow_run: @@ -7,7 +8,7 @@ on: jobs: deploy-pull-request: - name: Deploy pull request + name: Deploy PR #${{ github.event.workflow_run.pull_requests[0].number }} runs-on: ubuntu-latest permissions: contents: read @@ -53,4 +54,4 @@ jobs: comment-tag: ${{ steps.pr.outputs.id }} message: | Preview: ${{ steps.netlify.outputs.deploy-url }} - ⚠️ Exercise caution. Use test accounts. ⚠️ \ No newline at end of file + ⚠️ Exercise caution. Use test accounts. ⚠️ From c5c87036996521675f66a55c292b87e978afb37c Mon Sep 17 00:00:00 2001 From: Krishan <33421343+kfiven@users.noreply.github.com> Date: Wed, 4 Mar 2026 20:07:16 +1100 Subject: [PATCH 051/206] Update deploy PR workflow name and run name (#2728) --- .github/workflows/deploy-pull-request.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-pull-request.yml b/.github/workflows/deploy-pull-request.yml index 16abf517..baaa181f 100644 --- a/.github/workflows/deploy-pull-request.yml +++ b/.github/workflows/deploy-pull-request.yml @@ -1,5 +1,5 @@ name: Deploy PR to Netlify -run-name: Deploy PR #${{ github.event.workflow_run.pull_requests[0].number }} +run-name: "Deploy PR to Netlify (${{ github.event.workflow_run.head_branch }})" on: workflow_run: @@ -8,7 +8,7 @@ on: jobs: deploy-pull-request: - name: Deploy PR #${{ github.event.workflow_run.pull_requests[0].number }} + name: Deploy pull request runs-on: ubuntu-latest permissions: contents: read From 730670cf52c6c840f39be1bbc44d6d4f42fdd733 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 20:10:28 +1100 Subject: [PATCH 052/206] chore(deps): update actions/setup-node action to v6.3.0 (#2727) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/build-pull-request.yml | 2 +- .github/workflows/netlify-dev.yml | 2 +- .github/workflows/prod-deploy.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-pull-request.yml b/.github/workflows/build-pull-request.yml index 49ab15cb..62d897ed 100644 --- a/.github/workflows/build-pull-request.yml +++ b/.github/workflows/build-pull-request.yml @@ -14,7 +14,7 @@ jobs: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: ".node-version" package-manager-cache: false diff --git a/.github/workflows/netlify-dev.yml b/.github/workflows/netlify-dev.yml index ed694b19..f3eacfee 100644 --- a/.github/workflows/netlify-dev.yml +++ b/.github/workflows/netlify-dev.yml @@ -13,7 +13,7 @@ jobs: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: ".node-version" package-manager-cache: false diff --git a/.github/workflows/prod-deploy.yml b/.github/workflows/prod-deploy.yml index 2341e348..22615630 100644 --- a/.github/workflows/prod-deploy.yml +++ b/.github/workflows/prod-deploy.yml @@ -12,7 +12,7 @@ jobs: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: ".node-version" package-manager-cache: false From b050cd01f9ada1f253b0f5f50c6c1eddaf929978 Mon Sep 17 00:00:00 2001 From: Krishan <33421343+kfiven@users.noreply.github.com> Date: Sat, 7 Mar 2026 18:03:32 +1100 Subject: [PATCH 053/206] feat: Add voice/video room support (#2680) * Add users on the nav to showcase call activity and who is in the call * add check to prevent DCing from the call you're currently in... * Add avatar and username for the space (needs to be moved into RoomNavItem proper) * Add background variant to buttons * Update hook to keep method signature (accepting an array of Rooms instead) to support multiple room event tracking of the same event * Add state listener so the call activity is real time updated on joins/leaves within the space * Add RoomNavUser for displaying the user avatar + name in the nav for a visual of call activity and participants * rename CallNavBottom to CallNavStatus * Rename callnavbottom and fix linking implementation to actually be correct * temp fix to allow the status to be cleared in some way * re-add background to active call link button * prepare to feed this to child elements for visibility handling * loosely provide nav handling for testing refactoring * Add CallView * Update to funnel Outlet context through for Call handling (might not be the best approach, but removes code replication in PersistentCallContainer where we were remaking the roomview entirely) * update client layout to funnel outlet the iframes for the call container * funnel through just iframe for now for testing sake * Update room to use CallView * Pass forward the backupIframeRef now * remove unused params * Add backupIframeRef so we can re-add the lobby screen for non-joined calls (for viewing their text channels) * Remove unused imports and restructure to support being parent to clientlayout * Re-add layout as we're no longer oddly passing outlet context * swap to using ref provider context from to connect to persistentcallcontainer more directly * Revert to original code as we've moved calling to be more inline with design * Revert to original code as we've moved the outlet context passing out and made more direct use of the ref * Fix unexpected visibility in non-room areas * correctly provide visibility * re-add mobile chat handling * Improve call room view stability * split into two refs * add ViewedRoom usage * Disable * add roomViewId and related * (broken) juggle the iframe states proper... still needs fixing * Conditionals to manage the active iframe state better * add navigateRoom to be in both conditions for the nav button * Fix the view to correctly display the active iframe based on which is currently hosting the active call (juggling views) * Testing the iframe juggling. Seems to work for the first and second joins... so likely on the right path with this * add url as a param for widget url * fix backup iframe visibility * Much closer to the call state handling we want w/ hangups and joins * Fix the position of the member drawer to its correct location * Ensure drawer doesn't appear in call room * Better handling of the isCallActive in the join handler * Add ideal call room join behavior where text rooms to call room simply joins, but doesn't swap current view * Fix mobile call room default behavior from auto-join to displaying lobby * swap call status to be bound to call state and not active call id * Remove clean room ID and add default handler for if no active call has existed yet, but user clicks on show chat * Applies the correct changes to the call state and removes listeners of old active widget so we don't trigger hang ups on the new one (the element-call widget likes to spam the hang up response back several times for some reason long after you tell it to hang up) * Remove superfluous comments and Date.now() that was causing loading... bug when widgetId desynced * Remove Date.now() that was causing widgetId desync * add listener clearing, camel case es lint rule exception, remove unneeded else statements * Remove unused * Add widgetId as a getWidgetUrl param * Remove no longer needed files * revert ternary expression change and add to dependency array * add widgetId to correct pos in getWidgetUrl usage * Remove CallActivation * Move and rename RoomCallNavStatus * update imports and dependency array * Rename and clean up * Moved CallProvider * Fix spelling mistake * Fix to use shorthand prop * Remove unneeded logger.errors * Fixes element-call embedded support (but it seems to run poorly) * null the default url so that we fallback to the embedded version (would recommend hosting it until performance issue is determined) * Fix vite build to place element-call correctly for embedded npm package support * add vite preview as an npm script * Move files to more correct location * Add package-lock changes * Set dep version to exact * Fix path issue from moving file locations * Sets initial states so the iframes don't cause the other to fail with the npm embedded package * Revert navitem change * Just check for state on both which should only occur at initial * Fixes call initializing by default on mobile * Provides correct behavior when call isn't active and no activeClientWidgetApi exists yet * Corrects the state for the situations where both iframes are "active" (not necessarily visible) * Reduce code reuse in handleJoin * Seems to sort out the hangup status button bug the occurred after joining a call via lobby * Re-add the default view current active room behavior * Remove repetitive check * Add storing widget for comparing with (since we already store room id and the clientWidgetApi anyway) * Update rendering logic to clear up remaining rendering bug (straight to call -> lobby of another room and joining call from that interface -> lobby of that previous room and joining was leading to duplication of the user in lobbies. This was actually from listening to and acknowledging hangups from the viewed widget in CallProvider) * Prevent null rooms from ever rendering * This seems to manage the hangup state with the status bar button well enough that black screens should never be encountered * Remove viewed room setting here and pass the room to hang up (seems state doesn't update fast enough otherwise) * Remove unused * Properly declare new hangup method sig * Seems to avoid almost all invalid states (hang up while viewing another lobby and hitting join seems to black screen, sets the active call as the previous active room id, but does join the viewed room correctly) * Fix for cases where you're viewing a lobby and hang up your existing call and try to join lobby (was not rendering for the correct room id, but was joining the correct call prior) * Re-add intended switching behavior * More correct filter (viewedRoom can return false on that compare in some cases) * Seems to shore up the remaining state issues with the status bar hangup * Fix formatting * In widget hang up button should be handled correct now * Solves the CHCH sequence issue, CLJH remains * Fixes CLJH, found CCH * Solves CCH. Looks like CLCH left * A bit of an abomination, but adds a state counter to iteratively handle the diverse potential states (where a user can join from the nav bar or the join button, hang up from either as well, and account for the juggling iframes) Black screens shouldn't be occurring now. * Fix dependency array * Technically corrects the hangup button in the widget, should be more precise though * Bind the on messaging iframe for easier access in hangup/join handling * Far cleaner and more sensible handling of the call window... I just really don't like the idea of sending a click event, but right now the element-call code treats preload/skipLobby hangups (sent from our end) as if they had no lobby at all and thus black screens. Other implementation was working around that without just sending a click event on the iframe's hangup button. * Fixes a bug where if you left a call then went to a lobby and joined it didn't update the actual activeCallRoomId * Fixes complaints of null contentDocument in iframe * Update to use new icons (thank you) * Remove unneeded prop * Re-arrange more options and add checks for each option to see if it is a call room (probably should manage a state to see if a header is already on screen and provide a slightly modified visual based on that for call rooms) * Invert icons to show the state instead of the action they will perform (more visual clarity) * Update src/app/features/room-nav/RoomCallNavStatus.tsx Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com> * Update src/app/features/room-nav/RoomCallNavStatus.tsx Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com> * Update src/app/features/room-nav/RoomCallNavStatus.tsx Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com> * Update src/app/features/room-nav/RoomCallNavStatus.tsx Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com> * Update src/app/features/room-nav/RoomCallNavStatus.tsx Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com> * Update src/app/features/room-nav/RoomCallNavStatus.tsx Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com> * Update src/app/features/room-nav/RoomNavItem.tsx Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com> * Update src/app/features/room/RoomViewHeader.tsx Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com> * Update src/app/features/room-nav/RoomNavItem.tsx Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com> * Update src/app/features/room-nav/RoomNavItem.tsx Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com> * Update src/app/features/room-nav/RoomNavItem.tsx Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com> * Update src/app/features/room-nav/RoomNavItem.tsx Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com> * Update src/app/features/room-nav/RoomNavItem.tsx Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com> * Update src/app/features/room-nav/RoomNavItem.tsx Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com> * Update src/app/features/room-nav/RoomNavUser.tsx Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com> * Update src/app/features/room-nav/RoomNavUser.tsx Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com> * Update src/app/features/room-nav/RoomNavUser.tsx Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com> * Update src/app/pages/client/space/Space.tsx Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com> * Update src/app/features/call/CallView.tsx Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com> * Update src/app/features/call/CallView.tsx Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com> * Update src/app/features/call/CallView.tsx Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com> * Update src/app/features/room/RoomView.tsx Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com> * Update src/app/features/room/RoomView.tsx Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com> * adjust room header for calling * Remove No Active Call text when not in a call * update element-call version * Update src/app/features/room/RoomViewHeader.tsx Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com> * Update src/app/features/room/RoomViewHeader.tsx Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com> * Update src/app/features/room/RoomViewHeader.tsx Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com> * Update src/app/features/room/RoomViewHeader.tsx Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com> * Update src/app/features/room/RoomViewHeader.tsx Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com> * Revert most changes to Space.tsx * Show call room even if category is collapsed * changes to RoomNavItem, RoomNavUser and add useCallMembers * Rename file, sprinkle in the magic one line for matrixRTCSession. and remove comment block * swap userId to callMembership as a prop and add a nullchecked userId that uses the membership sender * update references to use callMembership instead * Simplify RoomNavUser Discard future functionality since it probably won't exist in time for merging this PR * Simplify RoomViewHeader.tsx Remove unused UI elements that don't have implemented functionality. Replace custom function for checking if a room is direct with a standard hook. * Update Room.tsx to accomodate restructuring of Room, RoomView and CallView * Update RoomView.tsx to accomodate restructuring of Room, RoomView and CallView * Update CallView.tsx to accomodate restructuring of Room, RoomView and CallView + suggested changes * add call related permissions to room permissions * bump element call to 0.16.3, apply cinny theme to element call ui, replace element call lobby (backup iframe) with custom ui and only use element call for the in-call ui * update text spacing * redo roomcallnavstatus ui, force user preferred mute/video states when first joining calls, update variable names and remove unnecessary logic * set default mic state to enabled * clean up ts/eslint errors * remove debug logs * format using prettier rules from project prettierrc * fix: show call nav status while active call is ongoing * fix: clean up call nav/call view console warnings * fix: keep call media controls visible before joining * fix: restore header icon button fill behavior Fixes regression from b074d421b66eb4d8b600dfa55b967e6c4f783044. * style: blend header and room input button styles in call nav * fix page header background color on room view header * fix: permissions and room icon resolution (#2) * Initialize call state upon room creation for call rooms, remove subsequent useless permission * handle case of missing call permissions * use call icon for room item summary when room is call room * replace previous icon src resolution function with a more robust approach * replace usages of previous icon resolution function with new implementation * fix room name not updating for a while when changed * set up framework for room power level overrides upon room creation * override join call permission to all members upon room creation * fix broken usages of RoomIcon * remove unneeded import * remove unnecessary logic * format with prettier * feat: show connected/connecting call status * fix: preserve navigation context when opening non-call rooms * fix: reset room name state when room instance changes * feat: Disable webcam by default using callIntent='audio' * Add channel type selecor * Add option for voice rooms, which for now sets the default selected option in the creation modal * Add proper support for room selection from the enu * Move enums to `types.ts` and change icons selection to use `getRoomIconSrc` * fix: group duplicate conditions into one * fix: typo * refactor: rename kind/voice to access/type and simplify room creation - rename CreateRoomVoice to CreateRoomType and modal voice state to type - rename CreateRoomKind to CreateRoomAccess and KindSelector to AccessSelector - propagate access/defaultAccess through create room and create space forms - set voice room power levels via createRoom power_level_content_override * refactor: unify join rule icon mapping and update call/space icons - bump folds from 2.5.0 to 2.6.0 - replace separate room/space join-rule icon hooks with useJoinRuleIcons(roomType) - route join-rule icons through getRoomIconSrc for consistent room type handling - simplify getRoomIconSrc by removing the locked override path - use VolumeHighGlobe for public call rooms and VolumeHighLock for private call rooms * chore(deps): bump matrix-widget-api to 1.17 and remove react-sdk-module-api * fix: adapt SmallWidget to matrix-widget-api 1.17.0 API * fix: render call room chat only when chat panel is open * fix(permissions): show call settings permissions only for call rooms * refactor: remove redundant room-nav props/guards and minor naming cleanup * fix: use PhoneDown icon for hang up action * chore(hooks): remove unused useStateEvents hook * fix(room): enable members drawer toggle in desktop call rooms - show filled User icon when the drawer is open * Revert "fix: adapt SmallWidget to matrix-widget-api 1.17.0 API" This reverts commit a4c34eff8a53669afa095be41b26f3e54adab0ed. * fix: semi-revert matrix-widget-api 1.17 bump and migrate to 1.13 API * fix(call): wait for Element Call contentLoaded before widget handshake - fixes not working on firefox * fix missing imports * improve create room type design and add beta badge for voice room * add beta badge for voice room in space lobby * fix create room modal title * pass missing roomType param to roomicon component * add roomtype * Add deafen functionality (#2695) * feat:(deafen functionality) * feat:(reworked voice controls for deafen) * ref:(use muted instead of volume for deafen) * fix:(backpedal audio_enabled rename) * ref:(renaming of deafened vars) * add stack avatar component * add call status bar - WIP * remove call status from navigation drawer * fix deprecated method use in use call members hook * render new call status bar * move call widget driver to plugins * remove old status bar usage from navigation drawer * add call session and joined hook * remove unknown changes * upgrade widget api * add element call embed plugin * remove unknown change * add call embed atom * add call embed hooks and context * add call embed provider * replace old call implementation * stop joining other call on second click if already in a call * refactor embed placement hook * add merge border prop to sequence card * add call preferences * add prescreen to call view - WIP * prevent joining new call if already in call * make call layout adaptive * render call chat as right panel * show call members in prescreen * render call join leave event in timeline * remove unknown rewrite in docker-nginx file * render call event without hidden event enable --------- Co-authored-by: Gigiaj Co-authored-by: Jaggar <18173108+GigiaJ@users.noreply.github.com> Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com> Co-authored-by: Gimle Larpes Co-authored-by: YoJames2019 Co-authored-by: YoJames2019 Co-authored-by: hazre Co-authored-by: haz <37149950+hazre@users.noreply.github.com> Co-authored-by: Ajay Bura <32841439+ajbura@users.noreply.github.com> Co-authored-by: James <49845975+YoJames2019@users.noreply.github.com> Co-authored-by: James Reilly Co-authored-by: Tymek Co-authored-by: Thedustbuster <92692948+Thedustbustr@users.noreply.github.com> --- config.json | 10 +- package-lock.json | 15 +- package.json | 3 + src/app/components/CallEmbedProvider.tsx | 63 +++ src/app/components/JoinRulesSwitcher.tsx | 30 +- ...ector.tsx => CreateRoomAccessSelector.tsx} | 48 ++- .../create-room/CreateRoomTypeSelector.tsx | 75 ++++ src/app/components/create-room/index.ts | 3 +- src/app/components/create-room/types.ts | 10 + src/app/components/create-room/utils.ts | 30 +- .../autocomplete/RoomMentionAutocomplete.tsx | 3 +- src/app/components/room-avatar/RoomAvatar.tsx | 14 +- .../components/sequence-card/SequenceCard.tsx | 3 +- src/app/components/sequence-card/style.css.ts | 13 +- .../stacked-avatar/StackedAvatar.tsx | 18 + src/app/components/stacked-avatar/index.ts | 1 + .../components/stacked-avatar/styles.css.ts | 59 +++ src/app/components/user-profile/UserChips.tsx | 2 +- src/app/features/add-existing/AddExisting.tsx | 6 +- src/app/features/call-status/CallControl.tsx | 168 ++++++++ src/app/features/call-status/CallRoomName.tsx | 55 +++ src/app/features/call-status/CallStatus.tsx | 52 +++ src/app/features/call-status/LiveChip.tsx | 137 +++++++ src/app/features/call-status/MemberGlance.tsx | 72 ++++ src/app/features/call-status/components.tsx | 9 + src/app/features/call-status/index.ts | 1 + src/app/features/call-status/styles.css.ts | 17 + src/app/features/call/CallMemberCard.tsx | 121 ++++++ src/app/features/call/CallView.tsx | 105 +++++ src/app/features/call/Controls.tsx | 145 +++++++ src/app/features/call/PrescreenControls.tsx | 68 ++++ src/app/features/call/styles.css.ts | 20 + .../common-settings/general/RoomJoinRules.tsx | 8 +- .../common-settings/general/RoomProfile.tsx | 4 +- src/app/features/create-room/CreateRoom.tsx | 85 +++-- .../features/create-room/CreateRoomModal.tsx | 9 +- src/app/features/create-space/CreateSpace.tsx | 46 +-- src/app/features/lobby/LobbyHeader.tsx | 15 +- src/app/features/lobby/RoomItem.tsx | 8 +- src/app/features/lobby/SpaceItem.tsx | 20 +- .../features/message-search/SearchFilters.tsx | 11 +- .../message-search/SearchResultGroup.tsx | 7 +- src/app/features/room-nav/RoomNavItem.tsx | 77 +++- .../features/room-settings/RoomSettings.tsx | 1 + .../room-settings/permissions/Permissions.tsx | 2 +- .../permissions/usePermissionItems.ts | 18 +- src/app/features/room/CallChatView.tsx | 57 +++ src/app/features/room/Room.tsx | 36 +- src/app/features/room/RoomTimeline.tsx | 46 +++ src/app/features/room/RoomView.tsx | 10 +- src/app/features/room/RoomViewHeader.tsx | 79 +++- src/app/features/search/Search.tsx | 2 +- .../features/space-settings/SpaceSettings.tsx | 2 +- src/app/hooks/useCall.ts | 55 +++ src/app/hooks/useCallEmbed.ts | 130 +++++++ src/app/hooks/useRoomMeta.ts | 2 + src/app/hooks/useStateEvents.ts | 28 -- src/app/pages/CallStatusRenderer.tsx | 11 + src/app/pages/Router.tsx | 23 +- .../pages/client/ClientInitStorageAtom.tsx | 8 +- src/app/pages/client/inbox/Notifications.tsx | 7 +- src/app/pages/client/space/Space.tsx | 13 +- src/app/plugins/call/CallControl.ts | 117 ++++++ src/app/plugins/call/CallControlState.ts | 13 + src/app/plugins/call/CallEmbed.ts | 358 ++++++++++++++++++ src/app/plugins/call/CallWidgetDriver.ts | 340 +++++++++++++++++ src/app/plugins/call/hooks.ts | 49 +++ src/app/plugins/call/index.ts | 3 + src/app/plugins/call/types.ts | 25 ++ src/app/plugins/call/utils.ts | 118 ++++++ src/app/state/callEmbed.ts | 20 + src/app/state/callPreferences.ts | 39 ++ src/app/state/createRoomModal.ts | 2 + src/app/state/hooks/callPreferences.ts | 61 +++ src/app/state/hooks/createRoomModal.ts | 7 +- src/app/utils/room.ts | 51 ++- src/types/matrix/room.ts | 3 + vite.config.js | 13 +- 78 files changed, 3154 insertions(+), 261 deletions(-) create mode 100644 src/app/components/CallEmbedProvider.tsx rename src/app/components/create-room/{CreateRoomKindSelector.tsx => CreateRoomAccessSelector.tsx} (54%) create mode 100644 src/app/components/create-room/CreateRoomTypeSelector.tsx create mode 100644 src/app/components/create-room/types.ts create mode 100644 src/app/components/stacked-avatar/StackedAvatar.tsx create mode 100644 src/app/components/stacked-avatar/index.ts create mode 100644 src/app/components/stacked-avatar/styles.css.ts create mode 100644 src/app/features/call-status/CallControl.tsx create mode 100644 src/app/features/call-status/CallRoomName.tsx create mode 100644 src/app/features/call-status/CallStatus.tsx create mode 100644 src/app/features/call-status/LiveChip.tsx create mode 100644 src/app/features/call-status/MemberGlance.tsx create mode 100644 src/app/features/call-status/components.tsx create mode 100644 src/app/features/call-status/index.ts create mode 100644 src/app/features/call-status/styles.css.ts create mode 100644 src/app/features/call/CallMemberCard.tsx create mode 100644 src/app/features/call/CallView.tsx create mode 100644 src/app/features/call/Controls.tsx create mode 100644 src/app/features/call/PrescreenControls.tsx create mode 100644 src/app/features/call/styles.css.ts create mode 100644 src/app/features/room/CallChatView.tsx create mode 100644 src/app/hooks/useCall.ts create mode 100644 src/app/hooks/useCallEmbed.ts delete mode 100644 src/app/hooks/useStateEvents.ts create mode 100644 src/app/pages/CallStatusRenderer.tsx create mode 100644 src/app/plugins/call/CallControl.ts create mode 100644 src/app/plugins/call/CallControlState.ts create mode 100644 src/app/plugins/call/CallEmbed.ts create mode 100644 src/app/plugins/call/CallWidgetDriver.ts create mode 100644 src/app/plugins/call/hooks.ts create mode 100644 src/app/plugins/call/index.ts create mode 100644 src/app/plugins/call/types.ts create mode 100644 src/app/plugins/call/utils.ts create mode 100644 src/app/state/callEmbed.ts create mode 100644 src/app/state/callPreferences.ts create mode 100644 src/app/state/hooks/callPreferences.ts diff --git a/config.json b/config.json index 644f0eb6..aed232e6 100644 --- a/config.json +++ b/config.json @@ -1,12 +1,6 @@ { "defaultHomeserver": 1, - "homeserverList": [ - "converser.eu", - "matrix.org", - "mozilla.org", - "unredacted.org", - "xmr.se" - ], + "homeserverList": ["converser.eu", "matrix.org", "mozilla.org", "unredacted.org", "xmr.se"], "allowCustomHomeservers": true, "featuredCommunities": { @@ -27,7 +21,7 @@ "#PrivSec.dev:arcticfoxes.net", "#disroot:aria-net.org" ], - "servers": [ "matrix.org", "mozilla.org", "unredacted.org" ] + "servers": ["matrix.org", "mozilla.org", "unredacted.org"] }, "hashRouter": { diff --git a/package-lock.json b/package-lock.json index 05466866..4648a9fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,7 @@ "linkify-react": "4.3.2", "linkifyjs": "4.3.2", "matrix-js-sdk": "38.2.0", + "matrix-widget-api": "1.17.0", "millify": "6.1.0", "pdfjs-dist": "4.2.67", "prismjs": "1.30.0", @@ -65,6 +66,7 @@ "ua-parser-js": "1.0.35" }, "devDependencies": { + "@element-hq/element-call-embedded": "0.16.3", "@esbuild-plugins/node-globals-polyfill": "0.2.3", "@rollup/plugin-inject": "5.0.3", "@rollup/plugin-wasm": "6.1.1", @@ -1649,6 +1651,12 @@ "node": ">=6.9.0" } }, + "node_modules/@element-hq/element-call-embedded": { + "version": "0.16.3", + "resolved": "https://registry.npmjs.org/@element-hq/element-call-embedded/-/element-call-embedded-0.16.3.tgz", + "integrity": "sha512-OViKJonDaDNVBUW9WdV9mk78/Ruh34C7XsEgt3O8D9z+64C39elbIgllHSoH5S12IRlv9RYrrV37FZLo6QWsDQ==", + "dev": true + }, "node_modules/@emotion/hash": { "version": "0.9.2", "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", @@ -8666,9 +8674,9 @@ } }, "node_modules/matrix-widget-api": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.13.1.tgz", - "integrity": "sha512-mkOHUVzaN018TCbObfGOSaMW2GoUxOfcxNNlTVx5/HeMk3OSQPQM0C9oEME5Liiv/dBUoSrEB64V8wF7e/gb1w==", + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.17.0.tgz", + "integrity": "sha512-5FHoo3iEP3Bdlv5jsYPWOqj+pGdFQNLWnJLiB0V7Ygne7bb+Gsj3ibyFyHWC6BVw+Z+tSW4ljHpO17I9TwStwQ==", "license": "Apache-2.0", "dependencies": { "@types/events": "^3.0.0", @@ -10909,6 +10917,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.4.tgz", "integrity": "sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==", "dev": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index de8ee72d..4eeead2d 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "scripts": { "start": "vite", "build": "vite build", + "preview": "vite preview", "lint": "yarn check:eslint && yarn check:prettier", "check:eslint": "eslint src/*", "check:prettier": "prettier --check .", @@ -55,6 +56,7 @@ "linkify-react": "4.3.2", "linkifyjs": "4.3.2", "matrix-js-sdk": "38.2.0", + "matrix-widget-api": "1.17.0", "millify": "6.1.0", "pdfjs-dist": "4.2.67", "prismjs": "1.30.0", @@ -76,6 +78,7 @@ "ua-parser-js": "1.0.35" }, "devDependencies": { + "@element-hq/element-call-embedded": "0.16.3", "@esbuild-plugins/node-globals-polyfill": "0.2.3", "@rollup/plugin-inject": "5.0.3", "@rollup/plugin-wasm": "6.1.1", diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx new file mode 100644 index 00000000..9f4b3f2b --- /dev/null +++ b/src/app/components/CallEmbedProvider.tsx @@ -0,0 +1,63 @@ +import React, { ReactNode, useCallback, useRef } from 'react'; +import { useAtomValue, useSetAtom } from 'jotai'; +import { + CallEmbedContextProvider, + CallEmbedRefContextProvider, + useCallHangupEvent, + useCallJoined, + useCallThemeSync, +} from '../hooks/useCallEmbed'; +import { callChatAtom, callEmbedAtom } from '../state/callEmbed'; +import { CallEmbed } from '../plugins/call'; +import { useSelectedRoom } from '../hooks/router/useSelectedRoom'; +import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize'; + +function CallUtils({ embed }: { embed: CallEmbed }) { + const setCallEmbed = useSetAtom(callEmbedAtom); + + useCallThemeSync(embed); + useCallHangupEvent( + embed, + useCallback(() => { + setCallEmbed(undefined); + }, [setCallEmbed]) + ); + + return null; +} + +type CallEmbedProviderProps = { + children?: ReactNode; +}; +export function CallEmbedProvider({ children }: CallEmbedProviderProps) { + const callEmbed = useAtomValue(callEmbedAtom); + const callEmbedRef = useRef(null); + const joined = useCallJoined(callEmbed); + + const selectedRoom = useSelectedRoom(); + const chat = useAtomValue(callChatAtom); + const screenSize = useScreenSizeContext(); + + const chatOnlyView = chat && screenSize !== ScreenSize.Desktop; + + const callVisible = callEmbed && selectedRoom === callEmbed.roomId && joined && !chatOnlyView; + + return ( + + {callEmbed && } + {children} +
+ + ); +} diff --git a/src/app/components/JoinRulesSwitcher.tsx b/src/app/components/JoinRulesSwitcher.tsx index 9507317a..bbc0a65d 100644 --- a/src/app/components/JoinRulesSwitcher.tsx +++ b/src/app/components/JoinRulesSwitcher.tsx @@ -16,34 +16,24 @@ import { import { JoinRule } from 'matrix-js-sdk'; import FocusTrap from 'focus-trap-react'; import { stopPropagation } from '../utils/keyboard'; +import { getRoomIconSrc } from '../utils/room'; export type ExtraJoinRules = 'knock_restricted'; export type ExtendedJoinRules = JoinRule | ExtraJoinRules; type JoinRuleIcons = Record; -export const useRoomJoinRuleIcon = (): JoinRuleIcons => + +export const useJoinRuleIcons = (roomType?: string): JoinRuleIcons => useMemo( () => ({ - [JoinRule.Invite]: Icons.HashLock, - [JoinRule.Knock]: Icons.HashLock, - knock_restricted: Icons.Hash, - [JoinRule.Restricted]: Icons.Hash, - [JoinRule.Public]: Icons.HashGlobe, - [JoinRule.Private]: Icons.HashLock, + [JoinRule.Invite]: getRoomIconSrc(Icons, roomType, JoinRule.Invite), + [JoinRule.Knock]: getRoomIconSrc(Icons, roomType, JoinRule.Knock), + knock_restricted: getRoomIconSrc(Icons, roomType, JoinRule.Restricted), + [JoinRule.Restricted]: getRoomIconSrc(Icons, roomType, JoinRule.Restricted), + [JoinRule.Public]: getRoomIconSrc(Icons, roomType, JoinRule.Public), + [JoinRule.Private]: getRoomIconSrc(Icons, roomType, JoinRule.Private), }), - [] - ); -export const useSpaceJoinRuleIcon = (): JoinRuleIcons => - useMemo( - () => ({ - [JoinRule.Invite]: Icons.SpaceLock, - [JoinRule.Knock]: Icons.SpaceLock, - knock_restricted: Icons.Space, - [JoinRule.Restricted]: Icons.Space, - [JoinRule.Public]: Icons.SpaceGlobe, - [JoinRule.Private]: Icons.SpaceLock, - }), - [] + [roomType] ); type JoinRuleLabels = Record; diff --git a/src/app/components/create-room/CreateRoomKindSelector.tsx b/src/app/components/create-room/CreateRoomAccessSelector.tsx similarity index 54% rename from src/app/components/create-room/CreateRoomKindSelector.tsx rename to src/app/components/create-room/CreateRoomAccessSelector.tsx index 096954fb..35f39af8 100644 --- a/src/app/components/create-room/CreateRoomKindSelector.tsx +++ b/src/app/components/create-room/CreateRoomAccessSelector.tsx @@ -2,43 +2,39 @@ import React from 'react'; import { Box, Text, Icon, Icons, config, IconSrc } from 'folds'; import { SequenceCard } from '../sequence-card'; import { SettingTile } from '../setting-tile'; +import { CreateRoomAccess } from './types'; -export enum CreateRoomKind { - Private = 'private', - Restricted = 'restricted', - Public = 'public', -} -type CreateRoomKindSelectorProps = { - value?: CreateRoomKind; - onSelect: (value: CreateRoomKind) => void; +type CreateRoomAccessSelectorProps = { + value?: CreateRoomAccess; + onSelect: (value: CreateRoomAccess) => void; canRestrict?: boolean; disabled?: boolean; - getIcon: (kind: CreateRoomKind) => IconSrc; + getIcon: (access: CreateRoomAccess) => IconSrc; }; -export function CreateRoomKindSelector({ +export function CreateRoomAccessSelector({ value, onSelect, canRestrict, disabled, getIcon, -}: CreateRoomKindSelectorProps) { +}: CreateRoomAccessSelectorProps) { return ( {canRestrict && ( onSelect(CreateRoomKind.Restricted)} + aria-pressed={value === CreateRoomAccess.Restricted} + onClick={() => onSelect(CreateRoomAccess.Restricted)} disabled={disabled} > } - after={value === CreateRoomKind.Restricted && } + before={} + after={value === CreateRoomAccess.Restricted && } > Restricted @@ -49,18 +45,18 @@ export function CreateRoomKindSelector({ )} onSelect(CreateRoomKind.Private)} + aria-pressed={value === CreateRoomAccess.Private} + onClick={() => onSelect(CreateRoomAccess.Private)} disabled={disabled} > } - after={value === CreateRoomKind.Private && } + before={} + after={value === CreateRoomAccess.Private && } > Private @@ -70,18 +66,18 @@ export function CreateRoomKindSelector({ onSelect(CreateRoomKind.Public)} + aria-pressed={value === CreateRoomAccess.Public} + onClick={() => onSelect(CreateRoomAccess.Public)} disabled={disabled} > } - after={value === CreateRoomKind.Public && } + before={} + after={value === CreateRoomAccess.Public && } > Public diff --git a/src/app/components/create-room/CreateRoomTypeSelector.tsx b/src/app/components/create-room/CreateRoomTypeSelector.tsx new file mode 100644 index 00000000..b1f69c39 --- /dev/null +++ b/src/app/components/create-room/CreateRoomTypeSelector.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { Box, Text, Icon, Icons, config, IconSrc } from 'folds'; +import { SequenceCard } from '../sequence-card'; +import { SettingTile } from '../setting-tile'; +import { CreateRoomType } from './types'; +import { BetaNoticeBadge } from '../BetaNoticeBadge'; + +type CreateRoomTypeSelectorProps = { + value?: CreateRoomType; + onSelect: (value: CreateRoomType) => void; + disabled?: boolean; + getIcon: (type: CreateRoomType) => IconSrc; +}; +export function CreateRoomTypeSelector({ + value, + onSelect, + disabled, + getIcon, +}: CreateRoomTypeSelectorProps) { + return ( + + onSelect(CreateRoomType.TextRoom)} + disabled={disabled} + > + } + after={value === CreateRoomType.TextRoom && } + > + + + Chat Room + + + - Messages, photos, and videos. + + + + + onSelect(CreateRoomType.VoiceRoom)} + disabled={disabled} + > + } + after={value === CreateRoomType.VoiceRoom && } + > + + + Voice Room + + + - Live audio and video conversations. + + + + + + + ); +} diff --git a/src/app/components/create-room/index.ts b/src/app/components/create-room/index.ts index ffd9cb3d..cce6e037 100644 --- a/src/app/components/create-room/index.ts +++ b/src/app/components/create-room/index.ts @@ -1,5 +1,6 @@ -export * from './CreateRoomKindSelector'; +export * from './CreateRoomAccessSelector'; export * from './CreateRoomAliasInput'; export * from './RoomVersionSelector'; export * from './utils'; export * from './AdditionalCreatorInput'; +export * from './types'; diff --git a/src/app/components/create-room/types.ts b/src/app/components/create-room/types.ts new file mode 100644 index 00000000..8b54587d --- /dev/null +++ b/src/app/components/create-room/types.ts @@ -0,0 +1,10 @@ +export enum CreateRoomType { + TextRoom = 'text', + VoiceRoom = 'voice', +} + +export enum CreateRoomAccess { + Private = 'private', + Restricted = 'restricted', + Public = 'public', +} diff --git a/src/app/components/create-room/utils.ts b/src/app/components/create-room/utils.ts index d0a45c3d..f3e699aa 100644 --- a/src/app/components/create-room/utils.ts +++ b/src/app/components/create-room/utils.ts @@ -7,10 +7,10 @@ import { Room, } from 'matrix-js-sdk'; import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types'; -import { CreateRoomKind } from './CreateRoomKindSelector'; import { RoomType, StateEvent } from '../../../types/matrix/room'; import { getViaServers } from '../../plugins/via-servers'; import { getMxIdServer } from '../../utils/matrix'; +import { CreateRoomAccess } from './types'; export const createRoomCreationContent = ( type: RoomType | undefined, @@ -32,7 +32,7 @@ export const createRoomCreationContent = ( }; export const createRoomJoinRulesState = ( - kind: CreateRoomKind, + access: CreateRoomAccess, parent: Room | undefined, knock: boolean ) => { @@ -40,13 +40,13 @@ export const createRoomJoinRulesState = ( join_rule: knock ? JoinRule.Knock : JoinRule.Invite, }; - if (kind === CreateRoomKind.Public) { + if (access === CreateRoomAccess.Public) { content = { join_rule: JoinRule.Public, }; } - if (kind === CreateRoomKind.Restricted && parent) { + if (access === CreateRoomAccess.Restricted && parent) { content = { join_rule: knock ? ('knock_restricted' as JoinRule) : JoinRule.Restricted, allow: [ @@ -86,11 +86,23 @@ export const createRoomEncryptionState = () => ({ }, }); +export const createRoomCallState = () => ({ + type: 'org.matrix.msc3401.call', + state_key: '', + content: {}, +}); + +export const createVoiceRoomPowerLevelsOverride = () => ({ + events: { + [StateEvent.GroupCallMemberPrefix]: 0, + }, +}); + export type CreateRoomData = { version: string; type?: RoomType; parent?: Room; - kind: CreateRoomKind; + access: CreateRoomAccess; name: string; topic?: string; aliasLocalPart?: string; @@ -110,7 +122,11 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis initialState.push(createRoomParentState(data.parent)); } - initialState.push(createRoomJoinRulesState(data.kind, data.parent, data.knock)); + if (data.type === RoomType.Call) { + initialState.push(createRoomCallState()); + } + + initialState.push(createRoomJoinRulesState(data.access, data.parent, data.knock)); const options: ICreateRoomOpts = { room_version: data.version, @@ -122,6 +138,8 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis data.allowFederation, data.additionalCreators ), + power_level_content_override: + data.type === RoomType.Call ? createVoiceRoomPowerLevelsOverride() : undefined, initial_state: initialState, }; diff --git a/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx b/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx index b0c64f60..377cecab 100644 --- a/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx +++ b/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx @@ -169,12 +169,13 @@ export function RoomMentionAutocomplete({ )} /> ) : ( - + )} } diff --git a/src/app/components/room-avatar/RoomAvatar.tsx b/src/app/components/room-avatar/RoomAvatar.tsx index 23f3998d..cbcd626a 100644 --- a/src/app/components/room-avatar/RoomAvatar.tsx +++ b/src/app/components/room-avatar/RoomAvatar.tsx @@ -2,7 +2,7 @@ import { JoinRule } from 'matrix-js-sdk'; import { AvatarFallback, AvatarImage, Icon, Icons, color } from 'folds'; import React, { ComponentProps, ReactEventHandler, ReactNode, forwardRef, useState } from 'react'; import * as css from './RoomAvatar.css'; -import { joinRuleToIconSrc } from '../../utils/room'; +import { getRoomIconSrc } from '../../utils/room'; import colorMXID from '../../../util/colorMXID'; type RoomAvatarProps = { @@ -44,13 +44,9 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps export const RoomIcon = forwardRef< SVGSVGElement, Omit, 'src'> & { - joinRule: JoinRule; - space?: boolean; + joinRule?: JoinRule; + roomType?: string; } ->(({ joinRule, space, ...props }, ref) => ( - +>(({ joinRule, roomType, ...props }, ref) => ( + )); diff --git a/src/app/components/sequence-card/SequenceCard.tsx b/src/app/components/sequence-card/SequenceCard.tsx index d0e77ae6..46d5ea0f 100644 --- a/src/app/components/sequence-card/SequenceCard.tsx +++ b/src/app/components/sequence-card/SequenceCard.tsx @@ -17,6 +17,7 @@ export const SequenceCard = as< firstChild, lastChild, outlined, + mergeBorder, ...props }, ref @@ -24,7 +25,7 @@ export const SequenceCard = as< ( + ({ size, variant, className, ...props }, ref) => ( + + ) +); diff --git a/src/app/components/stacked-avatar/index.ts b/src/app/components/stacked-avatar/index.ts new file mode 100644 index 00000000..63d9f56d --- /dev/null +++ b/src/app/components/stacked-avatar/index.ts @@ -0,0 +1 @@ +export * from './StackedAvatar'; diff --git a/src/app/components/stacked-avatar/styles.css.ts b/src/app/components/stacked-avatar/styles.css.ts new file mode 100644 index 00000000..3bbf7271 --- /dev/null +++ b/src/app/components/stacked-avatar/styles.css.ts @@ -0,0 +1,59 @@ +import { ComplexStyleRule } from '@vanilla-extract/css'; +import { recipe, RecipeVariants } from '@vanilla-extract/recipes'; +import { color, config, ContainerColor, toRem } from 'folds'; + +const getVariant = (variant: ContainerColor): ComplexStyleRule => ({ + outlineColor: color[variant].Container, +}); + +export const StackedAvatar = recipe({ + base: { + backgroundColor: color.Surface.Container, + outlineStyle: 'solid', + selectors: { + '&:first-child': { + marginLeft: 0, + }, + 'button&': { + cursor: 'pointer', + }, + }, + }, + + variants: { + size: { + '200': { + marginLeft: toRem(-6), + outlineWidth: config.borderWidth.B300, + }, + '300': { + marginLeft: toRem(-9), + outlineWidth: config.borderWidth.B400, + }, + '400': { + marginLeft: toRem(-10.5), + outlineWidth: config.borderWidth.B500, + }, + '500': { + marginLeft: toRem(-13), + outlineWidth: config.borderWidth.B600, + }, + }, + variant: { + Background: getVariant('Background'), + Surface: getVariant('Surface'), + SurfaceVariant: getVariant('SurfaceVariant'), + Primary: getVariant('Primary'), + Secondary: getVariant('Secondary'), + Success: getVariant('Success'), + Warning: getVariant('Warning'), + Critical: getVariant('Critical'), + }, + }, + defaultVariants: { + size: '400', + variant: 'Surface', + }, +}); + +export type StackedAvatarVariants = RecipeVariants; diff --git a/src/app/components/user-profile/UserChips.tsx b/src/app/components/user-profile/UserChips.tsx index 53e6618b..82880634 100644 --- a/src/app/components/user-profile/UserChips.tsx +++ b/src/app/components/user-profile/UserChips.tsx @@ -323,7 +323,7 @@ export function MutualRoomsChip({ userId }: { userId: string }) { )} /> ) : ( - + )} } diff --git a/src/app/features/add-existing/AddExisting.tsx b/src/app/features/add-existing/AddExisting.tsx index cbae018f..d100096b 100644 --- a/src/app/features/add-existing/AddExisting.tsx +++ b/src/app/features/add-existing/AddExisting.tsx @@ -291,7 +291,11 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM )} /> ) : ( - + )} } diff --git a/src/app/features/call-status/CallControl.tsx b/src/app/features/call-status/CallControl.tsx new file mode 100644 index 00000000..39cd975b --- /dev/null +++ b/src/app/features/call-status/CallControl.tsx @@ -0,0 +1,168 @@ +import { Box, Chip, Icon, IconButton, Icons, Text, Tooltip, TooltipProvider } from 'folds'; +import React, { useState } from 'react'; +import { StatusDivider } from './components'; +import { CallEmbed, useCallControlState } from '../../plugins/call'; + +type MicrophoneButtonProps = { + enabled: boolean; + onToggle: () => Promise; +}; +function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) { + return ( + + {enabled ? 'Turn Off Microphone' : 'Turn On Microphone'} + + } + > + {(anchorRef) => ( + onToggle()} + outlined={!enabled} + > + + + )} + + ); +} + +type SoundButtonProps = { + enabled: boolean; + onToggle: () => void; +}; +function SoundButton({ enabled, onToggle }: SoundButtonProps) { + return ( + + {enabled ? 'Turn Off Sound' : 'Turn On Sound'} + + } + > + {(anchorRef) => ( + onToggle()} + outlined={!enabled} + > + + + )} + + ); +} + +type VideoButtonProps = { + enabled: boolean; + onToggle: () => Promise; +}; +function VideoButton({ enabled, onToggle }: VideoButtonProps) { + return ( + + {enabled ? 'Stop Camera' : 'Start Camera'} + + } + > + {(anchorRef) => ( + onToggle()} + outlined={enabled} + > + + + )} + + ); +} + +function ScreenShareButton() { + const [enabled, setEnabled] = useState(false); + + return ( + + {enabled ? 'Stop Screenshare' : 'Start Screenshare'} + + } + > + {(anchorRef) => ( + setEnabled(!enabled)} + outlined={enabled} + > + + + )} + + ); +} + +export function CallControl({ callEmbed }: { callEmbed: CallEmbed }) { + const { microphone, video, sound } = useCallControlState(callEmbed.control); + + return ( + + + callEmbed.control.toggleMicrophone()} + /> + callEmbed.control.toggleSound()} /> + + + + callEmbed.control.toggleVideo()} /> + {false && } + + + } + outlined + onClick={() => callEmbed.hangup()} + > + + End + + + + ); +} diff --git a/src/app/features/call-status/CallRoomName.tsx b/src/app/features/call-status/CallRoomName.tsx new file mode 100644 index 00000000..39f0e914 --- /dev/null +++ b/src/app/features/call-status/CallRoomName.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { Room } from 'matrix-js-sdk'; +import { Chip, Text } from 'folds'; +import { useAtomValue } from 'jotai'; +import { useRoomName } from '../../hooks/useRoomMeta'; +import { RoomIcon } from '../../components/room-avatar'; +import { roomToParentsAtom } from '../../state/room/roomToParents'; +import { getAllParents, guessPerfectParent } from '../../utils/room'; +import { useOrphanSpaces } from '../../state/hooks/roomList'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { allRoomsAtom } from '../../state/room-list/roomList'; +import { mDirectAtom } from '../../state/mDirectList'; +import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom'; +import { useRoomNavigate } from '../../hooks/useRoomNavigate'; + +type CallRoomNameProps = { + room: Room; +}; +export function CallRoomName({ room }: CallRoomNameProps) { + const mx = useMatrixClient(); + const name = useRoomName(room); + const roomToParents = useAtomValue(roomToParentsAtom); + const orphanSpaces = useOrphanSpaces(mx, allRoomsAtom, roomToParents); + const mDirects = useAtomValue(mDirectAtom); + const dm = mDirects.has(room.roomId); + + const allRoomsSet = useAllJoinedRoomsSet(); + const getRoom = useGetRoom(allRoomsSet); + + const allParents = getAllParents(roomToParents, room.roomId); + const orphanParents = allParents && orphanSpaces.filter((o) => allParents.has(o)); + const perfectOrphanParent = orphanParents && guessPerfectParent(mx, room.roomId, orphanParents); + + const { navigateRoom } = useRoomNavigate(); + + return ( + + } + onClick={() => navigateRoom(room.roomId)} + > + + {name} + {!dm && perfectOrphanParent && ( + + {' •'} {getRoom(perfectOrphanParent)?.name ?? perfectOrphanParent} + + )} + + + ); +} diff --git a/src/app/features/call-status/CallStatus.tsx b/src/app/features/call-status/CallStatus.tsx new file mode 100644 index 00000000..e2a17222 --- /dev/null +++ b/src/app/features/call-status/CallStatus.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { Box, Spinner } from 'folds'; +import classNames from 'classnames'; +import { LiveChip } from './LiveChip'; +import * as css from './styles.css'; +import { CallRoomName } from './CallRoomName'; +import { CallControl } from './CallControl'; +import { ContainerColor } from '../../styles/ContainerColor.css'; +import { useCallMembers, useCallSession } from '../../hooks/useCall'; +import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize'; +import { MemberGlance } from './MemberGlance'; +import { StatusDivider } from './components'; +import { CallEmbed } from '../../plugins/call/CallEmbed'; +import { useCallJoined } from '../../hooks/useCallEmbed'; + +type CallStatusProps = { + callEmbed: CallEmbed; +}; +export function CallStatus({ callEmbed }: CallStatusProps) { + const { room } = callEmbed; + + const callSession = useCallSession(room); + const callMembers = useCallMembers(room, callSession); + const screenSize = useScreenSize(); + const callJoined = useCallJoined(callEmbed); + + return ( + + + {callJoined && callMembers.length > 0 ? ( + + + + + ) : ( + + )} + + + + + + + + ); +} diff --git a/src/app/features/call-status/LiveChip.tsx b/src/app/features/call-status/LiveChip.tsx new file mode 100644 index 00000000..34167fb6 --- /dev/null +++ b/src/app/features/call-status/LiveChip.tsx @@ -0,0 +1,137 @@ +import React, { MouseEventHandler, useState } from 'react'; +import { + Avatar, + Badge, + Box, + Chip, + config, + Icon, + Icons, + Menu, + MenuItem, + PopOut, + RectCords, + Scroll, + Text, + toRem, +} from 'folds'; +import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership'; +import FocusTrap from 'focus-trap-react'; +import { Room } from 'matrix-js-sdk'; +import * as css from './styles.css'; +import { stopPropagation } from '../../utils/keyboard'; +import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room'; +import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { UserAvatar } from '../../components/user-avatar'; +import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; +import { getMouseEventCords } from '../../utils/dom'; + +type LiveChipProps = { + room: Room; + members: CallMembership[]; + count: number; +}; +export function LiveChip({ count, room, members }: LiveChipProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const openUserProfile = useOpenUserRoomProfile(); + + const [cords, setCords] = useState(); + + const handleOpenMenu: MouseEventHandler = (evt) => { + setCords(evt.currentTarget.getBoundingClientRect()); + }; + + return ( + setCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + + + + + {members.map((callMember) => { + const userId = callMember.sender; + if (!userId) return null; + const name = + getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId; + const avatarMxc = getMemberAvatarMxc(room, userId); + const avatarUrl = avatarMxc + ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined + : undefined; + + return ( + + openUserProfile( + room.roomId, + undefined, + userId, + getMouseEventCords(evt.nativeEvent), + 'Right' + ) + } + before={ + + } + /> + + } + > + + {name} + + + ); + })} + + + + + + } + > + } + after={} + radii="Pill" + onClick={handleOpenMenu} + > + + {count} Live + + + + ); +} diff --git a/src/app/features/call-status/MemberGlance.tsx b/src/app/features/call-status/MemberGlance.tsx new file mode 100644 index 00000000..f4991f93 --- /dev/null +++ b/src/app/features/call-status/MemberGlance.tsx @@ -0,0 +1,72 @@ +import { Box, config, Icon, Icons, Text } from 'folds'; +import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership'; +import React from 'react'; +import { Room } from 'matrix-js-sdk'; +import { UserAvatar } from '../../components/user-avatar'; +import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room'; +import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { StackedAvatar } from '../../components/stacked-avatar'; +import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; +import { getMouseEventCords } from '../../utils/dom'; + +type MemberGlanceProps = { + room: Room; + members: CallMembership[]; + max?: number; +}; +export function MemberGlance({ room, members, max = 6 }: MemberGlanceProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const openUserProfile = useOpenUserRoomProfile(); + + const visibleMembers = members.slice(0, max); + const remainingCount = max && members.length > max ? members.length - max : 0; + + return ( + + {visibleMembers.map((callMember) => { + const userId = callMember.sender; + if (!userId) return null; + const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId; + const avatarMxc = getMemberAvatarMxc(room, userId); + const avatarUrl = avatarMxc + ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined + : undefined; + + return ( + + openUserProfile( + room.roomId, + undefined, + userId, + getMouseEventCords(evt.nativeEvent), + 'Top' + ) + } + > + } + /> + + ); + })} + {remainingCount > 0 && ( + + +{remainingCount} + + )} + + ); +} diff --git a/src/app/features/call-status/components.tsx b/src/app/features/call-status/components.tsx new file mode 100644 index 00000000..cd0c0daf --- /dev/null +++ b/src/app/features/call-status/components.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { Line } from 'folds'; +import * as css from './styles.css'; + +export function StatusDivider() { + return ( + + ); +} diff --git a/src/app/features/call-status/index.ts b/src/app/features/call-status/index.ts new file mode 100644 index 00000000..99accf8b --- /dev/null +++ b/src/app/features/call-status/index.ts @@ -0,0 +1 @@ +export * from './CallStatus'; diff --git a/src/app/features/call-status/styles.css.ts b/src/app/features/call-status/styles.css.ts new file mode 100644 index 00000000..26b08f0b --- /dev/null +++ b/src/app/features/call-status/styles.css.ts @@ -0,0 +1,17 @@ +import { style } from '@vanilla-extract/css'; +import { color, config, toRem } from 'folds'; + +export const LiveChipText = style({ + color: color.Critical.Main, +}); + +export const CallStatus = style([ + { + padding: `${toRem(6)} ${config.space.S200}`, + borderTop: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`, + }, +]); + +export const ControlDivider = style({ + height: toRem(16), +}); diff --git a/src/app/features/call/CallMemberCard.tsx b/src/app/features/call/CallMemberCard.tsx new file mode 100644 index 00000000..f96b9a06 --- /dev/null +++ b/src/app/features/call/CallMemberCard.tsx @@ -0,0 +1,121 @@ +import { CallMembership, SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc/CallMembership'; +import React, { useState } from 'react'; +import { Avatar, Box, Icon, Icons, Text } from 'folds'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; +import { SequenceCard } from '../../components/sequence-card'; +import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room'; +import { useRoom } from '../../hooks/useRoom'; +import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; +import { UserAvatar } from '../../components/user-avatar'; +import { getMouseEventCords } from '../../utils/dom'; +import * as css from './styles.css'; + +interface MemberWithMembershipData { + membershipData?: SessionMembershipData & { + 'm.call.intent': 'video' | 'audio'; + }; +} + +type CallMemberCardProps = { + member: CallMembership; +}; +export function CallMemberCard({ member }: CallMemberCardProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const room = useRoom(); + + const openUserProfile = useOpenUserRoomProfile(); + + const userId = member.sender; + if (!userId) return null; + + const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId; + const avatarMxc = getMemberAvatarMxc(room, userId); + const avatarUrl = avatarMxc + ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined + : undefined; + + const audioOnly = + (member as unknown as MemberWithMembershipData).membershipData?.['m.call.intent'] === 'audio'; + + return ( + + openUserProfile( + room.roomId, + undefined, + userId, + getMouseEventCords(evt.nativeEvent), + 'Right' + ) + } + > + + + } + /> + + + + {name} + + + {audioOnly && } + + + ); +} + +export function CallMemberRenderer({ + members, + max = 4, +}: { + members: CallMembership[]; + max?: number; +}) { + const [viewMore, setViewMore] = useState(false); + + const truncatedMembers = viewMore ? members : members.slice(0, 4); + const remaining = members.length - truncatedMembers.length; + + return ( + <> + {truncatedMembers.map((member) => ( + + ))} + {members.length > max && ( + setViewMore(!viewMore)} + > + + {viewMore ? ( + + Collapse + + ) : ( + + {remaining === 0 ? `+${remaining} Other` : `+${remaining} Others`} + + )} + + + + )} + + ); +} diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx new file mode 100644 index 00000000..b2a200fb --- /dev/null +++ b/src/app/features/call/CallView.tsx @@ -0,0 +1,105 @@ +import React, { useRef } from 'react'; +import { Badge, Box, color, Header, Scroll, Text, toRem } from 'folds'; +import { useCallEmbed, useCallJoined, useSyncCallEmbedPlacement } from '../../hooks/useCallEmbed'; +import { ContainerColor } from '../../styles/ContainerColor.css'; +import { PrescreenControls } from './PrescreenControls'; +import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; +import { useRoom } from '../../hooks/useRoom'; +import { useRoomCreators } from '../../hooks/useRoomCreators'; +import { useRoomPermissions } from '../../hooks/useRoomPermissions'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { StateEvent } from '../../../types/matrix/room'; +import { useCallMembers, useCallSession } from '../../hooks/useCall'; +import { CallMemberRenderer } from './CallMemberCard'; +import * as css from './styles.css'; + +function JoinMessage({ hasParticipant }: { hasParticipant?: boolean }) { + if (hasParticipant) return null; + + return ( + + Voice chat’s empty — Be the first to hop in! + + ); +} + +function NoPermissionMessage() { + return ( + + You don't have permission to join! + + ); +} + +function AlreadyInCallMessage() { + return ( + + Already in another call — End the current call to join! + + ); +} + +export function CallView() { + const mx = useMatrixClient(); + const room = useRoom(); + + const callViewRef = useRef(null); + useSyncCallEmbedPlacement(callViewRef); + + const powerLevels = usePowerLevelsContext(); + const creators = useRoomCreators(room); + + const permissions = useRoomPermissions(creators, powerLevels); + const canJoin = permissions.event(StateEvent.GroupCallMemberPrefix, mx.getSafeUserId()); + + const callSession = useCallSession(room); + const callMembers = useCallMembers(room, callSession); + const hasParticipant = callMembers.length > 0; + + const callEmbed = useCallEmbed(); + const callJoined = useCallJoined(callEmbed); + const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId; + + const currentJoined = callEmbed?.roomId === room.roomId && callJoined; + + return ( + + {!currentJoined && ( + + + + {hasParticipant && ( +
+ + Participant + + + + {callMembers.length} Live + + +
+ )} + + +
+ {!inOtherCall && + (canJoin ? ( + + ) : ( + + ))} + {inOtherCall && } +
+
+
+
+ )} +
+ ); +} diff --git a/src/app/features/call/Controls.tsx b/src/app/features/call/Controls.tsx new file mode 100644 index 00000000..39c5d87d --- /dev/null +++ b/src/app/features/call/Controls.tsx @@ -0,0 +1,145 @@ +import React from 'react'; +import { Icon, IconButton, Icons, Line, Text, Tooltip, TooltipProvider } from 'folds'; +import { useAtom } from 'jotai'; +import * as css from './styles.css'; +import { callChatAtom } from '../../state/callEmbed'; + +export function ControlDivider() { + return ( + + ); +} + +type MicrophoneButtonProps = { + enabled: boolean; + onToggle: () => void; +}; +export function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) { + return ( + + {enabled ? 'Turn Off Microphone' : 'Turn On Microphone'} + + } + > + {(anchorRef) => ( + onToggle()} + outlined + > + + + )} + + ); +} + +type SoundButtonProps = { + enabled: boolean; + onToggle: () => void; +}; +export function SoundButton({ enabled, onToggle }: SoundButtonProps) { + return ( + + {enabled ? 'Turn Off Sound' : 'Turn On Sound'} + + } + > + {(anchorRef) => ( + onToggle()} + outlined + > + + + )} + + ); +} + +type VideoButtonProps = { + enabled: boolean; + onToggle: () => void; +}; +export function VideoButton({ enabled, onToggle }: VideoButtonProps) { + return ( + + {enabled ? 'Stop Camera' : 'Start Camera'} + + } + > + {(anchorRef) => ( + onToggle()} + outlined + > + + + )} + + ); +} + +export function ChatButton() { + const [chat, setChat] = useAtom(callChatAtom); + + return ( + + {chat ? 'Close Chat' : 'Open Chat'} + + } + > + {(anchorRef) => ( + setChat(!chat)} + outlined + > + + + )} + + ); +} diff --git a/src/app/features/call/PrescreenControls.tsx b/src/app/features/call/PrescreenControls.tsx new file mode 100644 index 00000000..4782c9b6 --- /dev/null +++ b/src/app/features/call/PrescreenControls.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { Box, Button, Icon, Icons, Spinner, Text } from 'folds'; +import { SequenceCard } from '../../components/sequence-card'; +import * as css from './styles.css'; +import { ChatButton, ControlDivider, MicrophoneButton, SoundButton, VideoButton } from './Controls'; +import { useIsDirectRoom, useRoom } from '../../hooks/useRoom'; +import { useCallEmbed, useCallJoined, useCallStart } from '../../hooks/useCallEmbed'; +import { useCallPreferences } from '../../state/hooks/callPreferences'; +import { CallControlState } from '../../plugins/call/CallControlState'; + +type PrescreenControlsProps = { + canJoin?: boolean; +}; +export function PrescreenControls({ canJoin }: PrescreenControlsProps) { + const room = useRoom(); + const callEmbed = useCallEmbed(); + const callJoined = useCallJoined(callEmbed); + const direct = useIsDirectRoom(); + + const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId; + + const startCall = useCallStart(direct); + const joining = callEmbed?.roomId === room.roomId && !callJoined; + + const disabled = inOtherCall || !canJoin; + + const { microphone, video, sound, toggleMicrophone, toggleVideo, toggleSound } = + useCallPreferences(); + + return ( + + + + + + + + + + + + + + + ); +} diff --git a/src/app/features/call/styles.css.ts b/src/app/features/call/styles.css.ts new file mode 100644 index 00000000..1ee2892a --- /dev/null +++ b/src/app/features/call/styles.css.ts @@ -0,0 +1,20 @@ +import { style } from '@vanilla-extract/css'; +import { config, toRem } from 'folds'; + +export const CallViewContent = style({ + padding: config.space.S400, + paddingRight: 0, + minHeight: '100%', +}); + +export const ControlCard = style({ + padding: config.space.S300, +}); + +export const ControlDivider = style({ + height: toRem(24), +}); + +export const CallMemberCard = style({ + padding: config.space.S300, +}); diff --git a/src/app/features/common-settings/general/RoomJoinRules.tsx b/src/app/features/common-settings/general/RoomJoinRules.tsx index b9e75499..0d352167 100644 --- a/src/app/features/common-settings/general/RoomJoinRules.tsx +++ b/src/app/features/common-settings/general/RoomJoinRules.tsx @@ -6,9 +6,8 @@ import { useAtomValue } from 'jotai'; import { ExtendedJoinRules, JoinRulesSwitcher, - useRoomJoinRuleIcon, + useJoinRuleIcons, useRoomJoinRuleLabel, - useSpaceJoinRuleIcon, } from '../../../components/JoinRulesSwitcher'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../../room-settings/styles.css'; @@ -75,8 +74,7 @@ export function RoomJoinRules({ permissions }: RoomJoinRulesProps) { return r; }, [allowKnockRestricted, allowRestricted, allowKnock, space]); - const icons = useRoomJoinRuleIcon(); - const spaceIcons = useSpaceJoinRuleIcon(); + const icons = useJoinRuleIcons(room.getType()); const labels = useRoomJoinRuleLabel(); const [submitState, submit] = useAsyncCallback( @@ -137,7 +135,7 @@ export function RoomJoinRules({ permissions }: RoomJoinRulesProps) { } after={ ( ( { - if (kind === CreateRoomKind.Private) return Icons.HashLock; - if (kind === CreateRoomKind.Restricted) return Icons.Hash; - return Icons.HashGlobe; +const getCreateRoomAccessToIcon = (access: CreateRoomAccess, type?: CreateRoomType) => { + const isVoiceRoom = type === CreateRoomType.VoiceRoom; + + let joinRule: JoinRule = JoinRule.Public; + if (access === CreateRoomAccess.Restricted) joinRule = JoinRule.Restricted; + if (access === CreateRoomAccess.Private) joinRule = JoinRule.Knock; + + return getRoomIconSrc(Icons, isVoiceRoom ? RoomType.Call : undefined, joinRule); +}; + +const getCreateRoomTypeToIcon = (type: CreateRoomType) => { + if (type === CreateRoomType.VoiceRoom) return Icons.VolumeHigh; + return Icons.Hash; }; type CreateRoomFormProps = { - defaultKind?: CreateRoomKind; + defaultAccess?: CreateRoomAccess; + defaultType?: CreateRoomType; space?: Room; onCreate?: (roomId: string) => void; }; -export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormProps) { +export function CreateRoomForm({ + defaultAccess, + defaultType, + space, + onCreate, +}: CreateRoomFormProps) { const mx = useMatrixClient(); const alive = useAlive(); @@ -64,8 +83,9 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP const allowRestricted = space && restrictedSupported(selectedRoomVersion); - const [kind, setKind] = useState( - defaultKind ?? allowRestricted ? CreateRoomKind.Restricted : CreateRoomKind.Private + const [type, setType] = useState(defaultType ?? CreateRoomType.TextRoom); + const [access, setAccess] = useState( + defaultAccess ?? (allowRestricted ? CreateRoomAccess.Restricted : CreateRoomAccess.Private) ); const allowAdditionalCreators = creatorsSupported(selectedRoomVersion); const { additionalCreators, addAdditionalCreator, removeAdditionalCreator } = @@ -75,13 +95,13 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP const [knock, setKnock] = useState(false); const [advance, setAdvance] = useState(false); - const allowKnock = kind === CreateRoomKind.Private && knockSupported(selectedRoomVersion); + const allowKnock = access === CreateRoomAccess.Private && knockSupported(selectedRoomVersion); const allowKnockRestricted = - kind === CreateRoomKind.Restricted && knockRestrictedSupported(selectedRoomVersion); + access === CreateRoomAccess.Restricted && knockRestrictedSupported(selectedRoomVersion); const handleRoomVersionChange = (version: string) => { if (!restrictedSupported(version)) { - setKind(CreateRoomKind.Private); + setAccess(CreateRoomAccess.Private); } selectRoomVersion(version); }; @@ -107,19 +127,23 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP aliasInput && aliasInput.value ? replaceSpaceWithDash(aliasInput.value) : undefined; if (!roomName) return; - const publicRoom = kind === CreateRoomKind.Public; + const publicRoom = access === CreateRoomAccess.Public; let roomKnock = false; - if (allowKnock && kind === CreateRoomKind.Private) { + if (allowKnock && access === CreateRoomAccess.Private) { roomKnock = knock; } - if (allowKnockRestricted && kind === CreateRoomKind.Restricted) { + if (allowKnockRestricted && access === CreateRoomAccess.Restricted) { roomKnock = knock; } + let roomType: RoomType | undefined; + if (type === CreateRoomType.VoiceRoom) roomType = RoomType.Call; + create({ version: selectedRoomVersion, + type: roomType, parent: space, - kind, + access, name: roomName, topic: roomTopic || undefined, aliasLocalPart: publicRoom ? aliasLocalPart : undefined, @@ -136,21 +160,32 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP return ( + {!space && ( + + Type + + + )} Access - getCreateRoomAccessToIcon(roomAccess, type)} /> Name } + before={} name="nameInput" autoFocus size="500" @@ -171,7 +206,7 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP /> - {kind === CreateRoomKind.Public && } + {access === CreateRoomAccess.Public && } @@ -201,7 +236,7 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP />
)} - {kind !== CreateRoomKind.Public && ( + {access !== CreateRoomAccess.Public && ( <> - New Room + + {type === CreateRoomType.VoiceRoom ? 'New Voice Room' : 'New Chat Room'} + @@ -74,7 +77,7 @@ function CreateRoomModal({ state }: CreateRoomModalProps) { direction="Column" gap="500" > - +
diff --git a/src/app/features/create-space/CreateSpace.tsx b/src/app/features/create-space/CreateSpace.tsx index 530145af..b0c12f56 100644 --- a/src/app/features/create-space/CreateSpace.tsx +++ b/src/app/features/create-space/CreateSpace.tsx @@ -33,25 +33,25 @@ import { createRoom, CreateRoomAliasInput, CreateRoomData, - CreateRoomKind, - CreateRoomKindSelector, + CreateRoomAccess, + CreateRoomAccessSelector, RoomVersionSelector, useAdditionalCreators, } from '../../components/create-room'; import { RoomType } from '../../../types/matrix/room'; -const getCreateSpaceKindToIcon = (kind: CreateRoomKind) => { - if (kind === CreateRoomKind.Private) return Icons.SpaceLock; - if (kind === CreateRoomKind.Restricted) return Icons.Space; +const getCreateSpaceAccessToIcon = (access: CreateRoomAccess) => { + if (access === CreateRoomAccess.Private) return Icons.SpaceLock; + if (access === CreateRoomAccess.Restricted) return Icons.Space; return Icons.SpaceGlobe; }; type CreateSpaceFormProps = { - defaultKind?: CreateRoomKind; + defaultAccess?: CreateRoomAccess; space?: Room; onCreate?: (roomId: string) => void; }; -export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFormProps) { +export function CreateSpaceForm({ defaultAccess, space, onCreate }: CreateSpaceFormProps) { const mx = useMatrixClient(); const alive = useAlive(); @@ -65,8 +65,8 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor const allowRestricted = space && restrictedSupported(selectedRoomVersion); - const [kind, setKind] = useState( - defaultKind ?? allowRestricted ? CreateRoomKind.Restricted : CreateRoomKind.Private + const [access, setAccess] = useState( + defaultAccess ?? (allowRestricted ? CreateRoomAccess.Restricted : CreateRoomAccess.Private) ); const allowAdditionalCreators = creatorsSupported(selectedRoomVersion); @@ -76,13 +76,13 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor const [knock, setKnock] = useState(false); const [advance, setAdvance] = useState(false); - const allowKnock = kind === CreateRoomKind.Private && knockSupported(selectedRoomVersion); + const allowKnock = access === CreateRoomAccess.Private && knockSupported(selectedRoomVersion); const allowKnockRestricted = - kind === CreateRoomKind.Restricted && knockRestrictedSupported(selectedRoomVersion); + access === CreateRoomAccess.Restricted && knockRestrictedSupported(selectedRoomVersion); const handleRoomVersionChange = (version: string) => { if (!restrictedSupported(version)) { - setKind(CreateRoomKind.Private); + setAccess(CreateRoomAccess.Private); } selectRoomVersion(version); }; @@ -108,12 +108,12 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor aliasInput && aliasInput.value ? replaceSpaceWithDash(aliasInput.value) : undefined; if (!roomName) return; - const publicRoom = kind === CreateRoomKind.Public; + const publicRoom = access === CreateRoomAccess.Public; let roomKnock = false; - if (allowKnock && kind === CreateRoomKind.Private) { + if (allowKnock && access === CreateRoomAccess.Private) { roomKnock = knock; } - if (allowKnockRestricted && kind === CreateRoomKind.Restricted) { + if (allowKnockRestricted && access === CreateRoomAccess.Restricted) { roomKnock = knock; } @@ -121,7 +121,7 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor version: selectedRoomVersion, type: RoomType.Space, parent: space, - kind, + access, name: roomName, topic: roomTopic || undefined, aliasLocalPart: publicRoom ? aliasLocalPart : undefined, @@ -139,19 +139,19 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor Access - Name } + before={} name="nameInput" autoFocus size="500" @@ -172,7 +172,7 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor /> - {kind === CreateRoomKind.Public && } + {access === CreateRoomAccess.Public && } @@ -202,7 +202,7 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor /> )} - {kind !== CreateRoomKind.Public && advance && (allowKnock || allowKnockRestricted) && ( + {access !== CreateRoomAccess.Public && advance && (allowKnock || allowKnockRestricted) && ( {(onBack) => ( - + )} @@ -218,7 +218,11 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) { } > {(triggerRef) => ( - setPeopleDrawer((drawer) => !drawer)}> + setPeopleDrawer((drawer) => !drawer)} + > )} @@ -235,7 +239,12 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) { } > {(triggerRef) => ( - + )} diff --git a/src/app/features/lobby/RoomItem.tsx b/src/app/features/lobby/RoomItem.tsx index 994cda05..7de59acd 100644 --- a/src/app/features/lobby/RoomItem.tsx +++ b/src/app/features/lobby/RoomItem.tsx @@ -175,6 +175,7 @@ function RoomProfileError({ roomId, suggested, inaccessibleRoom, via }: RoomProf type RoomProfileProps = { roomId: string; + roomType?: string; name: string; topic?: string; avatarUrl?: string; @@ -185,6 +186,7 @@ type RoomProfileProps = { }; function RoomProfile({ roomId, + roomType, name, topic, avatarUrl, @@ -200,9 +202,7 @@ function RoomProfile({ roomId={roomId} src={avatarUrl} alt={name} - renderFallback={() => ( - - )} + renderFallback={() => } /> @@ -338,6 +338,7 @@ export const RoomItemCard = as<'div', RoomItemCardProps>( {(localSummary) => ( ( {summary && ( { - openCreateRoomModal(item.roomId); + const handleCreateRoom = (type?: CreateRoomType) => { + openCreateRoomModal(item.roomId, type); setCords(undefined); }; @@ -281,9 +283,19 @@ function AddRoomButton({ item }: { item: HierarchyItem }) { radii="300" variant="Primary" fill="None" - onClick={handleCreateRoom} + onClick={() => handleCreateRoom(CreateRoomType.TextRoom)} > - New Room + Chat Room + + handleCreateRoom(CreateRoomType.VoiceRoom)} + after={} + > + Voice Room Existing Room diff --git a/src/app/features/message-search/SearchFilters.tsx b/src/app/features/message-search/SearchFilters.tsx index 929dd1e9..6883e363 100644 --- a/src/app/features/message-search/SearchFilters.tsx +++ b/src/app/features/message-search/SearchFilters.tsx @@ -29,7 +29,7 @@ import { SearchOrderBy } from 'matrix-js-sdk'; import FocusTrap from 'focus-trap-react'; import { useVirtualizer } from '@tanstack/react-virtual'; import { useMatrixClient } from '../../hooks/useMatrixClient'; -import { joinRuleToIconSrc } from '../../utils/room'; +import { getRoomIconSrc } from '../../utils/room'; import { factoryRoomIdByAtoZ } from '../../utils/sort'; import { SearchItemStrGetter, @@ -274,9 +274,7 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto before={ } > @@ -392,10 +390,7 @@ export function SearchFilters({ onClick={() => onSelectedRoomsChange(selectedRooms.filter((rId) => rId !== roomId))} radii="Pill" before={ - + } after={} > diff --git a/src/app/features/message-search/SearchResultGroup.tsx b/src/app/features/message-search/SearchResultGroup.tsx index 62ef9c4b..14817cc4 100644 --- a/src/app/features/message-search/SearchResultGroup.tsx +++ b/src/app/features/message-search/SearchResultGroup.tsx @@ -203,7 +203,12 @@ export function SearchResultGroup({ src={getRoomAvatarUrl(mx, room, 96, useAuthentication)} alt={room.name} renderFallback={() => ( - + )} /> diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 33b21bff..cfb52d44 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -19,6 +19,7 @@ import { } from 'folds'; import { useFocusWithin, useHover } from 'react-aria'; import FocusTrap from 'focus-trap-react'; +import { useAtom, useAtomValue } from 'jotai'; import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav'; import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; @@ -51,6 +52,12 @@ import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationS import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { InviteUserPrompt } from '../../components/invite-user-prompt'; +import { useRoomName } from '../../hooks/useRoomMeta'; +import { useCallMembers, useCallSession } from '../../hooks/useCall'; +import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed'; +import { callChatAtom } from '../../state/callEmbed'; +import { useCallPreferencesAtom } from '../../state/hooks/callPreferences'; +import { CallControlState } from '../../plugins/call/CallControlState'; type RoomNavItemMenuProps = { room: Room; @@ -209,6 +216,24 @@ const RoomNavItemMenu = forwardRef( } ); +function CallChatToggle() { + const [chat, setChat] = useAtom(callChatAtom); + + return ( + setChat(!chat)} + aria-pressed={chat} + aria-label="Toggle Chat" + variant="Background" + fill="None" + size="300" + radii="300" + > + + + ); +} + type RoomNavItemProps = { room: Room; selected: boolean; @@ -236,6 +261,8 @@ export function RoomNavItem({ (receipt) => receipt.userId !== mx.getUserId() ); + const roomName = useRoomName(room); + const handleContextMenu: MouseEventHandler = (evt) => { evt.preventDefault(); setMenuAnchor({ @@ -251,6 +278,23 @@ export function RoomNavItem({ }; const optionsVisible = hover || !!menuAnchor; + const callSession = useCallSession(room); + const callMembers = useCallMembers(room, callSession); + const startCall = useCallStart(direct); + const callEmbed = useCallEmbed(); + const callPref = useAtomValue(useCallPreferencesAtom()); + + const handleStartCall: MouseEventHandler = (evt) => { + // Do not join if already in call + if (callEmbed) { + return; + } + // Start call in second click + if (selected) { + evt.preventDefault(); + startCall(room, new CallControlState(callPref.microphone, callPref.video, callPref.sound)); + } + }; return ( - + @@ -275,25 +319,28 @@ export function RoomNavItem({ ? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication) : getRoomAvatarUrl(mx, room, 96, useAuthentication) } - alt={room.name} + alt={roomName} renderFallback={() => ( - {nameInitials(room.name)} + {nameInitials(roomName)} )} /> ) : ( )} - {room.name} + {roomName} {!optionsVisible && !unread && !selected && typingMember.length > 0 && ( @@ -307,14 +354,30 @@ export function RoomNavItem({ )} {!optionsVisible && notificationMode !== RoomNotificationMode.Unset && ( - + + )} + {room.isCallRoom() && callMembers.length > 0 && ( + + + {callMembers.length} Live + + )} {optionsVisible && ( + {selected && (callEmbed?.roomId === room.roomId || room.isCallRoom()) && ( + + )} ( diff --git a/src/app/features/room-settings/permissions/Permissions.tsx b/src/app/features/room-settings/permissions/Permissions.tsx index 7572a71b..fe6b098b 100644 --- a/src/app/features/room-settings/permissions/Permissions.tsx +++ b/src/app/features/room-settings/permissions/Permissions.tsx @@ -23,7 +23,7 @@ export function Permissions({ requestClose }: PermissionsProps) { const canEditPowers = permissions.stateEvent(StateEvent.PowerLevelTags, mx.getSafeUserId()); const canEditPermissions = permissions.stateEvent(StateEvent.RoomPowerLevels, mx.getSafeUserId()); - const permissionGroups = usePermissionGroups(); + const permissionGroups = usePermissionGroups(room.isCallRoom()); const [powerEditor, setPowerEditor] = useState(false); diff --git a/src/app/features/room-settings/permissions/usePermissionItems.ts b/src/app/features/room-settings/permissions/usePermissionItems.ts index f3b45424..d4f5f562 100644 --- a/src/app/features/room-settings/permissions/usePermissionItems.ts +++ b/src/app/features/room-settings/permissions/usePermissionItems.ts @@ -2,7 +2,7 @@ import { useMemo } from 'react'; import { MessageEvent, StateEvent } from '../../../../types/matrix/room'; import { PermissionGroup } from '../../common-settings/permissions'; -export const usePermissionGroups = (): PermissionGroup[] => { +export const usePermissionGroups = (isCallRoom: boolean): PermissionGroup[] => { const groups: PermissionGroup[] = useMemo(() => { const messagesGroup: PermissionGroup = { name: 'Messages', @@ -46,6 +46,19 @@ export const usePermissionGroups = (): PermissionGroup[] => { ], }; + const callSettingsGroup: PermissionGroup = { + name: 'Calls', + items: [ + { + location: { + state: true, + key: StateEvent.GroupCallMemberPrefix, + }, + name: 'Join Call', + }, + ], + }; + const moderationGroup: PermissionGroup = { name: 'Moderation', items: [ @@ -203,12 +216,13 @@ export const usePermissionGroups = (): PermissionGroup[] => { return [ messagesGroup, + ...(isCallRoom ? [callSettingsGroup] : []), moderationGroup, roomOverviewGroup, roomSettingsGroup, otherSettingsGroup, ]; - }, []); + }, [isCallRoom]); return groups; }; diff --git a/src/app/features/room/CallChatView.tsx b/src/app/features/room/CallChatView.tsx new file mode 100644 index 00000000..6c32f4a0 --- /dev/null +++ b/src/app/features/room/CallChatView.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { useSetAtom } from 'jotai'; +import { useParams } from 'react-router-dom'; +import { Box, Text, TooltipProvider, Tooltip, Icon, Icons, IconButton, toRem } from 'folds'; +import { Page, PageHeader } from '../../components/page'; +import { callChatAtom } from '../../state/callEmbed'; +import { RoomView } from './RoomView'; +import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; + +export function CallChatView() { + const { eventId } = useParams(); + const setChat = useSetAtom(callChatAtom); + const screenSize = useScreenSizeContext(); + + const handleClose = () => setChat(false); + + return ( + + + + + + Chat + + + + + Close + + } + > + {(triggerRef) => ( + + + + )} + + + + + + + + + ); +} diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index 24878d5e..5e5d7d78 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -2,6 +2,7 @@ import React, { useCallback } from 'react'; import { Box, Line } from 'folds'; import { useParams } from 'react-router-dom'; import { isKeyHotkey } from 'is-hotkey'; +import { useAtomValue } from 'jotai'; import { RoomView } from './RoomView'; import { MembersDrawer } from './MembersDrawer'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; @@ -13,6 +14,10 @@ import { useKeyDown } from '../../hooks/useKeyDown'; import { markAsRead } from '../../utils/notifications'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useRoomMembers } from '../../hooks/useRoomMembers'; +import { CallView } from '../call/CallView'; +import { RoomViewHeader } from './RoomViewHeader'; +import { callChatAtom } from '../../state/callEmbed'; +import { CallChatView } from './CallChatView'; export function Room() { const { eventId } = useParams(); @@ -24,6 +29,7 @@ export function Room() { const screenSize = useScreenSizeContext(); const powerLevels = usePowerLevels(room); const members = useRoomMembers(mx, room.roomId); + const chat = useAtomValue(callChatAtom); useKeyDown( window, @@ -37,11 +43,37 @@ export function Room() { ) ); + const callView = room.isCallRoom(); + return ( - - {screenSize === ScreenSize.Desktop && isDrawer && ( + {callView && (screenSize === ScreenSize.Desktop || !chat) && ( + + + + + + + )} + {!callView && ( + + + + + + + )} + + {callView && chat && ( + <> + {screenSize === ScreenSize.Desktop && ( + + )} + + + )} + {!callView && screenSize === ScreenSize.Desktop && isDrawer && ( <> diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index d1678b65..64043054 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -27,6 +27,7 @@ import { HTMLReactParserOptions } from 'html-react-parser'; import classNames from 'classnames'; import { ReactEditor } from 'slate-react'; import { Editor } from 'slate'; +import { SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc/CallMembership'; import to from 'await-to-js'; import { useAtomValue, useSetAtom } from 'jotai'; import { @@ -1469,6 +1470,51 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli ); }, + [StateEvent.GroupCallMemberPrefix]: (mEventId, mEvent, item) => { + const highlighted = focusItem?.index === item && focusItem.highlight; + const senderId = mEvent.getSender() ?? ''; + const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId); + + const callJoined = mEvent.getContent().application; + + const timeJSX = ( + + } + /> + + ); + }, }, (mEventId, mEvent, item) => { if (!showHiddenEvents) return null; diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index 0f837594..a4f0d7af 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useRef } from 'react'; import { Box, Text, config } from 'folds'; -import { EventType, Room } from 'matrix-js-sdk'; +import { EventType } from 'matrix-js-sdk'; import { ReactEditor } from 'slate-react'; import { isKeyHotkey } from 'is-hotkey'; import { useStateEvent } from '../../hooks/useStateEvent'; @@ -15,13 +15,13 @@ import { RoomTombstone } from './RoomTombstone'; import { RoomInput } from './RoomInput'; import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing'; import { Page } from '../../components/page'; -import { RoomViewHeader } from './RoomViewHeader'; import { useKeyDown } from '../../hooks/useKeyDown'; import { editableActiveElement } from '../../utils/dom'; import { settingsAtom } from '../../state/settings'; import { useSetting } from '../../state/hooks/settings'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useRoomCreators } from '../../hooks/useRoomCreators'; +import { useRoom } from '../../hooks/useRoom'; const FN_KEYS_REGEX = /^F\d+$/; const shouldFocusMessageField = (evt: KeyboardEvent): boolean => { @@ -30,10 +30,8 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => { return false; } - // do not focus on F keys if (FN_KEYS_REGEX.test(code)) return false; - // do not focus on numlock/scroll lock if ( code.startsWith('OS') || code.startsWith('Meta') || @@ -56,12 +54,13 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => { return true; }; -export function RoomView({ room, eventId }: { room: Room; eventId?: string }) { +export function RoomView({ eventId }: { eventId?: string }) { const roomInputRef = useRef(null); const roomViewRef = useRef(null); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); + const room = useRoom(); const { roomId } = room; const editor = useEditor(); @@ -93,7 +92,6 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) { return ( - (({ room, requestClose ); }); -export function RoomViewHeader() { +export function RoomViewHeader({ callView }: { callView?: boolean }) { const navigate = useNavigate(); const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); @@ -263,12 +264,14 @@ export function RoomViewHeader() { const space = useSpaceOptionally(); const [menuAnchor, setMenuAnchor] = useState(); const [pinMenuAnchor, setPinMenuAnchor] = useState(); - const mDirects = useAtomValue(mDirectAtom); + const direct = useIsDirectRoom(); + + const [chat, setChat] = useAtom(callChatAtom); const pinnedEvents = useRoomPinnedEvents(room); const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption); - const ecryptedRoom = !!encryptionEvent; - const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId)); + const encryptedRoom = !!encryptionEvent; + const avatarMxc = useRoomAvatar(room, direct); const name = useRoomName(room); const topic = useRoomTopic(room); const avatarUrl = avatarMxc @@ -295,14 +298,27 @@ export function RoomViewHeader() { setPinMenuAnchor(evt.currentTarget.getBoundingClientRect()); }; + const openSettings = useOpenRoomSettings(); + const parentSpace = useSpaceOptionally(); + const handleMemberToggle = () => { + if (callView) { + openSettings(room.roomId, parentSpace?.roomId, RoomSettingsPage.MembersPage); + return; + } + setPeopleDrawer(!peopleDrawer); + }; + return ( - + {screenSize === ScreenSize.Mobile && ( {(onBack) => ( - + @@ -317,11 +333,7 @@ export function RoomViewHeader() { src={avatarUrl} alt={name} renderFallback={() => ( - + )} /> @@ -369,8 +381,9 @@ export function RoomViewHeader() { )} + - {!ecryptedRoom && ( + {!encryptedRoom && ( {(triggerRef) => ( - + )} @@ -398,6 +411,7 @@ export function RoomViewHeader() { > {(triggerRef) => ( } /> + {screenSize === ScreenSize.Desktop && ( {(triggerRef) => ( - setPeopleDrawer((drawer) => !drawer)}> + )} )} + + {callView && ( + + Chat + + } + > + {(triggerRef) => ( + setChat(!chat)}> + + + )} + + )} + {(triggerRef) => ( - + )} diff --git a/src/app/features/search/Search.tsx b/src/app/features/search/Search.tsx index fcd6233a..6027f322 100644 --- a/src/app/features/search/Search.tsx +++ b/src/app/features/search/Search.tsx @@ -373,7 +373,7 @@ export function Search({ requestClose }: SearchProps) { )} diff --git a/src/app/features/space-settings/SpaceSettings.tsx b/src/app/features/space-settings/SpaceSettings.tsx index e565fb92..b5fefc93 100644 --- a/src/app/features/space-settings/SpaceSettings.tsx +++ b/src/app/features/space-settings/SpaceSettings.tsx @@ -103,7 +103,7 @@ export function SpaceSettings({ initialPage, requestClose }: SpaceSettingsProps) alt={roomName} renderFallback={() => ( { + const mx = useMatrixClient(); + + const [session, setSession] = useState(mx.matrixRTC.getRoomSession(room)); + + useEffect(() => { + const start = (roomId: string) => { + if (roomId !== room.roomId) return; + setSession(mx.matrixRTC.getRoomSession(room)); + }; + const end = (roomId: string) => { + if (roomId !== room.roomId) return; + setSession(mx.matrixRTC.getRoomSession(room)); + }; + mx.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, start); + mx.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, end); + return () => { + mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, start); + mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, end); + }; + }, [mx, room]); + + return session; +}; + +export const useCallMembers = (room: Room, session: MatrixRTCSession): CallMembership[] => { + const [memberships, setMemberships] = useState( + MatrixRTCSession.sessionMembershipsForRoom(room, session.sessionDescription) + ); + + useEffect(() => { + const updateMemberships = () => { + setMemberships(MatrixRTCSession.sessionMembershipsForRoom(room, session.sessionDescription)); + }; + + updateMemberships(); + + session.on(MatrixRTCSessionEvent.MembershipsChanged, updateMemberships); + return () => { + session.removeListener(MatrixRTCSessionEvent.MembershipsChanged, updateMemberships); + }; + }, [session, room]); + + return memberships; +}; diff --git a/src/app/hooks/useCallEmbed.ts b/src/app/hooks/useCallEmbed.ts new file mode 100644 index 00000000..e04182dd --- /dev/null +++ b/src/app/hooks/useCallEmbed.ts @@ -0,0 +1,130 @@ +import { createContext, RefObject, useCallback, useContext, useEffect, useState } from 'react'; +import { MatrixRTCSession } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession'; +import { MatrixClient, Room } from 'matrix-js-sdk'; +import { useSetAtom } from 'jotai'; +import { + CallEmbed, + ElementCallThemeKind, + ElementWidgetActions, + useClientWidgetApiEvent, +} from '../plugins/call'; +import { useMatrixClient } from './useMatrixClient'; +import { ThemeKind, useTheme } from './useTheme'; +import { callEmbedAtom } from '../state/callEmbed'; +import { useResizeObserver } from './useResizeObserver'; +import { CallControlState } from '../plugins/call/CallControlState'; + +const CallEmbedContext = createContext(undefined); + +export const CallEmbedContextProvider = CallEmbedContext.Provider; + +export const useCallEmbed = (): CallEmbed | undefined => { + const callEmbed = useContext(CallEmbedContext); + + return callEmbed; +}; + +const CallEmbedRefContext = createContext | undefined>(undefined); +export const CallEmbedRefContextProvider = CallEmbedRefContext.Provider; +export const useCallEmbedRef = (): RefObject => { + const ref = useContext(CallEmbedRefContext); + if (!ref) { + throw new Error('CallEmbedRef is not provided!'); + } + return ref; +}; + +export const createCallEmbed = ( + mx: MatrixClient, + room: Room, + dm: boolean, + themeKind: ElementCallThemeKind, + container: HTMLElement, + controlState?: CallControlState +): CallEmbed => { + const rtcSession = mx.matrixRTC.getRoomSession(room); + const ongoing = + MatrixRTCSession.sessionMembershipsForRoom(room, rtcSession.sessionDescription).length > 0; + + const intent = CallEmbed.getIntent(dm, ongoing); + const widget = CallEmbed.getWidget(mx, room, intent, themeKind); + const embed = new CallEmbed(mx, room, widget, container, controlState); + + return embed; +}; + +export const useCallStart = (dm = false) => { + const mx = useMatrixClient(); + const theme = useTheme(); + const setCallEmbed = useSetAtom(callEmbedAtom); + const callEmbedRef = useCallEmbedRef(); + + const startCall = useCallback( + (room: Room, controlState?: CallControlState) => { + const container = callEmbedRef.current; + if (!container) { + throw new Error('Failed to start call, No embed container element found!'); + } + const callEmbed = createCallEmbed(mx, room, dm, theme.kind, container, controlState); + + setCallEmbed(callEmbed); + }, + [mx, dm, theme, setCallEmbed, callEmbedRef] + ); + + return startCall; +}; + +export const useCallJoined = (embed?: CallEmbed): boolean => { + const [joined, setJoined] = useState(embed?.joined ?? false); + + useClientWidgetApiEvent( + embed?.call, + ElementWidgetActions.JoinCall, + useCallback(() => { + setJoined(true); + }, []) + ); + + useEffect(() => { + if (!embed) { + setJoined(false); + } + }, [embed]); + + return joined; +}; + +export const useCallHangupEvent = (embed: CallEmbed, callback: () => void) => { + useClientWidgetApiEvent(embed.call, ElementWidgetActions.HangupCall, callback); +}; + +export const useCallThemeSync = (embed: CallEmbed) => { + const theme = useTheme(); + + useEffect(() => { + const name: ElementCallThemeKind = theme.kind === ThemeKind.Dark ? 'dark' : 'light'; + + embed.setTheme(name); + }, [theme.kind, embed]); +}; + +export const useSyncCallEmbedPlacement = (containerViewRef: RefObject): void => { + const callEmbedRef = useCallEmbedRef(); + + const syncCallEmbedPlacement = useCallback(() => { + const embedEl = callEmbedRef.current; + const container = containerViewRef.current; + if (!embedEl || !container) return; + + embedEl.style.top = `${container.offsetTop}px`; + embedEl.style.left = `${container.offsetLeft}px`; + embedEl.style.width = `${container.clientWidth}px`; + embedEl.style.height = `${container.clientHeight}px`; + }, [callEmbedRef, containerViewRef]); + + useResizeObserver( + syncCallEmbedPlacement, + useCallback(() => containerViewRef.current, [containerViewRef]) + ); +}; diff --git a/src/app/hooks/useRoomMeta.ts b/src/app/hooks/useRoomMeta.ts index 8b0ae8cc..086c3a56 100644 --- a/src/app/hooks/useRoomMeta.ts +++ b/src/app/hooks/useRoomMeta.ts @@ -20,6 +20,8 @@ export const useRoomName = (room: Room): string => { const [name, setName] = useState(room.name); useEffect(() => { + setName(room.name); + const handleRoomNameChange: RoomEventHandlerMap[RoomEvent.Name] = () => { setName(room.name); }; diff --git a/src/app/hooks/useStateEvents.ts b/src/app/hooks/useStateEvents.ts deleted file mode 100644 index dd085693..00000000 --- a/src/app/hooks/useStateEvents.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { useCallback, useMemo } from 'react'; -import { Room } from 'matrix-js-sdk'; -import { StateEvent } from '../../types/matrix/room'; -import { useForceUpdate } from './useForceUpdate'; -import { useStateEventCallback } from './useStateEventCallback'; -import { getStateEvents } from '../utils/room'; - -export const useStateEvents = (room: Room, eventType: StateEvent) => { - const [updateCount, forceUpdate] = useForceUpdate(); - - useStateEventCallback( - room.client, - useCallback( - (event) => { - if (event.getRoomId() === room.roomId && event.getType() === eventType) { - forceUpdate(); - } - }, - [room, eventType, forceUpdate] - ) - ); - - return useMemo( - () => getStateEvents(room, eventType), - // eslint-disable-next-line react-hooks/exhaustive-deps - [room, eventType, updateCount] - ); -}; diff --git a/src/app/pages/CallStatusRenderer.tsx b/src/app/pages/CallStatusRenderer.tsx new file mode 100644 index 00000000..f8e38054 --- /dev/null +++ b/src/app/pages/CallStatusRenderer.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { useCallEmbed } from '../hooks/useCallEmbed'; +import { CallStatus } from '../features/call-status'; + +export function CallStatusRenderer() { + const callEmbed = useCallEmbed(); + + if (!callEmbed) return null; + + return ; +} diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index f8647d18..4de42081 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -68,6 +68,8 @@ import { Create } from './client/create'; import { CreateSpaceModalRenderer } from '../features/create-space'; import { SearchModalRenderer } from '../features/search'; import { getFallbackSession } from '../state/sessions'; +import { CallStatusRenderer } from './CallStatusRenderer'; +import { CallEmbedProvider } from '../components/CallEmbedProvider'; export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => { const { hashRouter } = clientConfig; @@ -124,15 +126,18 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) - - - - } - > - - + + + + + } + > + + + + diff --git a/src/app/pages/client/ClientInitStorageAtom.tsx b/src/app/pages/client/ClientInitStorageAtom.tsx index 1abee707..5793bb6b 100644 --- a/src/app/pages/client/ClientInitStorageAtom.tsx +++ b/src/app/pages/client/ClientInitStorageAtom.tsx @@ -8,6 +8,8 @@ import { makeNavToActivePathAtom } from '../../state/navToActivePath'; import { NavToActivePathProvider } from '../../state/hooks/navToActivePath'; import { makeOpenedSidebarFolderAtom } from '../../state/openedSidebarFolder'; import { OpenedSidebarFolderProvider } from '../../state/hooks/openedSidebarFolder'; +import { makeCallPreferencesAtom } from '../../state/callPreferences'; +import { CallPreferencesProvider } from '../../state/hooks/callPreferences'; type ClientInitStorageAtomProps = { children: ReactNode; @@ -24,12 +26,16 @@ export function ClientInitStorageAtom({ children }: ClientInitStorageAtomProps) const openedSidebarFolderAtom = useMemo(() => makeOpenedSidebarFolderAtom(userId), [userId]); + const callPreferencesAtom = useMemo(() => makeCallPreferencesAtom(userId), [userId]); + return ( - {children} + + {children} + diff --git a/src/app/pages/client/inbox/Notifications.tsx b/src/app/pages/client/inbox/Notifications.tsx index 4cc94d91..9592224f 100644 --- a/src/app/pages/client/inbox/Notifications.tsx +++ b/src/app/pages/client/inbox/Notifications.tsx @@ -417,7 +417,12 @@ function RoomNotificationsGroupComp({ src={getRoomAvatarUrl(mx, room, 96, useAuthentication)} alt={room.name} renderFallback={() => ( - + )} /> diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index 3f60d2a9..2a7c6199 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -84,6 +84,7 @@ import { ContainerColor } from '../../../styles/ContainerColor.css'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { BreakWord } from '../../../styles/Text.css'; import { InviteUserPrompt } from '../../../components/invite-user-prompt'; +import { useCallEmbed } from '../../../hooks/useCallEmbed'; type SpaceMenuProps = { room: Room; @@ -387,15 +388,15 @@ export function Space() { const notificationPreferences = useRoomsNotificationPreferencesContext(); const tombstoneEvent = useStateEvent(space, StateEvent.RoomTombstone); - const selectedRoomId = useSelectedRoom(); const lobbySelected = useSpaceLobbySelected(spaceIdOrAlias); const searchSelected = useSpaceSearchSelected(spaceIdOrAlias); + const callEmbed = useCallEmbed(); const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom()); const getRoom = useCallback( - (rId: string) => { + (rId: string): Room | undefined => { if (allJoinedRooms.has(rId)) { return mx.getRoom(rId) ?? undefined; } @@ -412,11 +413,11 @@ export function Space() { if (!closedCategories.has(makeNavCategoryId(space.roomId, parentId))) { return false; } - const showRoom = roomToUnread.has(roomId) || roomId === selectedRoomId; - if (showRoom) return false; - return true; + const showRoomAnyway = + roomToUnread.has(roomId) || roomId === selectedRoomId || callEmbed?.roomId === roomId; + return !showRoomAnyway; }, - [space.roomId, closedCategories, roomToUnread, selectedRoomId] + [space.roomId, closedCategories, roomToUnread, selectedRoomId, callEmbed] ), useCallback( (sId) => closedCategories.has(makeNavCategoryId(space.roomId, sId)), diff --git a/src/app/plugins/call/CallControl.ts b/src/app/plugins/call/CallControl.ts new file mode 100644 index 00000000..c2087fe0 --- /dev/null +++ b/src/app/plugins/call/CallControl.ts @@ -0,0 +1,117 @@ +import { ClientWidgetApi } from 'matrix-widget-api'; +import EventEmitter from 'events'; +import { CallControlState } from './CallControlState'; +import { ElementMediaStateDetail, ElementMediaStatePayload, ElementWidgetActions } from './types'; + +export enum CallControlEvent { + StateUpdate = 'state_update', +} + +export class CallControl extends EventEmitter implements CallControlState { + private state: CallControlState; + + private call: ClientWidgetApi; + + private iframe: HTMLIFrameElement; + + constructor(state: CallControlState, call: ClientWidgetApi, iframe: HTMLIFrameElement) { + super(); + + this.state = state; + this.call = call; + this.iframe = iframe; + } + + public getState(): CallControlState { + return this.state; + } + + public get microphone(): boolean { + return this.state.microphone; + } + + public get video(): boolean { + return this.state.video; + } + + public get sound(): boolean { + return this.state.sound; + } + + public async applyState() { + await this.setMediaState({ + audio_enabled: this.microphone, + video_enabled: this.video, + }); + this.setSound(this.sound); + this.emitStateUpdate(); + } + + private setMediaState(state: ElementMediaStatePayload) { + return this.call.transport.send(ElementWidgetActions.DeviceMute, state); + } + + private setSound(sound: boolean): void { + const callDocument = this.iframe.contentDocument ?? this.iframe.contentWindow?.document; + if (callDocument) { + callDocument.querySelectorAll('audio').forEach((el) => { + if (el instanceof HTMLAudioElement) { + // eslint-disable-next-line no-param-reassign + el.muted = !sound; + } + }); + } + } + + public onMediaState(evt: CustomEvent) { + const { data } = evt.detail; + if (!data) return; + + const state = new CallControlState( + data.audio_enabled ?? this.microphone, + data.video_enabled ?? this.video, + this.sound + ); + + this.state = state; + this.emitStateUpdate(); + + if (this.microphone && !this.sound) { + this.toggleSound(); + } + } + + public toggleMicrophone() { + const payload: ElementMediaStatePayload = { + audio_enabled: !this.microphone, + video_enabled: this.video, + }; + return this.setMediaState(payload); + } + + public toggleVideo() { + const payload: ElementMediaStatePayload = { + audio_enabled: this.microphone, + video_enabled: !this.video, + }; + return this.setMediaState(payload); + } + + public toggleSound() { + const sound = !this.sound; + + this.setSound(sound); + + const state = new CallControlState(this.microphone, this.video, sound); + this.state = state; + this.emitStateUpdate(); + + if (!this.sound && this.microphone) { + this.toggleMicrophone(); + } + } + + private emitStateUpdate() { + this.emit(CallControlEvent.StateUpdate); + } +} diff --git a/src/app/plugins/call/CallControlState.ts b/src/app/plugins/call/CallControlState.ts new file mode 100644 index 00000000..42f0b196 --- /dev/null +++ b/src/app/plugins/call/CallControlState.ts @@ -0,0 +1,13 @@ +export class CallControlState { + public readonly microphone: boolean; + + public readonly video: boolean; + + public readonly sound: boolean; + + constructor(microphone: boolean, video: boolean, sound: boolean) { + this.microphone = microphone; + this.video = video; + this.sound = sound; + } +} diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts new file mode 100644 index 00000000..b852e3ef --- /dev/null +++ b/src/app/plugins/call/CallEmbed.ts @@ -0,0 +1,358 @@ +import { + ClientEvent, + KnownMembership, + MatrixClient, + MatrixEvent, + MatrixEventEvent, + Room, + RoomStateEvent, +} from 'matrix-js-sdk'; +import { + ClientWidgetApi, + IRoomEvent, + IWidget, + Widget, + WidgetApiToWidgetAction, + WidgetDriver, +} from 'matrix-widget-api'; +import { CallWidgetDriver } from './CallWidgetDriver'; +import { trimTrailingSlash } from '../../utils/common'; +import { + ElementCallIntent, + ElementCallThemeKind, + ElementMediaStateDetail, + ElementWidgetActions, +} from './types'; +import { CallControl } from './CallControl'; +import { CallControlState } from './CallControlState'; + +export class CallEmbed { + private mx: MatrixClient; + + public readonly call: ClientWidgetApi; + + public readonly iframe: HTMLIFrameElement; + + public readonly room: Room; + + public joined = false; + + public readonly control: CallControl; + + private readonly container: HTMLElement; + + private readUpToMap: { [roomId: string]: string } = {}; // room ID to event ID + + private eventsToFeed = new WeakSet(); + + private readonly disposables: Array<() => void> = []; + + static getIntent(dm: boolean, ongoing: boolean): ElementCallIntent { + if (ongoing) { + return dm ? ElementCallIntent.JoinExistingDM : ElementCallIntent.JoinExisting; + } + + return dm ? ElementCallIntent.StartCallDM : ElementCallIntent.StartCall; + } + + static getWidget( + mx: MatrixClient, + room: Room, + intent: ElementCallIntent, + themeKind: ElementCallThemeKind + ): Widget { + const userId = mx.getSafeUserId(); + const deviceId = mx.getDeviceId() ?? ''; + const clientOrigin = window.location.origin; + const widgetId = 'call-embed'; + + const params = new URLSearchParams({ + widgetId, + parentUrl: clientOrigin, + baseUrl: mx.baseUrl, + roomId: room.roomId, + userId, + deviceId, + intent, + + skipLobby: 'true', + confineToRoom: 'true', + appPrompt: 'false', + perParticipantE2EE: room.hasEncryptionStateEvent().toString(), + lang: 'en-EN', + theme: themeKind, + }); + + const widgetUrl = new URL( + `${trimTrailingSlash(import.meta.env.BASE_URL)}/public/element-call/index.html`, + window.location.origin + ); + widgetUrl.search = params.toString(); + + const options: IWidget = { + id: widgetId, + creatorUserId: userId, + name: 'Call', + type: 'm.call', + url: widgetUrl.href, + waitForIframeLoad: false, + data: {}, + }; + + const widget: Widget = new Widget(options); + + return widget; + } + + static getIframe(url: string): HTMLIFrameElement { + const iframe = document.createElement('iframe'); + + iframe.title = 'Call Embed'; + iframe.sandbox = + 'allow-forms allow-scripts allow-same-origin allow-popups allow-modals allow-downloads'; + iframe.allow = 'microphone; camera; display-capture; autoplay; clipboard-write;'; + iframe.src = url; + + iframe.style.width = '100%'; + iframe.style.height = '100%'; + iframe.style.border = 'none'; + + return iframe; + } + + constructor( + mx: MatrixClient, + room: Room, + widget: Widget, + container: HTMLElement, + initialControlState?: CallControlState + ) { + const iframe = CallEmbed.getIframe( + widget.getCompleteUrl({ currentUserId: mx.getSafeUserId() }) + ); + container.append(iframe); + + const callWidgetDriver: WidgetDriver = new CallWidgetDriver(mx, room.roomId); + const call: ClientWidgetApi = new ClientWidgetApi(widget, iframe, callWidgetDriver); + + this.mx = mx; + this.call = call; + this.room = room; + this.iframe = iframe; + this.container = container; + + const controlState = initialControlState ?? new CallControlState(true, false, true); + this.control = new CallControl(controlState, call, iframe); + + let initialMediaEvent = true; + this.disposables.push( + this.listenEvent(ElementWidgetActions.DeviceMute, (evt) => { + if (initialMediaEvent) { + initialMediaEvent = false; + this.control.applyState(); + return; + } + this.control.onMediaState(evt); + }) + ); + + this.start(); + } + + get roomId(): string { + return this.room.roomId; + } + + public setTheme(theme: ElementCallThemeKind) { + return this.call.transport.send(WidgetApiToWidgetAction.ThemeChange, { + name: theme, + }); + } + + public hangup() { + return this.call.transport.send(ElementWidgetActions.HangupCall, {}); + } + + public listenEvent(type: string, callback: (event: CustomEvent) => void) { + this.call.on(`action:${type}`, callback); + return () => { + this.call.off(`action:${type}`, callback); + }; + } + + private start() { + // Room widgets get locked to the room they were added in + this.call.setViewedRoomId(this.roomId); + this.disposables.push( + this.listenEvent(ElementWidgetActions.JoinCall, this.onCallJoined.bind(this)) + ); + + // Populate the map of "read up to" events for this widget with the current event in every room. + // This is a bit inefficient, but should be okay. We do this for all rooms in case the widget + // requests timeline capabilities in other rooms down the road. It's just easier to manage here. + this.mx.getRooms().forEach((room) => { + // Timelines are most recent last + const events = room.getLiveTimeline()?.getEvents() || []; + const roomEvent = events[events.length - 1]; + if (!roomEvent) return; // force later code to think the room is fresh + this.readUpToMap[room.roomId] = roomEvent.getId()!; + }); + + // Attach listeners for feeding events - the underlying widget classes handle permissions for us + this.mx.on(ClientEvent.Event, this.onEvent.bind(this)); + this.mx.on(MatrixEventEvent.Decrypted, this.onEventDecrypted.bind(this)); + this.mx.on(RoomStateEvent.Events, this.onStateUpdate.bind(this)); + this.mx.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent.bind(this)); + } + + /** + * Stops the widget messaging for if it is started. Skips stopping if it is an active + * widget. + * @param opts + */ + public dispose(): void { + this.disposables.forEach((disposable) => { + disposable(); + }); + this.call.stop(); + this.container.removeChild(this.iframe); + + this.mx.off(ClientEvent.Event, this.onEvent.bind(this)); + this.mx.off(MatrixEventEvent.Decrypted, this.onEventDecrypted.bind(this)); + this.mx.off(RoomStateEvent.Events, this.onStateUpdate.bind(this)); + this.mx.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent.bind(this)); + + // Clear internal state + this.readUpToMap = {}; + this.eventsToFeed = new WeakSet(); + } + + private onCallJoined(): void { + this.joined = true; + } + + private onEvent(ev: MatrixEvent): void { + this.mx.decryptEventIfNeeded(ev); + this.feedEvent(ev); + } + + private onEventDecrypted(ev: MatrixEvent): void { + this.feedEvent(ev); + } + + private onStateUpdate(ev: MatrixEvent): void { + if (this.call === null) return; + const raw = ev.getEffectiveEvent(); + this.call.feedStateUpdate(raw as IRoomEvent).catch((e) => { + console.error('Error sending state update to widget: ', e); + }); + } + + private async onToDeviceEvent(ev: MatrixEvent): Promise { + await this.mx.decryptEventIfNeeded(ev); + if (ev.isDecryptionFailure()) return; + await this.call?.feedToDevice(ev.getEffectiveEvent() as IRoomEvent, ev.isEncrypted()); + } + + /** + * Determines whether the event has a relation to an unknown parent. + */ + private relatesToUnknown(ev: MatrixEvent): boolean { + // Replies to unknown events don't count + if (!ev.relationEventId || ev.replyEventId) return false; + const room = this.mx.getRoom(ev.getRoomId()); + return room === null || !room.findEventById(ev.relationEventId); + } + + /** + * Advances the "read up to" marker for a room to a certain event. No-ops if + * the event is before the marker. + * @returns Whether the "read up to" marker was advanced. + */ + private advanceReadUpToMarker(ev: MatrixEvent): boolean { + const evId = ev.getId(); + if (evId === undefined) return false; + const roomId = ev.getRoomId(); + if (roomId === undefined) return false; + const room = this.mx.getRoom(roomId); + if (room === null) return false; + + const upToEventId = this.readUpToMap[ev.getRoomId()!]; + if (!upToEventId) { + // There's no marker yet; start it at this event + this.readUpToMap[roomId] = evId; + return true; + } + + // Small optimization for exact match (skip the search) + if (upToEventId === evId) return false; + + // Timelines are most recent last, so reverse the order and limit ourselves to 100 events + // to avoid overusing the CPU. + const timeline = room.getLiveTimeline(); + const events = [...timeline.getEvents()].reverse().slice(0, 100); + function isRelevantTimelineEvent(timelineEvent: MatrixEvent): boolean { + return timelineEvent.getId() === upToEventId || timelineEvent.getId() === ev.getId(); + } + const possibleMarkerEv = events.find(isRelevantTimelineEvent); + if (possibleMarkerEv?.getId() === upToEventId) { + // The event must be somewhere before the "read up to" marker + return false; + } + if (possibleMarkerEv?.getId() === ev.getId()) { + // The event is after the marker; advance it + this.readUpToMap[roomId] = evId; + return true; + } + + // We can't say for sure whether the widget has seen the event; let's + // just assume that it has + return false; + } + + /** + * Determines whether the event comes from a room that we've been invited to + * (in which case we likely don't have the full timeline). + */ + private isFromInvite(ev: MatrixEvent): boolean { + const room = this.mx.getRoom(ev.getRoomId()); + return room?.getMyMembership() === KnownMembership.Invite; + } + + private feedEvent(ev: MatrixEvent): void { + if (this.call === null) return; + if ( + // If we had decided earlier to feed this event to the widget, but + // it just wasn't ready, give it another try + this.eventsToFeed.delete(ev) || + // Skip marker timeline check for events with relations to unknown parent because these + // events are not added to the timeline here and will be ignored otherwise: + // https://github.com/matrix-org/matrix-js-sdk/blob/d3dfcd924201d71b434af3d77343b5229b6ed75e/src/models/room.ts#L2207-L2213 + this.relatesToUnknown(ev) || + // Skip marker timeline check for rooms where membership is + // 'invite', otherwise the membership event from the invitation room + // will advance the marker and new state events will not be + // forwarded to the widget. + this.isFromInvite(ev) || + // Check whether this event would be before or after our "read up to" marker. If it's + // before, or we can't decide, then we assume the widget will have already seen the event. + // If the event is after, or we don't have a marker for the room, + // then the marker will advance and we'll send it through. + // This approach of "read up to" prevents widgets receiving decryption spam from startup or + // receiving ancient events from backfill and such. + this.advanceReadUpToMarker(ev) + ) { + // If the event is still being decrypted, remember that we want to + // feed it to the widget (even if not strictly in the order given by + // the timeline) and get back to it later + if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) { + this.eventsToFeed.add(ev); + } else { + const raw = ev.getEffectiveEvent(); + this.call.feedEvent(raw as IRoomEvent).catch((e) => { + console.error('Error sending event to widget: ', e); + }); + } + } + } +} diff --git a/src/app/plugins/call/CallWidgetDriver.ts b/src/app/plugins/call/CallWidgetDriver.ts new file mode 100644 index 00000000..babe90ef --- /dev/null +++ b/src/app/plugins/call/CallWidgetDriver.ts @@ -0,0 +1,340 @@ +import { + type Capability, + type ISendDelayedEventDetails, + type ISendEventDetails, + type IReadEventRelationsResult, + type IRoomEvent, + WidgetDriver, + type IWidgetApiErrorResponseDataDetails, + type ISearchUserDirectoryResult, + type IGetMediaConfigResult, + type UpdateDelayedEventAction, + OpenIDRequestState, + SimpleObservable, + IOpenIDUpdate, +} from 'matrix-widget-api'; +import { + EventType, + type IContent, + MatrixError, + type MatrixEvent, + Direction, + type SendDelayedEventResponse, + type StateEvents, + type TimelineEvents, + MatrixClient, +} from 'matrix-js-sdk'; +import { getCallCapabilities } from './utils'; +import { downloadMedia, mxcUrlToHttp } from '../../utils/matrix'; + +export class CallWidgetDriver extends WidgetDriver { + private allowedCapabilities: Set; + + private readonly mx: MatrixClient; + + public constructor(mx: MatrixClient, private inRoomId: string) { + super(); + this.mx = mx; + + const deviceId = mx.getDeviceId(); + if (!deviceId) throw new Error('Failed to initialize CallWidgetDriver! Device ID not found.'); + + this.allowedCapabilities = getCallCapabilities(inRoomId, mx.getSafeUserId(), deviceId); + } + + public async validateCapabilities(requested: Set): Promise> { + const allow = Array.from(requested).filter((cap) => this.allowedCapabilities.has(cap)); + return new Set(allow); + } + + public async sendEvent( + eventType: string, + content: IContent, + stateKey: string | null = null, + targetRoomId: string | null = null + ): Promise { + const client = this.mx; + const roomId = targetRoomId || this.inRoomId; + + if (!client || !roomId) throw new Error('Not in a room or not attached to a client'); + + let r: { event_id: string } | null; + if (typeof stateKey === 'string') { + r = await client.sendStateEvent( + roomId, + eventType as keyof StateEvents, + content as StateEvents[keyof StateEvents], + stateKey + ); + } else if (eventType === EventType.RoomRedaction) { + // special case: extract the `redacts` property and call redact + r = await client.redactEvent(roomId, content.redacts); + } else { + r = await client.sendEvent( + roomId, + eventType as keyof TimelineEvents, + content as TimelineEvents[keyof TimelineEvents] + ); + } + + return { roomId, eventId: r.event_id }; + } + + public async sendDelayedEvent( + delay: number | null, + parentDelayId: string | null, + eventType: string, + content: IContent, + stateKey: string | null = null, + targetRoomId: string | null = null + ): Promise { + const client = this.mx; + const roomId = targetRoomId || this.inRoomId; + + if (!client || !roomId) throw new Error('Not in a room or not attached to a client'); + + let delayOpts; + if (delay !== null) { + delayOpts = { + delay, + ...(parentDelayId !== null && { parent_delay_id: parentDelayId }), + }; + } else if (parentDelayId !== null) { + delayOpts = { + parent_delay_id: parentDelayId, + }; + } else { + throw new Error('Must provide at least one of delay or parentDelayId'); + } + + let r: SendDelayedEventResponse | null; + if (stateKey !== null) { + // state event + r = await client._unstable_sendDelayedStateEvent( + roomId, + delayOpts, + eventType as keyof StateEvents, + content as StateEvents[keyof StateEvents], + stateKey + ); + } else { + // message event + r = await client._unstable_sendDelayedEvent( + roomId, + delayOpts, + null, + eventType as keyof TimelineEvents, + content as TimelineEvents[keyof TimelineEvents] + ); + } + + return { + roomId, + delayId: r.delay_id, + }; + } + + public async updateDelayedEvent( + delayId: string, + action: UpdateDelayedEventAction + ): Promise { + const client = this.mx; + + if (!client) throw new Error('Not in a room or not attached to a client'); + + await client._unstable_updateDelayedEvent(delayId, action); + } + + public async sendToDevice( + eventType: string, + encrypted: boolean, + contentMap: { [userId: string]: { [deviceId: string]: object } } + ): Promise { + const client = this.mx; + + if (encrypted) { + const crypto = client.getCrypto(); + if (!crypto) throw new Error('E2EE not enabled'); + + // attempt to re-batch these up into a single request + const invertedContentMap: { [content: string]: { userId: string; deviceId: string }[] } = {}; + + // eslint-disable-next-line no-restricted-syntax + for (const userId of Object.keys(contentMap)) { + const userContentMap = contentMap[userId]; + // eslint-disable-next-line no-restricted-syntax + for (const deviceId of Object.keys(userContentMap)) { + const content = userContentMap[deviceId]; + const stringifiedContent = JSON.stringify(content); + invertedContentMap[stringifiedContent] = invertedContentMap[stringifiedContent] || []; + invertedContentMap[stringifiedContent].push({ userId, deviceId }); + } + } + + await Promise.all( + Object.entries(invertedContentMap).map(async ([stringifiedContent, recipients]) => { + const batch = await crypto.encryptToDeviceMessages( + eventType, + recipients, + JSON.parse(stringifiedContent) + ); + + await client.queueToDevice(batch); + }) + ); + } else { + await client.queueToDevice({ + eventType, + batch: Object.entries(contentMap).flatMap(([userId, userContentMap]) => + Object.entries(userContentMap).map(([deviceId, content]) => ({ + userId, + deviceId, + payload: content, + })) + ), + }); + } + } + + public async readRoomTimeline( + roomId: string, + eventType: string, + msgtype: string | undefined, + stateKey: string | undefined, + limit: number, + since: string | undefined + ): Promise { + const safeLimit = + limit > 0 ? Math.min(limit, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary + + const room = this.mx.getRoom(roomId); + if (room === null) return []; + const results: MatrixEvent[] = []; + const events = room.getLiveTimeline().getEvents(); + + for (let i = events.length - 1; i >= 0; i -= 1) { + const ev = events[i]; + if (results.length >= safeLimit) break; + if (since !== undefined && ev.getId() === since) break; + + if ( + ev.getType() === eventType && + !ev.isState() && + (eventType !== EventType.RoomMessage || !msgtype || msgtype === ev.getContent().msgtype) && + (ev.getStateKey() === undefined || stateKey === undefined || ev.getStateKey() === stateKey) + ) { + results.push(ev); + } + } + + return results.map((e) => e.getEffectiveEvent() as IRoomEvent); + } + + public async askOpenID(observer: SimpleObservable): Promise { + return observer.update({ + state: OpenIDRequestState.Allowed, + token: await this.mx.getOpenIdToken(), + }); + } + + public async readRoomState( + roomId: string, + eventType: string, + stateKey: string | undefined + ): Promise { + const room = this.mx.getRoom(roomId); + if (room === null) return []; + const state = room.getLiveTimeline().getState(Direction.Forward); + if (state === undefined) return []; + + if (stateKey === undefined) + return state.getStateEvents(eventType).map((e) => e.getEffectiveEvent() as IRoomEvent); + const event = state.getStateEvents(eventType, stateKey); + return event === null ? [] : [event.getEffectiveEvent() as IRoomEvent]; + } + + public async readEventRelations( + eventId: string, + roomId?: string, + relationType?: string, + eventType?: string, + from?: string, + to?: string, + limit?: number, + direction?: 'f' | 'b' + ): Promise { + const client = this.mx; + const dir = direction as Direction; + const targetRoomId = roomId ?? this.inRoomId ?? undefined; + + if (typeof targetRoomId !== 'string') { + throw new Error('Error while reading the current room'); + } + + const { events, nextBatch, prevBatch } = await client.relations( + targetRoomId, + eventId, + relationType ?? null, + eventType ?? null, + { from, to, limit, dir } + ); + + return { + chunk: events.map((e) => e.getEffectiveEvent() as IRoomEvent), + nextBatch: nextBatch ?? undefined, + prevBatch: prevBatch ?? undefined, + }; + } + + public async searchUserDirectory( + searchTerm: string, + limit?: number + ): Promise { + const client = this.mx; + + const { limited, results } = await client.searchUserDirectory({ term: searchTerm, limit }); + + return { + limited, + results: results.map((r) => ({ + userId: r.user_id, + displayName: r.display_name, + avatarUrl: r.avatar_url, + })), + }; + } + + public async getMediaConfig(): Promise { + const client = this.mx; + + return client.getMediaConfig(); + } + + public async uploadFile(file: XMLHttpRequestBodyInit): Promise<{ contentUri: string }> { + const client = this.mx; + + const uploadResult = await client.uploadContent(file); + + return { contentUri: uploadResult.content_uri }; + } + + public async downloadFile(contentUri: string): Promise<{ file: XMLHttpRequestBodyInit }> { + const httpUrl = mxcUrlToHttp(this.mx, contentUri, true); + if (!httpUrl) { + throw new Error('Call widget failed to download file! No http url!'); + } + const blob = await downloadMedia(httpUrl); + return { file: blob }; + } + + public getKnownRooms(): string[] { + return this.mx.getVisibleRooms().map((r) => r.roomId); + } + + // eslint-disable-next-line class-methods-use-this + public processError(error: unknown): IWidgetApiErrorResponseDataDetails | undefined { + return error instanceof MatrixError + ? { matrix_api_error: error.asWidgetApiErrorData() } + : undefined; + } +} diff --git a/src/app/plugins/call/hooks.ts b/src/app/plugins/call/hooks.ts new file mode 100644 index 00000000..f5b3ff93 --- /dev/null +++ b/src/app/plugins/call/hooks.ts @@ -0,0 +1,49 @@ +import { + ClientWidgetApi, + IWidgetApiAcknowledgeResponseData, + IWidgetApiRequestData, +} from 'matrix-widget-api'; +import { useCallback, useEffect, useState } from 'react'; +import { CallControl, CallControlEvent } from './CallControl'; +import { CallControlState } from './CallControlState'; + +export const useClientWidgetApiEvent = ( + api: ClientWidgetApi | undefined, + type: string, + callback: (event: CustomEvent) => void +) => { + useEffect(() => { + api?.on(`action:${type}`, callback); + return () => { + api?.off(`action:${type}`, callback); + }; + }, [api, type, callback]); +}; + +export const useSendClientWidgetApiAction = (api: ClientWidgetApi) => { + const sendWidgetAction = useCallback( + async ( + action: string, + data: T + ): Promise => api.transport.send(action, data), + [api] + ); + + return sendWidgetAction; +}; + +export const useCallControlState = (control: CallControl): CallControlState => { + const [state, setState] = useState(control.getState()); + + useEffect(() => { + const handleUpdate = () => { + setState(control.getState()); + }; + control.on(CallControlEvent.StateUpdate, handleUpdate); + return () => { + control.off(CallControlEvent.StateUpdate, handleUpdate); + }; + }, [control]); + + return state; +}; diff --git a/src/app/plugins/call/index.ts b/src/app/plugins/call/index.ts new file mode 100644 index 00000000..a4c6bb36 --- /dev/null +++ b/src/app/plugins/call/index.ts @@ -0,0 +1,3 @@ +export * from './CallEmbed'; +export * from './hooks'; +export * from './types'; diff --git a/src/app/plugins/call/types.ts b/src/app/plugins/call/types.ts new file mode 100644 index 00000000..4f4fc381 --- /dev/null +++ b/src/app/plugins/call/types.ts @@ -0,0 +1,25 @@ +export enum ElementCallIntent { + StartCall = 'start_call', + JoinExisting = 'join_existing', + StartCallDM = 'start_call_dm', + JoinExistingDM = 'join_existing_dm', + StartCallDMVoice = 'start_call_dm_voice', + JoinExistingDMVoice = 'join_existing_dm_voice', +} + +export type ElementCallThemeKind = 'light' | 'dark'; + +export type ElementMediaStatePayload = { + audio_enabled?: boolean; + video_enabled?: boolean; +}; +export type ElementMediaStateDetail = { + data?: ElementMediaStatePayload; +}; + +export enum ElementWidgetActions { + JoinCall = 'io.element.join', + HangupCall = 'im.vector.hangup', + Close = 'io.element.close', + DeviceMute = 'io.element.device_mute', +} diff --git a/src/app/plugins/call/utils.ts b/src/app/plugins/call/utils.ts new file mode 100644 index 00000000..0ea72b3c --- /dev/null +++ b/src/app/plugins/call/utils.ts @@ -0,0 +1,118 @@ +import { + type Capability, + EventDirection, + MatrixCapabilities, + WidgetEventCapability, +} from 'matrix-widget-api'; +import { EventType } from 'matrix-js-sdk'; + +export function getCallCapabilities( + roomId: string, + userId: string, + deviceId: string +): Set { + const capabilities: Set = new Set(); + + capabilities.add(MatrixCapabilities.Screenshots); + capabilities.add(MatrixCapabilities.AlwaysOnScreen); + capabilities.add(MatrixCapabilities.MSC3846TurnServers); + capabilities.add(MatrixCapabilities.MSC4157SendDelayedEvent); + capabilities.add(MatrixCapabilities.MSC4157UpdateDelayedEvent); + capabilities.add(`org.matrix.msc2762.timeline:${roomId}`); + capabilities.add(`org.matrix.msc2762.state:${roomId}`); + + capabilities.add( + WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomMember).raw + ); + capabilities.add( + WidgetEventCapability.forStateEvent(EventDirection.Receive, 'org.matrix.msc3401.call').raw + ); + capabilities.add( + WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomEncryption).raw + ); + capabilities.add( + WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomName).raw + ); + + capabilities.add( + WidgetEventCapability.forStateEvent( + EventDirection.Send, + 'org.matrix.msc3401.call.member', + userId + ).raw + ); + capabilities.add( + WidgetEventCapability.forStateEvent( + EventDirection.Send, + 'org.matrix.msc3401.call.member', + `_${userId}_${deviceId}_m.call` + ).raw + ); + capabilities.add( + WidgetEventCapability.forStateEvent( + EventDirection.Send, + 'org.matrix.msc3401.call.member', + `${userId}_${deviceId}_m.call` + ).raw + ); + capabilities.add( + WidgetEventCapability.forStateEvent( + EventDirection.Send, + 'org.matrix.msc3401.call.member', + `_${userId}_${deviceId}` + ).raw + ); + capabilities.add( + WidgetEventCapability.forStateEvent( + EventDirection.Send, + 'org.matrix.msc3401.call.member', + `${userId}_${deviceId}` + ).raw + ); + + capabilities.add( + WidgetEventCapability.forStateEvent(EventDirection.Receive, 'org.matrix.msc3401.call.member') + .raw + ); + capabilities.add( + WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomCreate).raw + ); + + capabilities.add( + WidgetEventCapability.forRoomEvent( + EventDirection.Receive, + 'org.matrix.msc4075.rtc.notification' + ).raw + ); + + [ + 'io.element.call.encryption_keys', + 'org.matrix.rageshake_request', + EventType.Reaction, + EventType.RoomRedaction, + 'io.element.call.reaction', + 'org.matrix.msc4310.rtc.decline', + ].forEach((type) => { + capabilities.add(WidgetEventCapability.forRoomEvent(EventDirection.Send, type).raw); + capabilities.add(WidgetEventCapability.forRoomEvent(EventDirection.Receive, type).raw); + }); + + [ + EventType.CallInvite, + EventType.CallCandidates, + EventType.CallAnswer, + EventType.CallHangup, + EventType.CallReject, + EventType.CallSelectAnswer, + EventType.CallNegotiate, + EventType.CallSDPStreamMetadataChanged, + EventType.CallSDPStreamMetadataChangedPrefix, + EventType.CallReplaces, + EventType.CallEncryptionKeysPrefix, + ].forEach((type) => { + capabilities.add(WidgetEventCapability.forToDeviceEvent(EventDirection.Send, type).raw); + capabilities.add(WidgetEventCapability.forToDeviceEvent(EventDirection.Receive, type).raw); + }); + + return capabilities; +} diff --git a/src/app/state/callEmbed.ts b/src/app/state/callEmbed.ts new file mode 100644 index 00000000..a9f89300 --- /dev/null +++ b/src/app/state/callEmbed.ts @@ -0,0 +1,20 @@ +import { atom } from 'jotai'; +import { CallEmbed } from '../plugins/call'; + +const baseCallEmbedAtom = atom(undefined); + +export const callEmbedAtom = atom( + (get) => get(baseCallEmbedAtom), + (get, set, callEmbed) => { + const prevCallEmbed = get(baseCallEmbedAtom); + if (callEmbed === prevCallEmbed) return; + + if (prevCallEmbed) { + prevCallEmbed.dispose(); + } + + set(baseCallEmbedAtom, callEmbed); + } +); + +export const callChatAtom = atom(false); diff --git a/src/app/state/callPreferences.ts b/src/app/state/callPreferences.ts new file mode 100644 index 00000000..a8e82067 --- /dev/null +++ b/src/app/state/callPreferences.ts @@ -0,0 +1,39 @@ +import { WritableAtom } from 'jotai'; +import { + atomWithLocalStorage, + getLocalStorageItem, + setLocalStorageItem, +} from './utils/atomWithLocalStorage'; + +export type CallPreferences = { + microphone: boolean; + video: boolean; + sound: boolean; +}; + +const CALL_PREFERENCES = 'callPreferences'; + +const DEFAULT_PREFERENCES: CallPreferences = { + microphone: true, + video: false, + sound: true, +}; + +export type CallPreferencesAtom = WritableAtom; + +export const makeCallPreferencesAtom = (userId: string): CallPreferencesAtom => { + const storeKey = `${CALL_PREFERENCES}${userId}`; + + const callPreferencesAtom = atomWithLocalStorage( + storeKey, + (key) => { + const v = getLocalStorageItem(key, DEFAULT_PREFERENCES); + return v; + }, + (key, value) => { + setLocalStorageItem(key, value); + } + ); + + return callPreferencesAtom; +}; diff --git a/src/app/state/createRoomModal.ts b/src/app/state/createRoomModal.ts index 81af5d5b..f32e7665 100644 --- a/src/app/state/createRoomModal.ts +++ b/src/app/state/createRoomModal.ts @@ -1,7 +1,9 @@ import { atom } from 'jotai'; +import { CreateRoomType } from '../components/create-room/types'; export type CreateRoomModalState = { spaceId?: string; + type?: CreateRoomType; }; export const createRoomModalAtom = atom(undefined); diff --git a/src/app/state/hooks/callPreferences.ts b/src/app/state/hooks/callPreferences.ts new file mode 100644 index 00000000..829ed4b4 --- /dev/null +++ b/src/app/state/hooks/callPreferences.ts @@ -0,0 +1,61 @@ +import { createContext, useCallback, useContext } from 'react'; +import { useAtom } from 'jotai'; +import { CallPreferences, CallPreferencesAtom } from '../callPreferences'; + +const CallPreferencesAtomContext = createContext(null); +export const CallPreferencesProvider = CallPreferencesAtomContext.Provider; + +export const useCallPreferencesAtom = (): CallPreferencesAtom => { + const atom = useContext(CallPreferencesAtomContext); + if (!atom) { + throw new Error('CallPreferencesAtom not provided!'); + } + + return atom; +}; + +export const useCallPreferences = (): CallPreferences & { + toggleMicrophone: () => void; + toggleVideo: () => void; + toggleSound: () => void; +} => { + const callPrefAtom = useCallPreferencesAtom(); + const [pref, setPref] = useAtom(callPrefAtom); + + const toggleMicrophone = useCallback(() => { + const microphone = !pref.microphone; + + setPref({ + microphone, + video: pref.video, + sound: !pref.sound && microphone ? true : pref.sound, + }); + }, [setPref, pref]); + + const toggleVideo = useCallback(() => { + const video = !pref.video; + + setPref({ + microphone: pref.microphone, + video, + sound: pref.sound, + }); + }, [setPref, pref]); + + const toggleSound = useCallback(() => { + const sound = !pref.sound; + + setPref({ + microphone: !sound ? false : pref.microphone, + video: pref.video, + sound, + }); + }, [setPref, pref]); + + return { + ...pref, + toggleMicrophone, + toggleVideo, + toggleSound, + }; +}; diff --git a/src/app/state/hooks/createRoomModal.ts b/src/app/state/hooks/createRoomModal.ts index 15db7289..2663e5dc 100644 --- a/src/app/state/hooks/createRoomModal.ts +++ b/src/app/state/hooks/createRoomModal.ts @@ -1,6 +1,7 @@ import { useCallback } from 'react'; import { useAtomValue, useSetAtom } from 'jotai'; import { createRoomModalAtom, CreateRoomModalState } from '../createRoomModal'; +import { CreateRoomType } from '../../components/create-room/types'; export const useCreateRoomModalState = (): CreateRoomModalState | undefined => { const data = useAtomValue(createRoomModalAtom); @@ -19,13 +20,13 @@ export const useCloseCreateRoomModal = (): CloseCallback => { return close; }; -type OpenCallback = (space?: string) => void; +type OpenCallback = (space?: string, type?: CreateRoomType) => void; export const useOpenCreateRoomModal = (): OpenCallback => { const setSettings = useSetAtom(createRoomModalAtom); const open: OpenCallback = useCallback( - (spaceId) => { - setSettings({ spaceId }); + (spaceId, type) => { + setSettings({ spaceId, type }); }, [setSettings] ); diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index c282a0a2..05dfeb29 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -161,7 +161,8 @@ export const getOrphanParents = (roomToParents: RoomToParents, roomId: string): export const isMutedRule = (rule: IPushRule) => // Check for empty actions (new spec) or dont_notify (deprecated) - (rule.actions.length === 0 || rule.actions[0] === 'dont_notify') && rule.conditions?.[0]?.kind === 'event_match'; + (rule.actions.length === 0 || rule.actions[0] === 'dont_notify') && + rule.conditions?.[0]?.kind === 'event_match'; export const findMutedRule = (overrideRules: IPushRule[], roomId: string) => overrideRules.find((rule) => rule.rule_id === roomId && isMutedRule(rule)); @@ -257,24 +258,44 @@ export const getUnreadInfos = (mx: MatrixClient): UnreadInfo[] => { return unreadInfos; }; -export const joinRuleToIconSrc = ( +export const getRoomIconSrc = ( icons: Record, - joinRule: JoinRule, - space: boolean -): IconSrc | undefined => { - if (joinRule === JoinRule.Restricted) { - return space ? icons.Space : icons.Hash; + roomType?: string, + joinRule?: JoinRule +): IconSrc => { + if (roomType === RoomType.Space) { + if (joinRule === JoinRule.Public) return icons.SpaceGlobe; + if ( + joinRule === JoinRule.Invite || + joinRule === JoinRule.Knock || + joinRule === JoinRule.Private + ) { + return icons.SpaceLock; + } + return icons.Space; } - if (joinRule === JoinRule.Knock) { - return space ? icons.SpaceLock : icons.HashLock; + + if (roomType === RoomType.Call) { + if (joinRule === JoinRule.Public) return icons.VolumeHighGlobe; + if ( + joinRule === JoinRule.Invite || + joinRule === JoinRule.Knock || + joinRule === JoinRule.Private + ) { + return icons.VolumeHighLock; + } + return icons.VolumeHigh; } - if (joinRule === JoinRule.Invite) { - return space ? icons.SpaceLock : icons.HashLock; + + if (joinRule === JoinRule.Public) return icons.HashGlobe; + if ( + joinRule === JoinRule.Invite || + joinRule === JoinRule.Knock || + joinRule === JoinRule.Private + ) { + return icons.HashLock; } - if (joinRule === JoinRule.Public) { - return space ? icons.SpaceGlobe : icons.HashGlobe; - } - return undefined; + return icons.Hash; }; export const getRoomAvatarUrl = ( diff --git a/src/types/matrix/room.ts b/src/types/matrix/room.ts index b866fd77..c73c0f0b 100644 --- a/src/types/matrix/room.ts +++ b/src/types/matrix/room.ts @@ -32,6 +32,8 @@ export enum StateEvent { RoomGuestAccess = 'm.room.guest_access', RoomServerAcl = 'm.room.server_acl', RoomTombstone = 'm.room.tombstone', + GroupCallPrefix = 'org.matrix.msc3401.call', + GroupCallMemberPrefix = 'org.matrix.msc3401.call.member', SpaceChild = 'm.space.child', SpaceParent = 'm.space.parent', @@ -50,6 +52,7 @@ export enum MessageEvent { export enum RoomType { Space = 'm.space', + Call = 'org.matrix.msc3417.call', } export type MSpaceChildContent = { diff --git a/vite.config.js b/vite.config.js index dfa02fc4..a5af94d8 100644 --- a/vite.config.js +++ b/vite.config.js @@ -13,6 +13,10 @@ import buildConfig from './build.config'; const copyFiles = { targets: [ + { + src: 'node_modules/@element-hq/element-call-embedded/dist/*', + dest: 'public/element-call', + }, { src: 'node_modules/pdfjs-dist/build/pdf.worker.min.mjs', dest: '', @@ -47,7 +51,10 @@ function serverMatrixSdkCryptoWasm(wasmFilePath) { configureServer(server) { server.middlewares.use((req, res, next) => { if (req.url === wasmFilePath) { - const resolvedPath = path.join(path.resolve(), "/node_modules/@matrix-org/matrix-sdk-crypto-wasm/pkg/matrix_sdk_crypto_wasm_bg.wasm"); + const resolvedPath = path.join( + path.resolve(), + '/node_modules/@matrix-org/matrix-sdk-crypto-wasm/pkg/matrix_sdk_crypto_wasm_bg.wasm' + ); if (fs.existsSync(resolvedPath)) { res.setHeader('Content-Type', 'application/wasm'); @@ -102,8 +109,8 @@ export default defineConfig({ }, devOptions: { enabled: true, - type: 'module' - } + type: 'module', + }, }), ], optimizeDeps: { From c6bb4915bc188a96ab5861ccfaf8c78c986c1907 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Sat, 7 Mar 2026 20:47:45 +1100 Subject: [PATCH 054/206] Downgrade matrix-widget-api from 1.17.0 to 1.13.0 (#2736) Some user was having Disconnection Error --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4648a9fd..ceb7e8c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "linkify-react": "4.3.2", "linkifyjs": "4.3.2", "matrix-js-sdk": "38.2.0", - "matrix-widget-api": "1.17.0", + "matrix-widget-api": "1.13.0", "millify": "6.1.0", "pdfjs-dist": "4.2.67", "prismjs": "1.30.0", @@ -8674,9 +8674,9 @@ } }, "node_modules/matrix-widget-api": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.17.0.tgz", - "integrity": "sha512-5FHoo3iEP3Bdlv5jsYPWOqj+pGdFQNLWnJLiB0V7Ygne7bb+Gsj3ibyFyHWC6BVw+Z+tSW4ljHpO17I9TwStwQ==", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.13.0.tgz", + "integrity": "sha512-+LrvwkR1izL4h2euX8PDrvG/3PZZDEd6As+lmnR3jAVwbFJtU5iTnwmZGnCca9ddngCvXvAHkcpJBEPyPTZneQ==", "license": "Apache-2.0", "dependencies": { "@types/events": "^3.0.0", diff --git a/package.json b/package.json index 4eeead2d..e6204a21 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "linkify-react": "4.3.2", "linkifyjs": "4.3.2", "matrix-js-sdk": "38.2.0", - "matrix-widget-api": "1.17.0", + "matrix-widget-api": "1.13.0", "millify": "6.1.0", "pdfjs-dist": "4.2.67", "prismjs": "1.30.0", From 7953ec80e50e2272e24d04cec3f643cc3c222771 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Sun, 8 Mar 2026 14:22:11 +1100 Subject: [PATCH 055/206] Apply deafen state when call member changes (#2737) * fix deafen not working * apply deafen state when call member changes * remove unnecessary condition --- src/app/components/CallEmbedProvider.tsx | 2 ++ src/app/features/call/CallView.tsx | 4 ++-- src/app/hooks/useCall.ts | 9 +++++++++ src/app/hooks/useCallEmbed.ts | 11 ++++++++++- src/app/plugins/call/CallControl.ts | 10 ++++++---- 5 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index 9f4b3f2b..a78c210b 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -6,6 +6,7 @@ import { useCallHangupEvent, useCallJoined, useCallThemeSync, + useCallMemberSoundSync, } from '../hooks/useCallEmbed'; import { callChatAtom, callEmbedAtom } from '../state/callEmbed'; import { CallEmbed } from '../plugins/call'; @@ -15,6 +16,7 @@ import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize'; function CallUtils({ embed }: { embed: CallEmbed }) { const setCallEmbed = useSetAtom(callEmbedAtom); + useCallMemberSoundSync(embed); useCallThemeSync(embed); useCallHangupEvent( embed, diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx index b2a200fb..a8923d93 100644 --- a/src/app/features/call/CallView.tsx +++ b/src/app/features/call/CallView.tsx @@ -1,6 +1,6 @@ import React, { useRef } from 'react'; import { Badge, Box, color, Header, Scroll, Text, toRem } from 'folds'; -import { useCallEmbed, useCallJoined, useSyncCallEmbedPlacement } from '../../hooks/useCallEmbed'; +import { useCallEmbed, useCallJoined, useCallEmbedPlacementSync } from '../../hooks/useCallEmbed'; import { ContainerColor } from '../../styles/ContainerColor.css'; import { PrescreenControls } from './PrescreenControls'; import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; @@ -44,7 +44,7 @@ export function CallView() { const room = useRoom(); const callViewRef = useRef(null); - useSyncCallEmbedPlacement(callViewRef); + useCallEmbedPlacementSync(callViewRef); const powerLevels = usePowerLevelsContext(); const creators = useRoomCreators(room); diff --git a/src/app/hooks/useCall.ts b/src/app/hooks/useCall.ts index a4374fcd..1364e459 100644 --- a/src/app/hooks/useCall.ts +++ b/src/app/hooks/useCall.ts @@ -53,3 +53,12 @@ export const useCallMembers = (room: Room, session: MatrixRTCSession): CallMembe return memberships; }; + +export const useCallMembersChange = (session: MatrixRTCSession, callback: () => void): void => { + useEffect(() => { + session.on(MatrixRTCSessionEvent.MembershipsChanged, callback); + return () => { + session.removeListener(MatrixRTCSessionEvent.MembershipsChanged, callback); + }; + }, [session, callback]); +}; diff --git a/src/app/hooks/useCallEmbed.ts b/src/app/hooks/useCallEmbed.ts index e04182dd..d91df6aa 100644 --- a/src/app/hooks/useCallEmbed.ts +++ b/src/app/hooks/useCallEmbed.ts @@ -13,6 +13,7 @@ import { ThemeKind, useTheme } from './useTheme'; import { callEmbedAtom } from '../state/callEmbed'; import { useResizeObserver } from './useResizeObserver'; import { CallControlState } from '../plugins/call/CallControlState'; +import { useCallMembersChange, useCallSession } from './useCall'; const CallEmbedContext = createContext(undefined); @@ -99,6 +100,14 @@ export const useCallHangupEvent = (embed: CallEmbed, callback: () => void) => { useClientWidgetApiEvent(embed.call, ElementWidgetActions.HangupCall, callback); }; +export const useCallMemberSoundSync = (embed: CallEmbed) => { + const callSession = useCallSession(embed.room); + useCallMembersChange( + callSession, + useCallback(() => embed.control.applySound(), [embed]) + ); +}; + export const useCallThemeSync = (embed: CallEmbed) => { const theme = useTheme(); @@ -109,7 +118,7 @@ export const useCallThemeSync = (embed: CallEmbed) => { }, [theme.kind, embed]); }; -export const useSyncCallEmbedPlacement = (containerViewRef: RefObject): void => { +export const useCallEmbedPlacementSync = (containerViewRef: RefObject): void => { const callEmbedRef = useCallEmbedRef(); const syncCallEmbedPlacement = useCallback(() => { diff --git a/src/app/plugins/call/CallControl.ts b/src/app/plugins/call/CallControl.ts index c2087fe0..44dcacd7 100644 --- a/src/app/plugins/call/CallControl.ts +++ b/src/app/plugins/call/CallControl.ts @@ -47,6 +47,10 @@ export class CallControl extends EventEmitter implements CallControlState { this.emitStateUpdate(); } + public applySound() { + this.setSound(this.sound); + } + private setMediaState(state: ElementMediaStatePayload) { return this.call.transport.send(ElementWidgetActions.DeviceMute, state); } @@ -55,10 +59,8 @@ export class CallControl extends EventEmitter implements CallControlState { const callDocument = this.iframe.contentDocument ?? this.iframe.contentWindow?.document; if (callDocument) { callDocument.querySelectorAll('audio').forEach((el) => { - if (el instanceof HTMLAudioElement) { - // eslint-disable-next-line no-param-reassign - el.muted = !sound; - } + // eslint-disable-next-line no-param-reassign + el.muted = !sound; }); } } From 55e83065767645ed7cd510a6f4b5cf4733b000b4 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Sun, 8 Mar 2026 22:00:35 +1100 Subject: [PATCH 056/206] Display call member speaking status on bottom bar (#2742) * add mutation observer hok * add hook to read speaking member by observing iframe content * display speaking member name in call status bar and improve layout * fix shrining --- src/app/features/call-status/CallControl.tsx | 19 ++--- src/app/features/call-status/CallStatus.tsx | 43 +++++++--- src/app/features/call-status/MemberGlance.tsx | 5 +- .../features/call-status/MemberSpeaking.tsx | 78 +++++++++++++++++++ src/app/features/call-status/styles.css.ts | 4 + src/app/hooks/useCallSpeakers.ts | 60 ++++++++++++++ src/app/hooks/useMutationObserver.ts | 37 +++++++++ src/app/plugins/call/CallEmbed.ts | 4 + 8 files changed, 229 insertions(+), 21 deletions(-) create mode 100644 src/app/features/call-status/MemberSpeaking.tsx create mode 100644 src/app/hooks/useCallSpeakers.ts create mode 100644 src/app/hooks/useMutationObserver.ts diff --git a/src/app/features/call-status/CallControl.tsx b/src/app/features/call-status/CallControl.tsx index 39cd975b..097d00c9 100644 --- a/src/app/features/call-status/CallControl.tsx +++ b/src/app/features/call-status/CallControl.tsx @@ -20,12 +20,12 @@ function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) { {(anchorRef) => ( onToggle()} - outlined={!enabled} + outlined > @@ -51,12 +51,12 @@ function SoundButton({ enabled, onToggle }: SoundButtonProps) { {(anchorRef) => ( onToggle()} - outlined={!enabled} + outlined > ( onToggle()} - outlined={enabled} + outlined > ( setEnabled(!enabled)} - outlined={enabled} + outlined > @@ -144,9 +144,6 @@ export function CallControl({ callEmbed }: { callEmbed: CallEmbed }) { onToggle={() => callEmbed.control.toggleMicrophone()} /> callEmbed.control.toggleSound()} /> - - - callEmbed.control.toggleVideo()} /> {false && } diff --git a/src/app/features/call-status/CallStatus.tsx b/src/app/features/call-status/CallStatus.tsx index e2a17222..2b8b809f 100644 --- a/src/app/features/call-status/CallStatus.tsx +++ b/src/app/features/call-status/CallStatus.tsx @@ -12,6 +12,8 @@ import { MemberGlance } from './MemberGlance'; import { StatusDivider } from './components'; import { CallEmbed } from '../../plugins/call/CallEmbed'; import { useCallJoined } from '../../hooks/useCallEmbed'; +import { useCallSpeakers } from '../../hooks/useCallSpeakers'; +import { MemberSpeaking } from './MemberSpeaking'; type CallStatusProps = { callEmbed: CallEmbed; @@ -23,28 +25,51 @@ export function CallStatus({ callEmbed }: CallStatusProps) { const callMembers = useCallMembers(room, callSession); const screenSize = useScreenSize(); const callJoined = useCallJoined(callEmbed); + const speakers = useCallSpeakers(callEmbed); + + const compact = screenSize === ScreenSize.Mobile; + + const memberVisible = callJoined && callMembers.length > 0; return ( - - {callJoined && callMembers.length > 0 ? ( - - + + {memberVisible ? ( + ) : ( )} - - + + + {speakers.size > 0 && !compact && ( + <> + + + + + )} + + {memberVisible && ( + + + + )} - + {memberVisible && !compact && } + diff --git a/src/app/features/call-status/MemberGlance.tsx b/src/app/features/call-status/MemberGlance.tsx index f4991f93..2e65069a 100644 --- a/src/app/features/call-status/MemberGlance.tsx +++ b/src/app/features/call-status/MemberGlance.tsx @@ -10,13 +10,15 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { StackedAvatar } from '../../components/stacked-avatar'; import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { getMouseEventCords } from '../../utils/dom'; +import * as css from './styles.css'; type MemberGlanceProps = { room: Room; members: CallMembership[]; + speakers: Set; max?: number; }; -export function MemberGlance({ room, members, max = 6 }: MemberGlanceProps) { +export function MemberGlance({ room, members, speakers, max = 6 }: MemberGlanceProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const openUserProfile = useOpenUserRoomProfile(); @@ -38,6 +40,7 @@ export function MemberGlance({ room, members, max = 6 }: MemberGlanceProps) { return ( ; +}; +export function MemberSpeaking({ room, speakers }: MemberSpeakingProps) { + const speakingNames = Array.from(speakers).map( + (userId) => getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId + ); + return ( + + + + {speakingNames.length === 1 && ( + <> + {speakingNames[0]} + + {' is speaking...'} + + + )} + {speakingNames.length === 2 && ( + <> + {speakingNames[0]} + + {' and '} + + {speakingNames[1]} + + {' are speaking...'} + + + )} + {speakingNames.length === 3 && ( + <> + {speakingNames[0]} + + {', '} + + {speakingNames[1]} + + {' and '} + + {speakingNames[2]} + + {' are speaking...'} + + + )} + {speakingNames.length > 3 && ( + <> + {speakingNames[0]} + + {', '} + + {speakingNames[1]} + + {', '} + + {speakingNames[2]} + + {' and '} + + {speakingNames.length - 3} others + + {' are speaking...'} + + + )} + + + ); +} diff --git a/src/app/features/call-status/styles.css.ts b/src/app/features/call-status/styles.css.ts index 26b08f0b..dd32bc3d 100644 --- a/src/app/features/call-status/styles.css.ts +++ b/src/app/features/call-status/styles.css.ts @@ -15,3 +15,7 @@ export const CallStatus = style([ export const ControlDivider = style({ height: toRem(16), }); + +export const SpeakerAvatarOutline = style({ + boxShadow: `0 0 0 ${config.borderWidth.B600} ${color.Success.Main}`, +}); diff --git a/src/app/hooks/useCallSpeakers.ts b/src/app/hooks/useCallSpeakers.ts new file mode 100644 index 00000000..24003678 --- /dev/null +++ b/src/app/hooks/useCallSpeakers.ts @@ -0,0 +1,60 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { CallEmbed } from '../plugins/call'; +import { useMutationObserver } from './useMutationObserver'; +import { isUserId } from '../utils/matrix'; +import { useCallMembers, useCallSession } from './useCall'; +import { useCallJoined } from './useCallEmbed'; + +export const useCallSpeakers = (callEmbed: CallEmbed): Set => { + const [speakers, setSpeakers] = useState(new Set()); + const callSession = useCallSession(callEmbed.room); + const callMembers = useCallMembers(callEmbed.room, callSession); + const joined = useCallJoined(callEmbed); + + const videoContainers = useMemo(() => { + if (callMembers && joined) return callEmbed.document?.querySelectorAll('[data-video-fit]'); + return undefined; + }, [callEmbed, callMembers, joined]); + + const mutationObserver = useMutationObserver( + useCallback( + (mutations) => { + const s = new Set(); + + mutations.forEach((mutation) => { + if (mutation.type !== 'attributes') return; + const el = mutation.target as HTMLElement; + + const style = callEmbed.iframe.contentWindow?.getComputedStyle(el, '::before'); + if (!style) return; + const tileBackgroundImage = style.getPropertyValue('background-image'); + const speaking = tileBackgroundImage !== 'none'; + if (!speaking) return; + + const speakerId = el.querySelector('[aria-label]')?.getAttribute('aria-label'); + if (speakerId && isUserId(speakerId)) { + s.add(speakerId); + } + }); + + setSpeakers(s); + }, + [callEmbed] + ) + ); + + useEffect(() => { + videoContainers?.forEach((element) => { + mutationObserver.observe(element, { + attributes: true, + attributeFilter: ['class', 'style'], + }); + }); + + return () => { + mutationObserver.disconnect(); + }; + }, [videoContainers, mutationObserver]); + + return speakers; +}; diff --git a/src/app/hooks/useMutationObserver.ts b/src/app/hooks/useMutationObserver.ts new file mode 100644 index 00000000..5d0ce2ab --- /dev/null +++ b/src/app/hooks/useMutationObserver.ts @@ -0,0 +1,37 @@ +import { useEffect, useMemo } from 'react'; + +export type OnMutationCallback = (mutations: MutationRecord[]) => void; + +export const getMutationRecord = ( + target: Node, + mutations: MutationRecord[] +): MutationRecord | undefined => mutations.find((mutation) => mutation.target === target); + +export const useMutationObserver = ( + onMutationCallback: OnMutationCallback, + observeElement?: Node | null | (() => Node | null), + options?: MutationObserverInit +): MutationObserver => { + const mutationObserver = useMemo( + () => new MutationObserver(onMutationCallback), + [onMutationCallback] + ); + + useEffect(() => () => mutationObserver?.disconnect(), [mutationObserver]); + + useEffect(() => { + const element = typeof observeElement === 'function' ? observeElement() : observeElement; + + if (element) { + mutationObserver.observe(element, options); + } + + return () => { + if (element) { + mutationObserver.disconnect(); + } + }; + }, [mutationObserver, observeElement, options]); + + return mutationObserver; +}; diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts index b852e3ef..f01cd685 100644 --- a/src/app/plugins/call/CallEmbed.ts +++ b/src/app/plugins/call/CallEmbed.ts @@ -163,6 +163,10 @@ export class CallEmbed { return this.room.roomId; } + get document(): Document | undefined { + return this.iframe.contentDocument ?? this.iframe.contentWindow?.document; + } + public setTheme(theme: ElementCallThemeKind) { return this.call.transport.send(WidgetApiToWidgetAction.ThemeChange, { name: theme, From bc6caddcc8bfe7948c93db1f0662295976891c97 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Mon, 9 Mar 2026 14:04:48 +1100 Subject: [PATCH 057/206] Add own control buttons for element-call (#2744) * add mutation observer hok * add hook to read speaking member by observing iframe content * display speaking member name in call status bar and improve layout * fix shrining * add joined call control bar * remove chat toggle from room header * change member speaking icon to mic * fix joined call control appear in other * show spinner on end call button * hide call statusbar for mobile view when room is selected * make call statusbar more mobile friendly * fix call status bar item align --- src/app/components/CallEmbedProvider.tsx | 1 + src/app/features/call-status/CallControl.tsx | 54 +++-- src/app/features/call-status/CallStatus.tsx | 30 +-- src/app/features/call-status/LiveChip.tsx | 2 +- .../features/call-status/MemberSpeaking.tsx | 2 +- src/app/features/call/CallControls.tsx | 203 ++++++++++++++++++ src/app/features/call/CallView.tsx | 95 ++++---- src/app/features/call/Controls.tsx | 32 +++ src/app/features/call/PrescreenControls.tsx | 3 +- src/app/features/call/styles.css.ts | 4 + src/app/features/room-nav/RoomNavItem.tsx | 3 +- src/app/features/room/RoomViewHeader.tsx | 22 -- src/app/hooks/useCallEmbed.ts | 9 +- src/app/pages/CallStatusRenderer.tsx | 7 + src/app/plugins/call/CallControl.ts | 124 ++++++++++- src/app/plugins/call/CallControlState.ts | 14 +- src/app/plugins/call/CallEmbed.ts | 16 ++ 17 files changed, 521 insertions(+), 100 deletions(-) create mode 100644 src/app/features/call/CallControls.tsx diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index a78c210b..b50b1f50 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -1,5 +1,6 @@ import React, { ReactNode, useCallback, useRef } from 'react'; import { useAtomValue, useSetAtom } from 'jotai'; +import { config } from 'folds'; import { CallEmbedContextProvider, CallEmbedRefContextProvider, diff --git a/src/app/features/call-status/CallControl.tsx b/src/app/features/call-status/CallControl.tsx index 097d00c9..2f2bac7f 100644 --- a/src/app/features/call-status/CallControl.tsx +++ b/src/app/features/call-status/CallControl.tsx @@ -1,7 +1,8 @@ -import { Box, Chip, Icon, IconButton, Icons, Text, Tooltip, TooltipProvider } from 'folds'; -import React, { useState } from 'react'; +import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, Tooltip, TooltipProvider } from 'folds'; +import React, { useCallback } from 'react'; import { StatusDivider } from './components'; import { CallEmbed, useCallControlState } from '../../plugins/call'; +import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; type MicrophoneButtonProps = { enabled: boolean; @@ -104,9 +105,11 @@ function VideoButton({ enabled, onToggle }: VideoButtonProps) { ); } -function ScreenShareButton() { - const [enabled, setEnabled] = useState(false); - +type ScreenShareButtonProps = { + enabled: boolean; + onToggle: () => void; +}; +function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps) { return ( setEnabled(!enabled)} + onClick={onToggle} outlined > @@ -133,8 +136,14 @@ function ScreenShareButton() { ); } -export function CallControl({ callEmbed }: { callEmbed: CallEmbed }) { - const { microphone, video, sound } = useCallControlState(callEmbed.control); +export function CallControl({ callEmbed, compact }: { callEmbed: CallEmbed; compact: boolean }) { + const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control); + + const [hangupState, hangup] = useAsyncCallback( + useCallback(() => callEmbed.hangup(), [callEmbed]) + ); + const exiting = + hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success; return ( @@ -144,21 +153,36 @@ export function CallControl({ callEmbed }: { callEmbed: CallEmbed }) { onToggle={() => callEmbed.control.toggleMicrophone()} /> callEmbed.control.toggleSound()} /> + {!compact && } callEmbed.control.toggleVideo()} /> - {false && } + {!compact && ( + callEmbed.control.toggleScreenshare()} + /> + )} } + before={ + exiting ? ( + + ) : ( + + ) + } + disabled={exiting} outlined - onClick={() => callEmbed.hangup()} + onClick={hangup} > - - End - + {!compact && ( + + End + + )} ); diff --git a/src/app/features/call-status/CallStatus.tsx b/src/app/features/call-status/CallStatus.tsx index 2b8b809f..5d2182c2 100644 --- a/src/app/features/call-status/CallStatus.tsx +++ b/src/app/features/call-status/CallStatus.tsx @@ -47,18 +47,17 @@ export function CallStatus({ callEmbed }: CallStatusProps) { ) : ( )} - - - {speakers.size > 0 && !compact && ( + + {!compact && ( <> - - - + + {speakers.size > 0 && ( + <> + + + + + )} )} @@ -69,8 +68,13 @@ export function CallStatus({ callEmbed }: CallStatusProps) { )} {memberVisible && !compact && } - - + + {compact && ( + + + + )} + ); diff --git a/src/app/features/call-status/LiveChip.tsx b/src/app/features/call-status/LiveChip.tsx index 34167fb6..a5d00a55 100644 --- a/src/app/features/call-status/LiveChip.tsx +++ b/src/app/features/call-status/LiveChip.tsx @@ -128,7 +128,7 @@ export function LiveChip({ count, room, members }: LiveChipProps) { radii="Pill" onClick={handleOpenMenu} > - + {count} Live diff --git a/src/app/features/call-status/MemberSpeaking.tsx b/src/app/features/call-status/MemberSpeaking.tsx index ddde7e6e..27e272f2 100644 --- a/src/app/features/call-status/MemberSpeaking.tsx +++ b/src/app/features/call-status/MemberSpeaking.tsx @@ -14,7 +14,7 @@ export function MemberSpeaking({ room, speakers }: MemberSpeakingProps) { ); return ( - + {speakingNames.length === 1 && ( <> diff --git a/src/app/features/call/CallControls.tsx b/src/app/features/call/CallControls.tsx new file mode 100644 index 00000000..72edc57f --- /dev/null +++ b/src/app/features/call/CallControls.tsx @@ -0,0 +1,203 @@ +import React, { MouseEventHandler, useCallback, useRef, useState } from 'react'; +import { + Box, + Button, + config, + Icon, + IconButton, + Icons, + Menu, + MenuItem, + PopOut, + RectCords, + Spinner, + Text, + toRem, +} from 'folds'; +import FocusTrap from 'focus-trap-react'; +import { SequenceCard } from '../../components/sequence-card'; +import * as css from './styles.css'; +import { + ChatButton, + ControlDivider, + MicrophoneButton, + ScreenShareButton, + SoundButton, + VideoButton, +} from './Controls'; +import { CallEmbed, useCallControlState } from '../../plugins/call'; +import { useResizeObserver } from '../../hooks/useResizeObserver'; +import { stopPropagation } from '../../utils/keyboard'; +import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; + +type CallControlsProps = { + callEmbed: CallEmbed; +}; +export function CallControls({ callEmbed }: CallControlsProps) { + const controlRef = useRef(null); + const [compact, setCompact] = useState(document.body.clientWidth < 500); + + useResizeObserver( + useCallback(() => { + const element = controlRef.current; + if (!element) return; + setCompact(element.clientWidth < 500); + }, []), + useCallback(() => controlRef.current, []) + ); + + const { microphone, video, sound, screenshare, spotlight } = useCallControlState( + callEmbed.control + ); + + const [cords, setCords] = useState(); + + const handleOpenMenu: MouseEventHandler = (evt) => { + setCords(evt.currentTarget.getBoundingClientRect()); + }; + + const handleSpotlightClick = () => { + callEmbed.control.toggleSpotlight(); + setCords(undefined); + }; + + const handleReactionsClick = () => { + callEmbed.control.toggleReactions(); + setCords(undefined); + }; + + const handleSettingsClick = () => { + callEmbed.control.toggleSettings(); + setCords(undefined); + }; + + const [hangupState, hangup] = useAsyncCallback( + useCallback(() => callEmbed.hangup(), [callEmbed]) + ); + const exiting = + hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success; + + return ( + + + + + callEmbed.control.toggleMicrophone()} + /> + callEmbed.control.toggleSound()} /> + + {!compact && } + + callEmbed.control.toggleVideo()} /> + callEmbed.control.toggleScreenshare()} + /> + + + {!compact && } + + + + setCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + + + + + {spotlight ? 'Grid View' : 'Spotlight View'} + + + + + Reactions + + + + + Settings + + + + + + } + > + + + + + + + + + + + + ); +} diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx index a8923d93..0cddd2be 100644 --- a/src/app/features/call/CallView.tsx +++ b/src/app/features/call/CallView.tsx @@ -1,4 +1,4 @@ -import React, { useRef } from 'react'; +import React, { RefObject, useRef } from 'react'; import { Badge, Box, color, Header, Scroll, Text, toRem } from 'folds'; import { useCallEmbed, useCallJoined, useCallEmbedPlacementSync } from '../../hooks/useCallEmbed'; import { ContainerColor } from '../../styles/ContainerColor.css'; @@ -12,6 +12,7 @@ import { StateEvent } from '../../../types/matrix/room'; import { useCallMembers, useCallSession } from '../../hooks/useCall'; import { CallMemberRenderer } from './CallMemberCard'; import * as css from './styles.css'; +import { CallControls } from './CallControls'; function JoinMessage({ hasParticipant }: { hasParticipant?: boolean }) { if (hasParticipant) return null; @@ -39,13 +40,10 @@ function AlreadyInCallMessage() { ); } -export function CallView() { +function CallPrescreen() { const mx = useMatrixClient(); const room = useRoom(); - const callViewRef = useRef(null); - useCallEmbedPlacementSync(callViewRef); - const powerLevels = usePowerLevelsContext(); const creators = useRoomCreators(room); @@ -57,49 +55,70 @@ export function CallView() { const hasParticipant = callMembers.length > 0; const callEmbed = useCallEmbed(); - const callJoined = useCallJoined(callEmbed); const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId; + return ( + + + + {hasParticipant && ( +
+ + Participant + + + + {callMembers.length} Live + + +
+ )} + + +
+ {!inOtherCall && + (canJoin ? : )} + {inOtherCall && } +
+
+
+
+ ); +} + +type CallJoinedProps = { + containerRef: RefObject; + joined: boolean; +}; +function CallJoined({ joined, containerRef }: CallJoinedProps) { + const callEmbed = useCallEmbed(); + + return ( + + + {callEmbed && joined && } + + ); +} + +export function CallView() { + const room = useRoom(); + const callContainerRef = useRef(null); + useCallEmbedPlacementSync(callContainerRef); + + const callEmbed = useCallEmbed(); + const callJoined = useCallJoined(callEmbed); + const currentJoined = callEmbed?.roomId === room.roomId && callJoined; return ( - {!currentJoined && ( - - - - {hasParticipant && ( -
- - Participant - - - - {callMembers.length} Live - - -
- )} - - -
- {!inOtherCall && - (canJoin ? ( - - ) : ( - - ))} - {inOtherCall && } -
-
-
-
- )} + {!currentJoined && } +
); } diff --git a/src/app/features/call/Controls.tsx b/src/app/features/call/Controls.tsx index 39c5d87d..143a8022 100644 --- a/src/app/features/call/Controls.tsx +++ b/src/app/features/call/Controls.tsx @@ -114,6 +114,38 @@ export function VideoButton({ enabled, onToggle }: VideoButtonProps) { ); } +type ScreenShareButtonProps = { + enabled: boolean; + onToggle: () => void; +}; +export function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps) { + return ( + + {enabled ? 'Stop Screenshare' : 'Start Screenshare'} + + } + > + {(anchorRef) => ( + onToggle()} + outlined + > + + + )} + + ); +} + export function ChatButton() { const [chat, setChat] = useAtom(callChatAtom); diff --git a/src/app/features/call/PrescreenControls.tsx b/src/app/features/call/PrescreenControls.tsx index 4782c9b6..1174bbf1 100644 --- a/src/app/features/call/PrescreenControls.tsx +++ b/src/app/features/call/PrescreenControls.tsx @@ -6,7 +6,6 @@ import { ChatButton, ControlDivider, MicrophoneButton, SoundButton, VideoButton import { useIsDirectRoom, useRoom } from '../../hooks/useRoom'; import { useCallEmbed, useCallJoined, useCallStart } from '../../hooks/useCallEmbed'; import { useCallPreferences } from '../../state/hooks/callPreferences'; -import { CallControlState } from '../../plugins/call/CallControlState'; type PrescreenControlsProps = { canJoin?: boolean; @@ -50,7 +49,7 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) { -
- -
- - )} - {loading || !mx ? ( - - ) : ( - - - {(serverConfigs) => ( - - - - {children} - - - - )} - - - )} - + + + {mx && } + {loading && } + {(loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error) && ( + + + + + {loadState.status === AsyncStatus.Error && ( + {`Failed to load. ${loadState.error.message}`} + )} + {startState.status === AsyncStatus.Error && ( + {`Failed to start. ${startState.error.message}`} + )} + + + + + + )} + {loading || !mx ? ( + + ) : ( + + + {(serverConfigs) => ( + + + + {children} + + + + )} + + + )} + + ); } diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts index aeb28e36..87046676 100644 --- a/src/app/plugins/call/CallEmbed.ts +++ b/src/app/plugins/call/CallEmbed.ts @@ -146,7 +146,7 @@ export class CallEmbed { let initialMediaEvent = true; this.disposables.push( - this.listenEvent(ElementWidgetActions.DeviceMute, (evt) => { + this.listenAction(ElementWidgetActions.DeviceMute, (evt) => { if (initialMediaEvent) { initialMediaEvent = false; this.control.applyState(); @@ -177,18 +177,27 @@ export class CallEmbed { return this.call.transport.send(ElementWidgetActions.HangupCall, {}); } - public listenEvent(type: string, callback: (event: CustomEvent) => void) { - this.call.on(`action:${type}`, callback); - return () => { - this.call.off(`action:${type}`, callback); - }; + public onPreparing(callback: () => void) { + return this.listenEvent('preparing', callback); + } + + public onPreparingError(callback: (error: any) => void) { + return this.listenEvent('error:preparing', callback); + } + + public onReady(callback: () => void) { + return this.listenEvent('ready', callback); + } + + public onCapabilitiesNotified(callback: () => void) { + return this.listenEvent('capabilitiesNotified', callback); } private start() { // Room widgets get locked to the room they were added in this.call.setViewedRoomId(this.roomId); this.disposables.push( - this.listenEvent(ElementWidgetActions.JoinCall, this.onCallJoined.bind(this)) + this.listenAction(ElementWidgetActions.JoinCall, this.onCallJoined.bind(this)) ); // Populate the map of "read up to" events for this widget with the current event in every room. @@ -375,4 +384,15 @@ export class CallEmbed { } } } + + public listenAction(type: string, callback: (event: CustomEvent) => void) { + return this.listenEvent(`action:${type}`, callback); + } + + public listenEvent(type: string, callback: (event: T) => void) { + this.call.on(type, callback); + return () => { + this.call.off(type, callback); + }; + } } From 296249de3245dd597b5711ff4a81eb568ebf9a83 Mon Sep 17 00:00:00 2001 From: Krishan <33421343+kfiven@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:26:25 +1100 Subject: [PATCH 061/206] chore: enable semantic check on PR title (#2447) * Enable semantic check on PR title * Update semantic pull request action version * Update PR trigger types in workflow configuration Removed 'reopened' and 'synchronize' types from pull request triggers. --- .github/workflows/pr-title.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .github/workflows/pr-title.yml diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 00000000..a52ee8e6 --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,15 @@ +name: Check PR title + +on: + pull_request_target: + types: + - opened + - edited + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 37e0c2aaac26ed77ec0fac2c97d03dfa08362172 Mon Sep 17 00:00:00 2001 From: Krishan <33421343+kfiven@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:26:55 +1100 Subject: [PATCH 062/206] chore(deps): continue action if login fails (#2758) chore(action): continue action if login fails --- .github/workflows/docker-pr.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/docker-pr.yml b/.github/workflows/docker-pr.yml index 822b8f3f..960aeb8f 100644 --- a/.github/workflows/docker-pr.yml +++ b/.github/workflows/docker-pr.yml @@ -30,6 +30,7 @@ jobs: with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} + continue-on-error: true - name: Login to the Github Container registry #Do not update this action from a outside PR if: github.event.pull_request.head.repo.fork == false @@ -38,6 +39,7 @@ jobs: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + continue-on-error: true - name: Extract metadata (tags, labels) for Docker, GHCR id: meta From 0cbfbab5ad4dec16c0c927bef6232662c6725aee Mon Sep 17 00:00:00 2001 From: Krishan <33421343+kfiven@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:57:20 +1100 Subject: [PATCH 063/206] chore: add semantic commits to renovate configuration (#2760) * Add semantic commits to Renovate configuration * Fix formatting issue in renovate.json --- .github/renovate.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/renovate.json b/.github/renovate.json index 62b0cf2a..2c6c653e 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -1,6 +1,10 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": ["config:recommended", ":dependencyDashboardApproval"], + "extends": [ + "config:recommended", + ":dependencyDashboardApproval", + ":semanticCommits" + ], "labels": ["Dependencies"], "packageRules": [ { From 0d1566977afc7d27795fff2ceebb7c46261e7395 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Tue, 10 Mar 2026 22:45:26 +1100 Subject: [PATCH 064/206] fix: call ui imorovements (#2749) * fix member button tooltip in call room header * hide sticker button in room input based on chat window width * render camera on off data instead of duplicate join messages * hide duplicate call member changes instead of rendering as video status * fix prescreen message spacing --- src/app/features/call/CallView.tsx | 4 ++-- src/app/features/call/styles.css.ts | 4 ++++ src/app/features/room/RoomInput.tsx | 2 +- src/app/features/room/RoomTimeline.tsx | 8 +++++++- src/app/features/room/RoomViewHeader.tsx | 6 +++++- 5 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx index 7a8c28a5..7c7bec6c 100644 --- a/src/app/features/call/CallView.tsx +++ b/src/app/features/call/CallView.tsx @@ -97,7 +97,7 @@ function CallPrescreen() { )} -
+ {!inOtherCall && (hasPermission ? ( @@ -105,7 +105,7 @@ function CallPrescreen() { ))} {inOtherCall && } -
+
diff --git a/src/app/features/call/styles.css.ts b/src/app/features/call/styles.css.ts index 249edb08..2b9f28ad 100644 --- a/src/app/features/call/styles.css.ts +++ b/src/app/features/call/styles.css.ts @@ -22,3 +22,7 @@ export const CallMemberCard = style({ export const CallControlContainer = style({ padding: config.space.S400, }); + +export const PrescreenMessage = style({ + padding: config.space.S200, +}); diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index ae46d2d0..f88ccf93 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -221,7 +221,7 @@ export const RoomInput = forwardRef( const isComposing = useComposingCheck(); useElementSizeObserver( - useCallback(() => document.body, []), + useCallback(() => fileDropContainerRef.current, [fileDropContainerRef]), useCallback((width) => setHideStickerBtn(width < 500), []) ); diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 64043054..39d7e50a 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -1475,7 +1475,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli const senderId = mEvent.getSender() ?? ''; const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId); - const callJoined = mEvent.getContent().application; + const content = mEvent.getContent(); + const prevContent = mEvent.getPrevContent(); + + const callJoined = content.application; + if (callJoined && 'application' in prevContent) { + return null; + } const timeJSX = (