Compare commits

10 Commits

Author SHA1 Message Date
94f8466d1c feat: add call management features with useCall and useRoomCallMembers hooks
- Implemented useCall hook for managing call state and actions.
- Created useRoomCallMembers hook to track active call members in a room.
- Added useRtcConfig for fetching RTC configurations and LiveKit JWT.
- Developed Audio settings component for managing audio devices and settings.
- Introduced device selection and screen sharing options in the Audio settings.
- Persisted audio settings in localStorage for user preferences.
2026-01-23 19:35:25 +11:00
Krishan
c88cb4bca9 Release v4.10.2 (#2528) 2025-11-05 17:49:56 +11:00
Ajay Bura
46c02b89de Update folds to fix broken scrollbar color (#2505) 2025-10-15 17:30:03 +11:00
Ajay Bura
e13d97aa98 Fix member are not sorted correctly after last js-sdk update (#2504) 2025-10-15 17:27:11 +11:00
Krishan
958ae8945d Release v4.10.1 (#2495) 2025-09-29 14:34:38 +10:00
renovate[bot]
f55a3764d5 fix(deps): update dependency matrix-js-sdk to v38 [security] (#2493)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-27 10:00:04 +05:30
dependabot[bot]
3bdcf37bf0 Bump softprops/action-gh-release from 2.3.2 to 2.3.3 (#2478)
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.3.2 to 2.3.3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](72f2c25fcb...6cbd405e2c)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: 2.3.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-26 22:24:59 +10:00
dependabot[bot]
9d7808ec46 Bump nginx from 1.29.0-alpine to 1.29.1-alpine (#2450)
Bumps nginx from 1.29.0-alpine to 1.29.1-alpine.

---
updated-dependencies:
- dependency-name: nginx
  dependency-version: 1.29.1-alpine
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-26 22:21:32 +10:00
dependabot[bot]
20d30903fd Bump docker/setup-buildx-action from 3.10.0 to 3.11.1 (#2373)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.10.0 to 3.11.1.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3.10.0...v3.11.1)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 3.11.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-26 22:19:28 +10:00
Ginger
b78f6f23b5 Add support to mark videos as spoilers (#2255)
* Add support for MSC4193: Spoilers on Media

* Clarify variable names and wording

* Restore list atom

* Improve spoilered image UX with autoload off

* Use `aria-pressed` to indicate attachment spoiler state

* Improve spoiler button tooltip wording, keep reveal button from conflicting with load errors

* Make it possible to mark videos as spoilers

* Allow videos to be marked as spoilers when uploaded

* Apply requested changes

* Show a loading spinner on spoiled media when unblurred

---------

Co-authored-by: Ajay Bura <32841439+ajbura@users.noreply.github.com>
2025-09-25 13:41:35 +10:00
33 changed files with 3417 additions and 75 deletions

View File

@@ -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@72f2c25fcb47643c292f7107632f7a47c1df5cd8
uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836
with:
files: |
cinny-${{ steps.vars.outputs.tag }}.tar.gz
@@ -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.10.0
uses: docker/setup-buildx-action@v3.11.1
- name: Login to Docker Hub
uses: docker/login-action@v3.5.0
with:

View File

@@ -11,7 +11,7 @@ RUN npm run build
## App
FROM nginx:1.29.0-alpine
FROM nginx:1.29.1-alpine
COPY --from=builder /src/dist /app
COPY --from=builder /src/docker-nginx.conf /etc/nginx/conf.d/default.conf

View File

@@ -10,6 +10,10 @@
],
"allowCustomHomeservers": true,
"calling": {
"livekitServiceUrl": "https://b.ruv.wtf/matrix-rtc/livekit/jwt"
},
"featuredCommunities": {
"openAsDefault": false,
"spaces": [
@@ -32,7 +36,7 @@
},
"hashRouter": {
"enabled": false,
"enabled": true,
"basename": "/"
}
}

133
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "cinny",
"version": "4.10.0",
"version": "4.10.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cinny",
"version": "4.10.0",
"version": "4.10.2",
"license": "AGPL-3.0-only",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
@@ -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",
@@ -43,7 +43,8 @@
"jotai": "2.6.0",
"linkify-react": "4.1.3",
"linkifyjs": "4.1.3",
"matrix-js-sdk": "37.5.0",
"livekit-client": "2.17.0",
"matrix-js-sdk": "38.2.0",
"millify": "6.1.0",
"pdfjs-dist": "4.2.67",
"prismjs": "1.30.0",
@@ -1649,6 +1650,12 @@
"node": ">=6.9.0"
}
},
"node_modules/@bufbuild/protobuf": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz",
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@emotion/hash": {
"version": "0.9.2",
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
@@ -2235,6 +2242,21 @@
"resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
},
"node_modules/@livekit/mutex": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz",
"integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==",
"license": "Apache-2.0"
},
"node_modules/@livekit/protocol": {
"version": "1.42.2",
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.42.2.tgz",
"integrity": "sha512-0jeCwoMJKcwsZICg5S6RZM4xhJoF78qMvQELjACJQn6/VB+jmiySQKOSELTXvPBVafHfEbMlqxUw2UR1jTXs2g==",
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "^1.10.0"
}
},
"node_modules/@mapbox/node-pre-gyp": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
@@ -2256,20 +2278,14 @@
}
},
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-14.1.0.tgz",
"integrity": "sha512-vcSxHJIr6lP0Fgo8jl0sTHg+OZxZn+skGjiyB62erfgw/R2QqJl0ZVSY8SRcbk9LtHo/ZGld1tnaOyjL2e3cLQ==",
"version": "15.3.0",
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-15.3.0.tgz",
"integrity": "sha512-QyxHvncvkl7nf+tnn92PjQ54gMNV8hMSpiukiDgNrqF6IYwgySTlcSdkPYdw8QjZJ0NR6fnVrNzMec0OohM3wA==",
"license": "Apache-2.0",
"engines": {
"node": ">= 18"
}
},
"node_modules/@matrix-org/olm": {
"version": "3.2.15",
"resolved": "https://registry.npmjs.org/@matrix-org/olm/-/olm-3.2.15.tgz",
"integrity": "sha512-S7lOrndAK9/8qOtaTq/WhttJC/o4GAzdfK0MUPpo8ApzsJEC0QjtwrkC3KBXdFP1cD1MXi/mlKR7aaoVMKgs6Q==",
"license": "Apache-2.0"
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -7163,9 +7179,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",
@@ -8301,6 +8317,15 @@
"resolved": "https://registry.npmjs.org/javascript-stringify/-/javascript-stringify-2.1.0.tgz",
"integrity": "sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg=="
},
"node_modules/jose": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
"integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/jotai": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/jotai/-/jotai-2.6.0.tgz",
@@ -8510,6 +8535,27 @@
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.1.3.tgz",
"integrity": "sha512-auMesunaJ8yfkHvK4gfg1K0SaKX/6Wn9g2Aac/NwX+l5VdmFZzo/hdPGxEOETj+ryRa4/fiOPjeeKURSAJx1sg=="
},
"node_modules/livekit-client": {
"version": "2.17.0",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.17.0.tgz",
"integrity": "sha512-BD1QUS44ancVTBdnAher0aO7DV5holFYH2lYradYT/HgXtn6R8xPyvtDAH3UH40jGcesDo9fEopCFwEdOgrIhg==",
"license": "Apache-2.0",
"dependencies": {
"@livekit/mutex": "1.1.1",
"@livekit/protocol": "1.42.2",
"events": "^3.3.0",
"jose": "^6.1.0",
"loglevel": "^1.9.2",
"sdp-transform": "^2.15.0",
"ts-debounce": "^4.0.0",
"tslib": "2.8.1",
"typed-emitter": "^2.1.0",
"webrtc-adapter": "^9.0.1"
},
"peerDependencies": {
"@types/dom-mediacapture-record": "^1"
}
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -8631,14 +8677,13 @@
"license": "Apache-2.0"
},
"node_modules/matrix-js-sdk": {
"version": "37.5.0",
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-37.5.0.tgz",
"integrity": "sha512-5tyuAi5hnKud1UkVq8Z2/3c22hWGELBZzErJPZkE6Hju2uGUfGtrIx6uj6puv0ZjvsUU3X6Qgm8vdReKO1PGig==",
"version": "38.2.0",
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-38.2.0.tgz",
"integrity": "sha512-R3jzK8rDGi/3OXOax8jFFyxblCG9KTT5yuXAbvnZCGcpTm8lZ4mHQAn5UydVD8qiyUMNMpaaMd6/k7N+5I/yaQ==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-wasm": "^14.0.1",
"@matrix-org/olm": "3.2.15",
"@matrix-org/matrix-sdk-crypto-wasm": "^15.1.0",
"another-json": "^0.2.0",
"bs58": "^6.0.0",
"content-type": "^1.0.4",
@@ -8653,7 +8698,7 @@
"uuid": "11"
},
"engines": {
"node": ">=20.0.0"
"node": ">=22.0.0"
}
},
"node_modules/matrix-js-sdk/node_modules/uuid": {
@@ -9961,6 +10006,16 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/safe-array-concat": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
@@ -10063,6 +10118,12 @@
"compute-scroll-into-view": "^3.0.2"
}
},
"node_modules/sdp": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.1.tgz",
"integrity": "sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==",
"license": "MIT"
},
"node_modules/sdp-transform": {
"version": "2.15.0",
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz",
@@ -10758,6 +10819,12 @@
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
},
"node_modules/ts-debounce": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/ts-debounce/-/ts-debounce-4.0.0.tgz",
"integrity": "sha512-+1iDGY6NmOGidq7i7xZGA4cm8DAa6fqdYcvO5Z6yBevH++Bdo9Qt/mN0TzHUgcCcKv1gmh9+W5dHqz8pMWbCbg==",
"license": "MIT"
},
"node_modules/tsconfig-paths": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
@@ -10906,6 +10973,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/typed-emitter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz",
"integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==",
"license": "MIT",
"optionalDependencies": {
"rxjs": "*"
}
},
"node_modules/typescript": {
"version": "4.9.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.4.tgz",
@@ -11660,6 +11736,19 @@
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
},
"node_modules/webrtc-adapter": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.3.tgz",
"integrity": "sha512-5fALBcroIl31OeXAdd1YUntxiZl1eHlZZWzNg3U4Fn+J9/cGL3eT80YlrsWGvj2ojuz1rZr2OXkgCzIxAZ7vRQ==",
"license": "BSD-3-Clause",
"dependencies": {
"sdp": "^3.2.0"
},
"engines": {
"node": ">=6.0.0",
"npm": ">=3.10.0"
}
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "cinny",
"version": "4.10.0",
"version": "4.10.2",
"description": "Yet another matrix client",
"main": "index.js",
"type": "module",
@@ -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",
@@ -54,7 +54,8 @@
"jotai": "2.6.0",
"linkify-react": "4.1.3",
"linkifyjs": "4.1.3",
"matrix-js-sdk": "37.5.0",
"livekit-client": "2.17.0",
"matrix-js-sdk": "38.2.0",
"millify": "6.1.0",
"pdfjs-dist": "4.2.67",
"prismjs": "1.30.0",

View File

@@ -209,13 +209,11 @@ export function RenderMessageContent({
<MVideo
content={getContent()}
renderAsFile={renderFile}
renderVideoContent={({ body, info, mimeType, url, encInfo }) => (
renderVideoContent={({ body, info, ...props }) => (
<VideoContent
body={body}
info={info}
mimeType={mimeType}
url={url}
encInfo={encInfo}
{...props}
renderThumbnail={
mediaAutoLoad
? () => (

View File

@@ -224,6 +224,8 @@ type RenderVideoContentProps = {
mimeType: string;
url: string;
encInfo?: IEncryptedFile;
markedAsSpoiler?: boolean;
spoilerReason?: string;
};
type MVideoProps = {
content: IVideoContent;
@@ -274,6 +276,8 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
mimeType: safeMimeType,
url: mxcUrl,
encInfo: content.file,
markedAsSpoiler: content[MATRIX_SPOILER_PROPERTY_NAME],
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
})}
</AttachmentBox>
</Attachment>

View File

@@ -214,7 +214,7 @@ export const ImageContent = as<'div', ImageContentProps>(
)}
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
!load &&
!markedAsSpoiler && (
!blurred && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<Spinner variant="Secondary" />
</Box>

View File

@@ -3,6 +3,7 @@ import {
Badge,
Box,
Button,
Chip,
Icon,
Icons,
Spinner,
@@ -47,6 +48,8 @@ type VideoContentProps = {
info: IVideoInfo & IThumbnailContent;
encInfo?: EncryptedAttachmentInfo;
autoPlay?: boolean;
markedAsSpoiler?: boolean;
spoilerReason?: string;
renderThumbnail?: () => ReactNode;
renderVideo: (props: RenderVideoProps) => ReactNode;
};
@@ -60,6 +63,8 @@ export const VideoContent = as<'div', VideoContentProps>(
info,
encInfo,
autoPlay,
markedAsSpoiler,
spoilerReason,
renderThumbnail,
renderVideo,
...props
@@ -72,6 +77,7 @@ export const VideoContent = as<'div', VideoContentProps>(
const [load, setLoad] = useState(false);
const [error, setError] = useState(false);
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => {
@@ -114,11 +120,15 @@ export const VideoContent = as<'div', VideoContentProps>(
/>
)}
{renderThumbnail && !load && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<Box
className={classNames(css.AbsoluteContainer, blurred && css.Blur)}
alignItems="Center"
justifyContent="Center"
>
{renderThumbnail()}
</Box>
)}
{!autoPlay && srcState.status === AsyncStatus.Idle && (
{!autoPlay && !blurred && srcState.status === AsyncStatus.Idle && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<Button
variant="Secondary"
@@ -133,7 +143,7 @@ export const VideoContent = as<'div', VideoContentProps>(
</Box>
)}
{srcState.status === AsyncStatus.Success && (
<Box className={css.AbsoluteContainer}>
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
{renderVideo({
title: body,
src: srcState.data,
@@ -144,8 +154,39 @@ export const VideoContent = as<'div', VideoContentProps>(
})}
</Box>
)}
{blurred && !error && srcState.status !== AsyncStatus.Error && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<TooltipProvider
tooltip={
typeof spoilerReason === 'string' && (
<Tooltip variant="Secondary">
<Text>{spoilerReason}</Text>
</Tooltip>
)
}
position="Top"
align="Center"
>
{(triggerRef) => (
<Chip
ref={triggerRef}
variant="Secondary"
radii="Pill"
size="500"
outlined
onClick={() => {
setBlurred(false);
}}
>
<Text size="B300">Spoiler</Text>
</Chip>
)}
</TooltipProvider>
</Box>
)}
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
!load && (
!load &&
!blurred && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<Spinner variant="Secondary" />
</Box>

View File

@@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import React, { ReactNode, useEffect } from 'react';
import { Box, Chip, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds';
import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard';
import { UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
@@ -13,8 +13,54 @@ import {
import { useObjectURL } from '../../hooks/useObjectURL';
import { useMediaConfig } from '../../hooks/useMediaConfig';
type ImagePreviewProps = { fileItem: TUploadItem; onSpoiler: (marked: boolean) => void };
function ImagePreview({ fileItem, onSpoiler }: ImagePreviewProps) {
type PreviewImageProps = {
fileItem: TUploadItem;
};
function PreviewImage({ fileItem }: PreviewImageProps) {
const { originalFile, metadata } = fileItem;
const fileUrl = useObjectURL(originalFile);
return (
<img
style={{
objectFit: 'contain',
width: '100%',
height: toRem(152),
filter: metadata.markedAsSpoiler ? 'blur(44px)' : undefined,
}}
alt={originalFile.name}
src={fileUrl}
/>
);
}
type PreviewVideoProps = {
fileItem: TUploadItem;
};
function PreviewVideo({ fileItem }: PreviewVideoProps) {
const { originalFile, metadata } = fileItem;
const fileUrl = useObjectURL(originalFile);
return (
// eslint-disable-next-line jsx-a11y/media-has-caption
<video
style={{
objectFit: 'contain',
width: '100%',
height: toRem(152),
filter: metadata.markedAsSpoiler ? 'blur(44px)' : undefined,
}}
src={fileUrl}
/>
);
}
type MediaPreviewProps = {
fileItem: TUploadItem;
onSpoiler: (marked: boolean) => void;
children: ReactNode;
};
function MediaPreview({ fileItem, onSpoiler, children }: MediaPreviewProps) {
const { originalFile, metadata } = fileItem;
const fileUrl = useObjectURL(originalFile);
@@ -27,16 +73,7 @@ function ImagePreview({ fileItem, onSpoiler }: ImagePreviewProps) {
position: 'relative',
}}
>
<img
style={{
objectFit: 'contain',
width: '100%',
height: toRem(152),
filter: fileItem.metadata.markedAsSpoiler ? 'blur(44px)' : undefined,
}}
src={fileUrl}
alt={originalFile.name}
/>
{children}
<Box
justifyContent="End"
style={{
@@ -136,7 +173,14 @@ export function UploadCardRenderer({
bottom={
<>
{fileItem.originalFile.type.startsWith('image') && (
<ImagePreview fileItem={fileItem} onSpoiler={handleSpoiler} />
<MediaPreview fileItem={fileItem} onSpoiler={handleSpoiler}>
<PreviewImage fileItem={fileItem} />
</MediaPreview>
)}
{fileItem.originalFile.type.startsWith('video') && (
<MediaPreview fileItem={fileItem} onSpoiler={handleSpoiler}>
<PreviewVideo fileItem={fileItem} />
</MediaPreview>
)}
{upload.status === UploadStatus.Idle && !fileSizeExceeded && (
<UploadCardProgress sentBytes={0} totalBytes={file.size} />

View File

@@ -0,0 +1,142 @@
import { style } from '@vanilla-extract/css';
import { config, DefaultReset, color } from 'folds';
export const CallOverlayContainer = style([
DefaultReset,
{
position: 'fixed',
bottom: config.space.S400,
right: config.space.S400,
zIndex: 9999,
backgroundColor: color.Surface.Container,
borderRadius: config.radii.R400,
padding: config.space.S300,
boxShadow: `0 4px 12px ${color.Other.Shadow}`,
minWidth: '280px',
maxWidth: '400px',
transition: 'box-shadow 0.2s ease',
userSelect: 'none',
},
]);
export const CallOverlayDragging = style({
boxShadow: `0 8px 24px ${color.Other.Shadow}`,
opacity: 0.95,
});
export const CallOverlayFullscreen = style({
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
width: '100vw',
height: '100vh',
maxWidth: 'none',
borderRadius: 0,
display: 'flex',
flexDirection: 'column',
transform: 'none !important',
});
export const CallOverlayHeader = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: config.space.S200,
});
export const CallOverlayControls = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: config.space.S200,
});
export const CallDuration = style({
fontVariantNumeric: 'tabular-nums',
});
export const VideoContainer = style({
position: 'relative',
width: '100%',
aspectRatio: '16 / 9',
backgroundColor: color.Surface.ContainerActive,
borderRadius: config.radii.R300,
marginBottom: config.space.S200,
overflow: 'hidden',
selectors: {
[`${CallOverlayFullscreen} &`]: {
flex: 1,
aspectRatio: 'unset',
borderRadius: 0,
marginBottom: 0,
},
},
});
export const LocalVideo = style({
position: 'absolute',
bottom: config.space.S200,
right: config.space.S200,
width: '80px',
aspectRatio: '4 / 3',
backgroundColor: color.Surface.ContainerLine,
borderRadius: config.radii.R300,
overflow: 'hidden',
});
export const VideoElement = style({
width: '100%',
height: '100%',
objectFit: 'cover',
});
export const ScreenShareContainer = style({
position: 'relative',
width: '100%',
aspectRatio: '16 / 9',
backgroundColor: color.Surface.ContainerActive,
borderRadius: config.radii.R300,
marginBottom: config.space.S200,
overflow: 'hidden',
border: `2px solid ${color.Primary.Main}`,
selectors: {
[`${CallOverlayFullscreen} &`]: {
flex: 1,
aspectRatio: 'unset',
borderRadius: 0,
marginBottom: 0,
},
},
});
export const ScreenShareLabel = style({
position: 'absolute',
top: config.space.S100,
left: config.space.S100,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
color: 'white',
padding: `${config.space.S100} ${config.space.S200}`,
borderRadius: config.radii.R300,
fontSize: '0.75rem',
display: 'flex',
alignItems: 'center',
gap: config.space.S100,
zIndex: 1,
});
export const ScreenShareVideoContainer = style({
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#000',
});
export const ScreenShareVideo = style({
width: '100%',
height: '100%',
objectFit: 'contain',
});

View File

@@ -0,0 +1,514 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Box, Icon, IconButton, Icons, Text, Tooltip, TooltipProvider } from 'folds';
import { useCall } from './useCall';
import { CallState, CallType, ScreenShareTrackInfo } from './types';
import * as css from './CallOverlay.css';
/** Drag position state */
interface DragPosition {
x: number;
y: number;
}
/** Custom hook for draggable functionality */
function useDraggable(initialPosition: DragPosition = { x: 0, y: 0 }) {
const [position, setPosition] = useState<DragPosition>(initialPosition);
const [isDragging, setIsDragging] = useState(false);
const dragRef = useRef<HTMLDivElement>(null);
const dragStartRef = useRef<{ x: number; y: number; startX: number; startY: number } | null>(null);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest('button')) return;
e.preventDefault();
setIsDragging(true);
dragStartRef.current = {
x: position.x,
y: position.y,
startX: e.clientX,
startY: e.clientY,
};
}, [position]);
useEffect(() => {
if (!isDragging) return undefined;
const handleMouseMove = (e: MouseEvent) => {
if (!dragStartRef.current) return;
const deltaX = e.clientX - dragStartRef.current.startX;
const deltaY = e.clientY - dragStartRef.current.startY;
setPosition({
x: dragStartRef.current.x + deltaX,
y: dragStartRef.current.y + deltaY,
});
};
const handleMouseUp = () => {
setIsDragging(false);
dragStartRef.current = null;
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging]);
return { position, isDragging, handleMouseDown, dragRef };
}
/**
* Custom Screen share icon SVG (Phosphor-style)
*/
function ScreenShareIcon() {
return (
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 16V8a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h5v2H8v2h8v-2h-2v-2h5a2 2 0 0 0 2-2zm-2 0H5V8h14v8z" />
<path d="M12 9l-4 4h3v3h2v-3h3l-4-4z" />
</svg>
);
}
/**
* Custom Screen share off icon SVG (Phosphor-style)
*/
function ScreenShareOffIcon() {
return (
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 16V8a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h5v2H8v2h8v-2h-2v-2h5a2 2 0 0 0 2-2zm-2 0H5V8h14v8z" />
<path d="M2 4l20 16" stroke="currentColor" strokeWidth="2" />
</svg>
);
}
/** Fullscreen icon */
function FullscreenIcon() {
return (
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
</svg>
);
}
/** Exit fullscreen icon */
function ExitFullscreenIcon() {
return (
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
<path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z" />
</svg>
);
}
/** Picture-in-Picture icon */
function PipIcon() {
return (
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 7h-8v6h8V7zm2-4H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14z" />
</svg>
);
}
/**
* Formats call duration in MM:SS format
*/
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
/**
* Component that displays active call controls and video streams
*/
export function CallOverlay() {
const { activeCall, endCall, toggleMute, toggleVideo, toggleScreenShare } = useCall();
const [duration, setDuration] = useState(0);
const [isTogglingScreenShare, setIsTogglingScreenShare] = useState(false);
const [screenShareTrack, setScreenShareTrack] = useState<ScreenShareTrackInfo | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [isPip, setIsPip] = useState(false);
const localVideoRef = useRef<HTMLVideoElement>(null);
const remoteVideoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const { position, isDragging, handleMouseDown } = useDraggable();
// Update screen share track state when activeCall changes
useEffect(() => {
if (activeCall?.remoteScreenTracks && activeCall.remoteScreenTracks.size > 0) {
const [firstTrackInfo] = activeCall.remoteScreenTracks.values();
setScreenShareTrack(firstTrackInfo || null);
} else {
setScreenShareTrack(null);
}
}, [activeCall]);
useEffect(() => {
if (!activeCall || activeCall.state !== CallState.Connected) {
setDuration(0);
return undefined;
}
const { startTime } = activeCall;
const updateDuration = () => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
setDuration(elapsed);
};
updateDuration();
const interval = setInterval(updateDuration, 1000);
return () => clearInterval(interval);
}, [activeCall]);
useEffect(() => {
if (localVideoRef.current && activeCall?.localStream) {
localVideoRef.current.srcObject = activeCall.localStream;
}
}, [activeCall?.localStream]);
useEffect(() => {
if (remoteVideoRef.current && activeCall?.remoteStreams.size) {
const [firstStream] = activeCall.remoteStreams.values();
if (firstStream) {
remoteVideoRef.current.srcObject = firstStream;
}
}
}, [activeCall?.remoteStreams]);
// Attach screen share track to container using LiveKit's recommended approach
useEffect(() => {
const containerEl = document.getElementById('screen-share-container');
if (screenShareTrack?.track && containerEl) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const track = screenShareTrack.track as any;
if (typeof track.attach === 'function') {
// Let LiveKit create and manage the video element
const videoEl = track.attach() as HTMLVideoElement;
videoEl.className = css.ScreenShareVideo;
// Clear any existing content
containerEl.innerHTML = '';
containerEl.appendChild(videoEl);
// eslint-disable-next-line no-console
console.log('📺 Attached screen share - LiveKit created video element');
// Cleanup: detach when effect re-runs or unmounts
return () => {
if (typeof track.detach === 'function') {
track.detach(videoEl);
}
if (videoEl.parentNode) {
videoEl.parentNode.removeChild(videoEl);
}
};
}
}
// No track, clear container
if (containerEl && !screenShareTrack?.track) {
containerEl.innerHTML = '';
}
return undefined;
}, [screenShareTrack]);
// Fullscreen toggle handler
const handleToggleFullscreen = useCallback(async () => {
if (!containerRef.current) return;
try {
if (!isFullscreen) {
await containerRef.current.requestFullscreen();
setIsFullscreen(true);
} else if (document.fullscreenElement) {
await document.exitFullscreen();
setIsFullscreen(false);
}
} catch (err) {
// eslint-disable-next-line no-console
console.error('Fullscreen error:', err);
}
}, [isFullscreen]);
// PiP toggle handler
const handleTogglePip = useCallback(async () => {
const videoEl = remoteVideoRef.current;
if (!videoEl) return;
try {
if (!isPip && document.pictureInPictureEnabled) {
await videoEl.requestPictureInPicture();
setIsPip(true);
} else if (document.pictureInPictureElement) {
await document.exitPictureInPicture();
setIsPip(false);
}
} catch (err) {
// eslint-disable-next-line no-console
console.error('PiP error:', err);
}
}, [isPip]);
// Listen for fullscreen change events
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement);
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
}, []);
// Listen for PiP change events
useEffect(() => {
const videoEl = remoteVideoRef.current;
if (!videoEl) return undefined;
const handlePipEnter = () => setIsPip(true);
const handlePipLeave = () => setIsPip(false);
videoEl.addEventListener('enterpictureinpicture', handlePipEnter);
videoEl.addEventListener('leavepictureinpicture', handlePipLeave);
return () => {
videoEl.removeEventListener('enterpictureinpicture', handlePipEnter);
videoEl.removeEventListener('leavepictureinpicture', handlePipLeave);
};
}, [activeCall]);
if (!activeCall || activeCall.state === CallState.Idle || activeCall.state === CallState.Ended) {
return null;
}
const isConnecting = activeCall.state === CallState.Connecting;
const isVideoCall = activeCall.callType === CallType.Video;
// Check if anyone is sharing their screen
const hasRemoteScreenShare = screenShareTrack !== null;
const handleEndCall = () => {
endCall();
};
const handleToggleMute = () => {
toggleMute();
};
const handleToggleVideo = () => {
toggleVideo();
};
const handleToggleScreenShare = async () => {
if (isTogglingScreenShare) return;
setIsTogglingScreenShare(true);
try {
await toggleScreenShare();
} finally {
setIsTogglingScreenShare(false);
}
};
return (
<div
ref={containerRef}
className={`${css.CallOverlayContainer} ${isFullscreen ? css.CallOverlayFullscreen : ''} ${isDragging ? css.CallOverlayDragging : ''}`}
style={!isFullscreen ? {
transform: `translate(${position.x}px, ${position.y}px)`,
} : undefined}
>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className={css.CallOverlayHeader}
onMouseDown={handleMouseDown}
style={{ cursor: isDragging ? 'grabbing' : 'grab' }}
>
<Box alignItems="Center" gap="200">
<Icon
size="200"
src={isVideoCall ? Icons.VideoCamera : Icons.Phone}
/>
<Text size="T300">
{isConnecting ? 'Connecting...' : 'In Call'}
</Text>
</Box>
<Box alignItems="Center" gap="100">
<Text size="T200" className={css.CallDuration}>
{formatDuration(duration)}
</Text>
{isVideoCall && (
<>
<TooltipProvider
position="Top"
offset={4}
tooltip={<Tooltip><Text>{isPip ? 'Exit PiP' : 'Picture-in-Picture'}</Text></Tooltip>}
>
{(triggerRef) => (
<IconButton ref={triggerRef} onClick={handleTogglePip} variant="Secondary" size="300">
<Box as="span" style={{ width: '1rem', height: '1rem', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<PipIcon />
</Box>
</IconButton>
)}
</TooltipProvider>
<TooltipProvider
position="Top"
offset={4}
tooltip={<Tooltip><Text>{isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}</Text></Tooltip>}
>
{(triggerRef) => (
<IconButton ref={triggerRef} onClick={handleToggleFullscreen} variant="Secondary" size="300">
<Box as="span" style={{ width: '1rem', height: '1rem', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{isFullscreen ? <ExitFullscreenIcon /> : <FullscreenIcon />}
</Box>
</IconButton>
)}
</TooltipProvider>
</>
)}
</Box>
</div>
{/* Remote Screen Share Display */}
{hasRemoteScreenShare && screenShareTrack && (
<div className={css.ScreenShareContainer}>
<div className={css.ScreenShareLabel}>
<ScreenShareIcon />
<span>{screenShareTrack.participantId}</span>
</div>
{/* LiveKit will create and manage the video element inside this container */}
<div id="screen-share-container" className={css.ScreenShareVideoContainer} />
</div>
)}
{isVideoCall && (
<div className={css.VideoContainer}>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
ref={remoteVideoRef}
autoPlay
playsInline
controls
className={css.VideoElement}
/>
<div className={css.LocalVideo}>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
ref={localVideoRef}
autoPlay
playsInline
muted
controls
className={css.VideoElement}
/>
</div>
</div>
)}
<div className={css.CallOverlayControls}>
<TooltipProvider
position="Top"
offset={4}
tooltip={
<Tooltip>
<Text>{activeCall.isMuted ? 'Unmute' : 'Mute'}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
onClick={handleToggleMute}
variant={activeCall.isMuted ? 'Critical' : 'Secondary'}
>
<Icon
size="300"
src={activeCall.isMuted ? Icons.MicMute : Icons.Mic}
/>
</IconButton>
)}
</TooltipProvider>
{isVideoCall && (
<TooltipProvider
position="Top"
offset={4}
tooltip={
<Tooltip>
<Text>{activeCall.isVideoEnabled ? 'Turn Off Camera' : 'Turn On Camera'}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
onClick={handleToggleVideo}
variant={activeCall.isVideoEnabled ? 'Secondary' : 'Critical'}
>
<Icon
size="300"
src={activeCall.isVideoEnabled ? Icons.VideoCamera : Icons.VideoCameraMute}
/>
</IconButton>
)}
</TooltipProvider>
)}
<TooltipProvider
position="Top"
offset={4}
tooltip={
<Tooltip>
<Text>{activeCall.isScreenSharing ? 'Stop Screen Share' : 'Share Screen'}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
onClick={handleToggleScreenShare}
variant={activeCall.isScreenSharing ? 'Primary' : 'Secondary'}
disabled={isTogglingScreenShare}
>
<Box
as="span"
style={{
width: '1.5rem',
height: '1.5rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
{activeCall.isScreenSharing ? <ScreenShareOffIcon /> : <ScreenShareIcon />}
</Box>
</IconButton>
)}
</TooltipProvider>
<TooltipProvider
position="Top"
offset={4}
tooltip={
<Tooltip>
<Text>End Call</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
onClick={handleEndCall}
variant="Critical"
>
<Icon size="300" src={Icons.Phone} />
</IconButton>
)}
</TooltipProvider>
</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import React, { ReactNode } from 'react';
import { CallProvider, useCallService } from './useCall';
import { CallOverlay } from './CallOverlay';
import { useClientConfig } from '../../hooks/useClientConfig';
interface CallProviderWrapperProps {
children: ReactNode;
}
/**
* Wrapper component that initializes the call service and provides it to children
* Also renders the CallOverlay for active call controls
*/
export function CallProviderWrapper({ children }: CallProviderWrapperProps) {
const clientConfig = useClientConfig();
const livekitServiceUrl = clientConfig.calling?.livekitServiceUrl;
// eslint-disable-next-line no-console
console.log('CallProviderWrapper - config calling:', clientConfig.calling, 'livekitUrl:', livekitServiceUrl);
const callContextValue = useCallService({ livekitServiceUrl });
return (
<CallProvider value={callContextValue}>
{children}
<CallOverlay />
</CallProvider>
);
}

View File

@@ -0,0 +1,737 @@
/* eslint-disable no-console */
/* eslint-disable import/no-extraneous-dependencies */
import {
Room,
RoomEvent,
RemoteParticipant,
RemoteTrackPublication,
ConnectionState,
ParticipantEvent,
Participant,
LocalTrackPublication,
Track,
} from 'livekit-client';
import { MatrixClient } from 'matrix-js-sdk';
import {
ActiveCall,
CallEvent,
CallEventType,
CallServiceConfig,
CallState,
CallType,
LiveKitJWTResponse,
CallMemberEventContent,
} from './types';
import { fetchLiveKitJWT, fetchWellKnownWithRTC, getLiveKitFocus, requestOpenIdToken } from './useRtcConfig';
import { getAudioSettings, SCREEN_SHARE_RESOLUTIONS, SCREEN_SHARE_BITRATES } from '../settings/audio/Audio';
type CallEventListener = (event: CallEvent) => void;
/** The Matrix state event type for call membership (MSC3401) */
const CALL_MEMBER_EVENT_TYPE = 'org.matrix.msc3401.call.member';
/**
* Service for managing Matrix RTC calls via LiveKit
* Handles LiveKit connections, media streams, call state, and Matrix room signaling
*/
export class CallService {
private config: CallServiceConfig;
private matrixClient: MatrixClient;
private activeCall: ActiveCall | null = null;
private listeners: Set<CallEventListener> = new Set();
private livekitRoom: Room | null = null;
private livekitJwt: LiveKitJWTResponse | null = null;
/**
* Creates a new CallService instance
* @param config - Service configuration including homeserver and LiveKit URLs
* @param matrixClient - The Matrix client for sending state events
*/
constructor(config: CallServiceConfig, matrixClient: MatrixClient) {
this.config = config;
this.matrixClient = matrixClient;
}
/**
* Subscribes to call events
* @param listener - Callback function for call events
* @returns Unsubscribe function
*/
on(listener: CallEventListener): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
/**
* Emits a call event to all listeners
*/
private emit(event: CallEvent): void {
this.listeners.forEach((listener) => listener(event));
}
/**
* Updates call state and emits event
*/
private setState(state: CallState): void {
if (this.activeCall) {
this.activeCall.state = state;
this.emit({
type: CallEventType.StateChanged,
roomId: this.activeCall.roomId,
data: { state },
});
}
}
/**
* Gets the current active call
*/
getActiveCall(): ActiveCall | null {
return this.activeCall;
}
/**
* Checks if calls are supported on this homeserver
*/
async isCallSupported(): Promise<boolean> {
try {
const wellKnown = await fetchWellKnownWithRTC(this.config.homeserverBaseUrl);
return getLiveKitFocus(wellKnown) !== undefined;
} catch {
return false;
}
}
/**
* Generates a unique call ID for this session
*/
private generateCallId(): string {
return `${this.config.deviceId}_${Date.now()}`;
}
/**
* Sends a call member state event to announce participation in a call
* @param roomId - The Matrix room ID
* @param callId - The unique call ID
* @param active - Whether the user is actively in the call
*/
private async sendCallMemberEvent(
roomId: string,
callId: string,
active: boolean
): Promise<void> {
const stateKey = this.config.userId;
if (active) {
const content: CallMemberEventContent = {
'm.calls': [
{
'm.call_id': callId,
'm.devices': [
{
device_id: this.config.deviceId,
session_id: callId,
expires_ts: Date.now() + 1000 * 60 * 60, // 1 hour expiry
feeds: [
{ purpose: 'usermedia' },
],
},
],
},
],
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (this.matrixClient as any).sendStateEvent(
roomId,
CALL_MEMBER_EVENT_TYPE,
content,
stateKey
);
} else {
// Clear the call membership by sending empty calls array
const content: CallMemberEventContent = {
'm.calls': [],
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (this.matrixClient as any).sendStateEvent(
roomId,
CALL_MEMBER_EVENT_TYPE,
content,
stateKey
);
}
}
/**
* Starts a call in the specified room
* @param roomId - The Matrix room ID to start the call in
* @param callType - Voice or video call
*/
async startCall(roomId: string, callType: CallType): Promise<void> {
if (this.activeCall) {
throw new Error('A call is already active');
}
const callId = this.generateCallId();
this.activeCall = {
roomId,
callType,
state: CallState.Connecting,
startTime: Date.now(),
remoteStreams: new Map(),
remoteScreenStreams: new Map(),
remoteScreenTracks: new Map(),
participants: [],
isMuted: false,
isVideoEnabled: callType === CallType.Video,
isScreenSharing: false,
};
this.emit({
type: CallEventType.StateChanged,
roomId,
data: { state: CallState.Connecting },
});
try {
// Step 1: Get an OpenID token from the Matrix homeserver
const openIdToken = await requestOpenIdToken(
this.config.homeserverBaseUrl,
this.config.userId,
this.config.accessToken
);
// Step 2: Exchange OpenID token for LiveKit JWT
console.log('Requesting LiveKit JWT for Matrix room:', roomId);
this.livekitJwt = await fetchLiveKitJWT(
this.config.livekitServiceUrl,
roomId,
this.config.userId,
this.config.deviceId,
openIdToken
);
console.log('Got LiveKit JWT, connecting to:', this.livekitJwt.url);
console.log('LiveKit JWT response:', JSON.stringify(this.livekitJwt));
// Step 3: Create and connect to LiveKit room
this.livekitRoom = new Room({
adaptiveStream: true,
dynacast: true,
});
this.setupLiveKitEventHandlers();
await this.livekitRoom.connect(this.livekitJwt.url, this.livekitJwt.jwt);
console.log('Connected to LiveKit room');
console.log('LiveKit room name:', this.livekitRoom.name);
console.log('Local participant identity:', this.livekitRoom.localParticipant.identity);
console.log('Remote participants:', this.livekitRoom.remoteParticipants.size);
// Get user's audio device preferences
const audioSettings = getAudioSettings();
console.log('Using audio settings:', audioSettings);
// Build audio capture options with processing settings
const audioCaptureOptions = {
deviceId: audioSettings.microphoneId || undefined,
noiseSuppression: audioSettings.noiseSuppression,
echoCancellation: audioSettings.echoCancellation,
autoGainControl: audioSettings.autoGainControl,
};
console.log('Audio capture options:', audioCaptureOptions);
// Step 4: Publish local tracks based on call type with selected devices and processing
if (callType === CallType.Video) {
// For video calls, enable both camera and microphone
await this.livekitRoom.localParticipant.setCameraEnabled(true);
await this.livekitRoom.localParticipant.setMicrophoneEnabled(true, audioCaptureOptions);
} else {
// Voice call - only enable microphone with preferred device and processing
await this.livekitRoom.localParticipant.setMicrophoneEnabled(true, audioCaptureOptions);
}
// Step 5: Send Matrix state event to announce participation
await this.sendCallMemberEvent(roomId, callId, true);
console.log('Announced call participation to Matrix room');
this.setState(CallState.Connected);
} catch (error) {
console.error('Failed to start call:', error);
this.setState(CallState.Error);
this.emit({
type: CallEventType.Error,
roomId,
data: { error },
});
throw error;
}
}
/**
* Sets up LiveKit room event handlers
*/
private setupLiveKitEventHandlers(): void {
if (!this.livekitRoom) return;
this.livekitRoom.on(RoomEvent.ConnectionStateChanged, (state: ConnectionState) => {
console.log('LiveKit connection state:', state);
if (!this.activeCall) return;
switch (state) {
case ConnectionState.Connected:
this.setState(CallState.Connected);
break;
case ConnectionState.Reconnecting:
this.setState(CallState.Reconnecting);
break;
case ConnectionState.Disconnected:
this.setState(CallState.Ended);
break;
default:
break;
}
});
this.livekitRoom.on(
RoomEvent.TrackSubscribed,
(
track: RemoteTrackPublication['track'],
publication: RemoteTrackPublication,
participant: RemoteParticipant
) => {
if (!track || !this.activeCall) return;
const isScreenShare = publication.source === Track.Source.ScreenShare ||
publication.source === Track.Source.ScreenShareAudio;
console.log('Track subscribed:', track.kind, 'source:', publication.source,
'isScreenShare:', isScreenShare, 'from', participant.identity);
console.log('Track mediaStreamTrack:', track.mediaStreamTrack);
console.log('Track mediaStreamTrack readyState:', track.mediaStreamTrack?.readyState);
console.log('Track mediaStreamTrack enabled:', track.mediaStreamTrack?.enabled);
// For audio tracks, attach to an audio element to play
if (track.kind === 'audio') {
const audioElement = track.attach() as HTMLAudioElement;
audioElement.id = `audio-${participant.identity}-${publication.source}`;
// Set the preferred output device if supported and selected
const audioSettings = getAudioSettings();
if (audioSettings.speakerId && 'setSinkId' in audioElement) {
(audioElement as HTMLAudioElement & { setSinkId: (id: string) => Promise<void> })
.setSinkId(audioSettings.speakerId)
.then(() => console.log('Speaker set to:', audioSettings.speakerId))
.catch((e) => console.warn('Could not set speaker device:', e));
}
document.body.appendChild(audioElement);
console.log('Audio element attached for', participant.identity);
}
// Handle screen share video tracks separately
if (isScreenShare && track.kind === 'video') {
const mediaStream = new MediaStream([track.mediaStreamTrack]);
console.log('📺 Screen share received from', participant.identity);
console.log('📺 Screen share track:', track);
console.log('📺 Screen share MediaStream:', mediaStream);
console.log('📺 Screen share MediaStream tracks:', mediaStream?.getTracks());
this.activeCall.remoteScreenStreams.set(participant.identity, mediaStream);
// Store the actual track for proper attachment later
this.activeCall.remoteScreenTracks.set(participant.identity, {
participantId: participant.identity,
track,
stream: mediaStream,
});
this.emit({
type: CallEventType.ScreenShareStarted,
roomId: this.activeCall.roomId,
data: {
participantId: participant.identity,
stream: mediaStream,
track,
},
});
} else if (track.kind === 'video') {
const mediaStream = new MediaStream([track.mediaStreamTrack]);
this.activeCall.remoteStreams.set(participant.identity, mediaStream);
this.emit({
type: CallEventType.RemoteStreamAdded,
roomId: this.activeCall.roomId,
data: {
participantId: participant.identity,
stream: mediaStream,
},
});
}
// Update participants list
if (!this.activeCall.participants.includes(participant.identity)) {
this.activeCall.participants.push(participant.identity);
}
}
);
this.livekitRoom.on(
RoomEvent.TrackUnsubscribed,
(
track: RemoteTrackPublication['track'],
publication: RemoteTrackPublication,
participant: RemoteParticipant
) => {
if (!this.activeCall) return;
const isScreenShare = publication.source === Track.Source.ScreenShare ||
publication.source === Track.Source.ScreenShareAudio;
console.log('Track unsubscribed:', track?.kind, 'source:', publication.source,
'isScreenShare:', isScreenShare, 'from', participant.identity);
// Remove audio element if it exists
if (track?.kind === 'audio') {
const audioElement = document.getElementById(`audio-${participant.identity}-${publication.source}`);
if (audioElement) {
audioElement.remove();
console.log('Audio element removed for', participant.identity);
}
track.detach();
}
// Handle screen share removal
if (isScreenShare && track?.kind === 'video') {
// Detach the track from any elements
track.detach();
this.activeCall.remoteScreenStreams.delete(participant.identity);
this.activeCall.remoteScreenTracks.delete(participant.identity);
console.log('📺 Screen share ended from', participant.identity);
this.emit({
type: CallEventType.ScreenShareStopped,
roomId: this.activeCall.roomId,
data: { participantId: participant.identity },
});
} else if (track?.kind === 'video') {
this.activeCall.remoteStreams.delete(participant.identity);
this.emit({
type: CallEventType.RemoteStreamRemoved,
roomId: this.activeCall.roomId,
data: { participantId: participant.identity },
});
}
}
);
this.livekitRoom.on(RoomEvent.ParticipantConnected, (participant: RemoteParticipant) => {
console.log('Participant connected:', participant.identity, 'in Matrix room:', this.activeCall?.roomId);
console.log('Current LiveKit room name:', this.livekitRoom?.name);
// Listen for this participant's speaking state
participant.on(ParticipantEvent.IsSpeakingChanged, (speaking: boolean) => {
if (speaking) {
console.log(`🔊 ${participant.identity} is speaking`);
} else {
console.log(`🔈 ${participant.identity} stopped speaking`);
}
});
if (this.activeCall && !this.activeCall.participants.includes(participant.identity)) {
this.activeCall.participants.push(participant.identity);
this.emit({
type: CallEventType.ParticipantJoined,
roomId: this.activeCall.roomId,
data: { participantId: participant.identity },
});
}
});
this.livekitRoom.on(RoomEvent.ParticipantDisconnected, (participant: RemoteParticipant) => {
console.log('Participant disconnected:', participant.identity);
if (this.activeCall) {
this.activeCall.participants = this.activeCall.participants.filter(
(p) => p !== participant.identity
);
this.activeCall.remoteStreams.delete(participant.identity);
this.emit({
type: CallEventType.ParticipantLeft,
roomId: this.activeCall.roomId,
data: { participantId: participant.identity },
});
}
});
this.livekitRoom.on(RoomEvent.LocalTrackPublished, (publication) => {
console.log('Local track published:', publication.kind);
if (this.activeCall && publication.track) {
const mediaStream = new MediaStream([publication.track.mediaStreamTrack]);
this.activeCall.localStream = mediaStream;
}
});
// Voice activity detection - logs when speakers change
this.livekitRoom.on(RoomEvent.ActiveSpeakersChanged, (speakers: Participant[]) => {
if (speakers.length > 0) {
const speakerNames = speakers.map((s) => s.identity).join(', ');
console.log('🎤 Active speakers:', speakerNames);
}
});
// Track local participant speaking state
this.livekitRoom.localParticipant.on(ParticipantEvent.IsSpeakingChanged, (speaking: boolean) => {
if (speaking) {
console.log('🎙️ You are speaking');
} else {
console.log('🔇 You stopped speaking');
}
});
// Track audio level changes for local participant (more detailed)
this.livekitRoom.localParticipant.on(ParticipantEvent.AudioStreamAcquired, () => {
console.log('🎧 Audio stream acquired');
});
}
/**
* Ends the current call
*/
async endCall(): Promise<void> {
if (!this.activeCall) {
return;
}
const { roomId } = this.activeCall;
this.setState(CallState.Disconnecting);
try {
// Clear Matrix call membership
await this.sendCallMemberEvent(roomId, '', false);
} catch (error) {
console.error('Failed to clear call membership:', error);
}
// Clean up all audio elements
document.querySelectorAll('[id^="audio-"]').forEach((el) => el.remove());
// Disconnect from LiveKit
if (this.livekitRoom) {
this.livekitRoom.disconnect();
this.livekitRoom = null;
}
this.activeCall = null;
this.livekitJwt = null;
this.emit({
type: CallEventType.StateChanged,
roomId,
data: { state: CallState.Ended },
});
}
/**
* Toggles audio mute state
*/
toggleMute(): boolean {
if (!this.livekitRoom?.localParticipant) {
return false;
}
const newMuteState = !this.activeCall?.isMuted;
this.livekitRoom.localParticipant.setMicrophoneEnabled(!newMuteState);
if (this.activeCall) {
this.activeCall.isMuted = newMuteState;
}
return newMuteState;
}
/**
* Toggles video enabled state
*/
toggleVideo(): boolean {
if (!this.livekitRoom?.localParticipant) {
return false;
}
const newVideoState = !this.activeCall?.isVideoEnabled;
this.livekitRoom.localParticipant.setCameraEnabled(newVideoState);
if (this.activeCall) {
this.activeCall.isVideoEnabled = newVideoState;
}
return newVideoState;
}
/**
* Gets the LiveKit JWT for the current call
*/
getLiveKitCredentials(): LiveKitJWTResponse | null {
return this.livekitJwt;
}
/**
* Gets the LiveKit room instance
*/
getLiveKitRoom(): Room | null {
return this.livekitRoom;
}
/**
* Starts screen sharing with configurable resolution and bitrate
* @returns true if screen share started successfully
*/
async startScreenShare(): Promise<boolean> {
if (!this.livekitRoom?.localParticipant || !this.activeCall) {
console.warn('Cannot start screen share: no active call or room');
return false;
}
if (this.activeCall.isScreenSharing) {
console.warn('Screen sharing is already active');
return true;
}
try {
// Preserve current mute state before starting screen share
const wasMuted = this.activeCall.isMuted;
const audioSettings = getAudioSettings();
const resolution = SCREEN_SHARE_RESOLUTIONS[audioSettings.screenShareResolution];
console.log('Starting screen share with settings:', {
resolution: audioSettings.screenShareResolution,
bitrate: audioSettings.screenShareBitrate,
frameRate: audioSettings.screenShareFrameRate,
});
// Build screen share options - LiveKit uses different format
// Resolution and framerate are set via constraints when creating track
// Note: audio here is for system/tab audio, not microphone
const screenShareOptions = {
audio: true, // Include system audio if available
resolution: audioSettings.screenShareResolution === 'source'
? undefined
: {
width: resolution.width,
height: resolution.height,
frameRate: audioSettings.screenShareFrameRate,
},
};
// Enable screen share with the configured options
await this.livekitRoom.localParticipant.setScreenShareEnabled(true, screenShareOptions);
// Restore mute state - LiveKit might have changed mic state
if (wasMuted) {
this.livekitRoom.localParticipant.setMicrophoneEnabled(false);
}
// Set bitrate on the screen share track publication
const screenSharePub = Array.from(
this.livekitRoom.localParticipant.trackPublications.values()
).find(
(pub): pub is LocalTrackPublication =>
pub.source === Track.Source.ScreenShare
);
if (screenSharePub?.track) {
console.log(`Screen share started with bitrate: ${SCREEN_SHARE_BITRATES[audioSettings.screenShareBitrate].bitrate}kbps`);
// LiveKit handles bitrate via simulcast and adaptive streaming
}
this.activeCall.isScreenSharing = true;
this.emit({
type: CallEventType.StateChanged,
roomId: this.activeCall.roomId,
data: { state: this.activeCall.state, isScreenSharing: true },
});
console.log('✅ Screen sharing started');
return true;
} catch (error) {
console.error('Failed to start screen share:', error);
return false;
}
}
/**
* Stops screen sharing
* @returns true if screen share stopped successfully
*/
async stopScreenShare(): Promise<boolean> {
if (!this.livekitRoom?.localParticipant || !this.activeCall) {
return false;
}
if (!this.activeCall.isScreenSharing) {
return true;
}
try {
await this.livekitRoom.localParticipant.setScreenShareEnabled(false);
this.activeCall.isScreenSharing = false;
this.emit({
type: CallEventType.StateChanged,
roomId: this.activeCall.roomId,
data: { state: this.activeCall.state, isScreenSharing: false },
});
console.log('🛑 Screen sharing stopped');
return true;
} catch (error) {
console.error('Failed to stop screen share:', error);
return false;
}
}
/**
* Toggles screen sharing on/off
* @returns The new screen sharing state
*/
async toggleScreenShare(): Promise<boolean> {
if (this.activeCall?.isScreenSharing) {
await this.stopScreenShare();
return false;
}
return this.startScreenShare();
}
/**
* Gets the current screen sharing state
*/
isScreenSharing(): boolean {
return this.activeCall?.isScreenSharing ?? false;
}
/**
* Cleans up resources
*/
dispose(): void {
this.endCall();
this.listeners.clear();
}
}

View File

@@ -0,0 +1,7 @@
export * from './types';
export * from './useRtcConfig';
export * from './CallService';
export * from './useCall';
export * from './CallProviderWrapper';
export * from './CallOverlay';
export * from './useRoomCallMembers';

View File

@@ -0,0 +1,185 @@
/**
* Types for Matrix RTC calling functionality
* Uses LiveKit via the org.matrix.msc4143.rtc_foci configuration
*/
/**
* LiveKit focus configuration from well-known
*/
export interface LiveKitFocus {
type: 'livekit';
livekit_service_url: string;
}
/**
* RTC Foci configuration from well-known response
*/
export type RtcFoci = LiveKitFocus;
/**
* Extended well-known response with RTC configuration
*/
export interface WellKnownWithRTC {
'm.homeserver': {
base_url: string;
};
'm.identity_server'?: {
base_url: string;
};
'io.element.e2ee'?: {
default?: boolean;
force_disable?: boolean;
};
'org.matrix.msc4143.rtc_foci'?: RtcFoci[];
}
/**
* OpenID token response from the Matrix homeserver
*/
export interface OpenIdToken {
access_token: string;
token_type: string;
matrix_server_name: string;
expires_in: number;
}
/**
* Member information for LiveKit JWT request
*/
export interface LiveKitMember {
id: string;
claimed_user_id: string;
claimed_device_id: string;
}
/**
* Request body for LiveKit JWT service
*/
export interface LiveKitJWTRequest {
openid_token: OpenIdToken;
room_id: string;
member: LiveKitMember;
}
/**
* LiveKit JWT response from the service
*/
export interface LiveKitJWTResponse {
jwt: string;
url: string;
}
/**
* Possible call states
*/
export enum CallState {
Idle = 'idle',
Connecting = 'connecting',
Connected = 'connected',
Reconnecting = 'reconnecting',
Disconnecting = 'disconnecting',
Ended = 'ended',
Error = 'error',
}
/**
* Call type
*/
export enum CallType {
Voice = 'voice',
Video = 'video',
}
/**
* Screen share track info with the actual track for proper attachment
*/
export interface ScreenShareTrackInfo {
participantId: string;
track: unknown; // RemoteTrack from livekit-client
stream: MediaStream;
}
/**
* Active call information
*/
export interface ActiveCall {
roomId: string;
callType: CallType;
state: CallState;
startTime: number;
localStream?: MediaStream;
localScreenStream?: MediaStream;
remoteStreams: Map<string, MediaStream>;
remoteScreenStreams: Map<string, MediaStream>;
remoteScreenTracks: Map<string, ScreenShareTrackInfo>;
participants: string[];
isMuted: boolean;
isVideoEnabled: boolean;
isScreenSharing: boolean;
}
/**
* Call event types for the call service
*/
export enum CallEventType {
StateChanged = 'state_changed',
ParticipantJoined = 'participant_joined',
ParticipantLeft = 'participant_left',
RemoteStreamAdded = 'remote_stream_added',
RemoteStreamRemoved = 'remote_stream_removed',
ScreenShareStarted = 'screen_share_started',
ScreenShareStopped = 'screen_share_stopped',
Error = 'error',
}
/**
* Call event payload
*/
export interface CallEvent {
type: CallEventType;
roomId: string;
data?: unknown;
}
/**
* Call service configuration
*/
export interface CallServiceConfig {
livekitServiceUrl: string;
homeserverBaseUrl: string;
accessToken: string;
userId: string;
deviceId: string;
}
/**
* Feed information for call member event
*/
export interface CallFeed {
purpose: 'usermedia' | 'screenshare';
}
/**
* Device information for call member event
*/
export interface CallDevice {
device_id: string;
session_id: string;
expires_ts: number;
feeds: CallFeed[];
}
/**
* Call information for call member event
*/
export interface CallInfo {
'm.call_id': string;
'm.devices': CallDevice[];
}
/**
* Content for org.matrix.msc3401.call.member state event
*/
export interface CallMemberEventContent {
'm.calls': CallInfo[];
}

View File

@@ -0,0 +1,238 @@
import { createContext, useContext, useState, useCallback, useEffect, useMemo } from 'react';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { CallService } from './CallService';
import { ActiveCall, CallEvent, CallEventType, CallState, CallType } from './types';
import { fetchWellKnownWithRTC, getLiveKitFocus } from './useRtcConfig';
interface CallContextValue {
callService: CallService | null;
activeCall: ActiveCall | null;
callSupported: boolean;
callSupportLoading: boolean;
startCall: (roomId: string, callType: CallType) => Promise<void>;
endCall: () => Promise<void>;
toggleMute: () => boolean;
toggleVideo: () => boolean;
toggleScreenShare: () => Promise<boolean>;
}
const CallContext = createContext<CallContextValue | null>(null);
/**
* Hook to access the call context
* @returns The call context value
*/
export function useCall(): CallContextValue {
const context = useContext(CallContext);
if (!context) {
throw new Error('useCall must be used within a CallProvider');
}
return context;
}
export const CallProvider = CallContext.Provider;
interface UseCallServiceOptions {
livekitServiceUrl?: string;
}
/**
* Hook to initialize and manage the call service
* Should be used at the app level to provide call context
* @param options - Optional configuration including livekitServiceUrl from client config
*/
export function useCallService(options?: UseCallServiceOptions): CallContextValue {
const mx = useMatrixClient();
const [callService, setCallService] = useState<CallService | null>(null);
const [activeCall, setActiveCall] = useState<ActiveCall | null>(null);
const [callSupported, setCallSupported] = useState(false);
const [callSupportLoading, setCallSupportLoading] = useState(true);
const configuredLivekitUrl = options?.livekitServiceUrl;
useEffect(() => {
let unsubscribe: (() => void) | undefined;
let service: CallService | undefined;
const initCallService = async () => {
const baseUrl = mx.getHomeserverUrl();
const accessToken = mx.getAccessToken();
const userId = mx.getSafeUserId();
const deviceId = mx.getDeviceId();
if (!accessToken || !deviceId) {
setCallSupportLoading(false);
return;
}
try {
let livekitServiceUrl: string | undefined = configuredLivekitUrl;
// If no configured URL, try to get from well-known
if (!livekitServiceUrl) {
const wellKnown = await fetchWellKnownWithRTC(baseUrl);
// eslint-disable-next-line no-console
console.log('Call service init - well-known:', wellKnown);
const livekitFocus = getLiveKitFocus(wellKnown);
// eslint-disable-next-line no-console
console.log('Call service init - LiveKit focus:', livekitFocus);
livekitServiceUrl = livekitFocus?.livekit_service_url;
} else {
// eslint-disable-next-line no-console
console.log('Call service init - Using configured LiveKit URL:', livekitServiceUrl);
}
if (!livekitServiceUrl) {
// eslint-disable-next-line no-console
console.log('Call service init - No LiveKit service found, calls not supported');
setCallSupported(false);
setCallSupportLoading(false);
return;
}
service = new CallService(
{
livekitServiceUrl,
homeserverBaseUrl: baseUrl,
accessToken,
userId,
deviceId,
},
mx
);
setCallService(service);
setCallSupported(true);
setCallSupportLoading(false);
// eslint-disable-next-line no-console
console.log('Call service init - Success! Calls are supported');
unsubscribe = service.on((event: CallEvent) => {
// Update state for any event that might change the call state
if (event.type === CallEventType.StateChanged ||
event.type === CallEventType.ScreenShareStarted ||
event.type === CallEventType.ScreenShareStopped ||
event.type === CallEventType.RemoteStreamAdded ||
event.type === CallEventType.RemoteStreamRemoved) {
const newCall = service?.getActiveCall();
if (newCall) {
// Deep copy Maps to ensure React detects changes
setActiveCall({
...newCall,
remoteStreams: new Map(newCall.remoteStreams),
remoteScreenTracks: new Map(newCall.remoteScreenTracks),
remoteScreenStreams: new Map(newCall.remoteScreenStreams),
});
} else {
setActiveCall(null);
}
}
});
} catch (error) {
// eslint-disable-next-line no-console
console.error('Call service init - Error:', error);
setCallSupported(false);
setCallSupportLoading(false);
}
};
initCallService();
return () => {
unsubscribe?.();
service?.dispose();
};
}, [mx, configuredLivekitUrl]);
const startCall = useCallback(
async (roomId: string, callType: CallType) => {
if (!callService) {
throw new Error('Call service not initialized');
}
await callService.startCall(roomId, callType);
},
[callService]
);
const endCall = useCallback(async () => {
if (!callService) {
return;
}
await callService.endCall();
}, [callService]);
const toggleMute = useCallback(() => {
if (!callService) {
return false;
}
const newState = callService.toggleMute();
const call = callService.getActiveCall();
if (call) {
setActiveCall({ ...call });
}
return newState;
}, [callService]);
const toggleVideo = useCallback(() => {
if (!callService) {
return false;
}
const newState = callService.toggleVideo();
const call = callService.getActiveCall();
if (call) {
setActiveCall({ ...call });
}
return newState;
}, [callService]);
const toggleScreenShare = useCallback(async () => {
if (!callService) {
return false;
}
const newState = await callService.toggleScreenShare();
const call = callService.getActiveCall();
if (call) {
setActiveCall({ ...call });
}
return newState;
}, [callService]);
return useMemo(
() => ({
callService,
activeCall,
callSupported,
callSupportLoading,
startCall,
endCall,
toggleMute,
toggleVideo,
toggleScreenShare,
}),
[callService, activeCall, callSupported, callSupportLoading, startCall, endCall, toggleMute, toggleVideo, toggleScreenShare]
);
}
/**
* Hook to check if a call is active in a specific room
*/
export function useRoomCall(roomId: string) {
const { activeCall, startCall, endCall, callSupported, callSupportLoading } = useCall();
const isInCall = activeCall?.roomId === roomId;
const callState = isInCall ? activeCall.state : CallState.Idle;
const startVoiceCall = useCallback(() => startCall(roomId, CallType.Voice), [roomId, startCall]);
const startVideoCall = useCallback(() => startCall(roomId, CallType.Video), [roomId, startCall]);
return {
isInCall,
callState,
activeCall: isInCall ? activeCall : null,
startVoiceCall,
startVideoCall,
endCall,
callSupported,
callSupportLoading,
};
}

View File

@@ -0,0 +1,114 @@
import { useCallback, useEffect, useState } from 'react';
import { MatrixEvent, Room, RoomStateEvent } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
/** The Matrix state event type for call membership (MSC3401) */
const CALL_MEMBER_EVENT_TYPE = 'org.matrix.msc3401.call.member';
/** Represents an active call member in a room */
export interface CallMember {
userId: string;
deviceId: string;
sessionId: string;
expiresTs: number;
}
/**
* Extracts active call members from a room's state events
* @param room - The Matrix room to check
* @returns Array of active call members
*/
function getActiveCallMembers(room: Room): CallMember[] {
const members: CallMember[] = [];
const now = Date.now();
try {
const stateEvents = room.currentState.getStateEvents(CALL_MEMBER_EVENT_TYPE);
for (const event of stateEvents) {
const content = event.getContent();
const userId = event.getStateKey();
if (!userId || !content['m.calls']) continue;
const calls = content['m.calls'];
if (!Array.isArray(calls)) continue;
for (const call of calls) {
const devices = call['m.devices'];
if (!Array.isArray(devices)) continue;
for (const device of devices) {
const expiresTs = device.expires_ts || 0;
// Check if the call membership is still valid (not expired)
if (expiresTs > now) {
members.push({
userId,
deviceId: device.device_id,
sessionId: device.session_id,
expiresTs,
});
}
}
}
}
} catch (e) {
console.error('Error reading call member events:', e);
}
return members;
}
/**
* Hook to get the list of users currently in a call in a room
* @param roomId - The Matrix room ID to monitor
* @returns Object with call members and whether a call is active
*/
export function useRoomCallMembers(roomId: string) {
const mx = useMatrixClient();
const [callMembers, setCallMembers] = useState<CallMember[]>([]);
const updateCallMembers = useCallback(() => {
const room = mx.getRoom(roomId);
if (!room) {
setCallMembers([]);
return;
}
const members = getActiveCallMembers(room);
setCallMembers(members);
}, [mx, roomId]);
useEffect(() => {
updateCallMembers();
// Listen for state event changes
const handleStateEvent = (event: MatrixEvent) => {
if (event.getRoomId() === roomId && event.getType() === CALL_MEMBER_EVENT_TYPE) {
updateCallMembers();
}
};
mx.on(RoomStateEvent.Events, handleStateEvent);
// Also refresh periodically to handle expired memberships
const interval = setInterval(updateCallMembers, 30000);
return () => {
mx.off(RoomStateEvent.Events, handleStateEvent);
clearInterval(interval);
};
}, [mx, roomId, updateCallMembers]);
const isCallActive = callMembers.length > 0;
const myUserId = mx.getUserId();
const othersInCall = callMembers.filter((m) => m.userId !== myUserId);
return {
callMembers,
isCallActive,
othersInCall,
callMemberCount: callMembers.length,
};
}

View File

@@ -0,0 +1,170 @@
import { useCallback } from 'react';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { LiveKitFocus, LiveKitJWTResponse, OpenIdToken, WellKnownWithRTC } from './types';
/**
* Fetches the LiveKit focus configuration from well-known data
* @param wellKnown - The well-known response that may contain RTC foci
* @returns The LiveKit focus configuration if available
*/
export function getLiveKitFocus(wellKnown: WellKnownWithRTC): LiveKitFocus | undefined {
const rtcFoci = wellKnown['org.matrix.msc4143.rtc_foci'];
if (!rtcFoci || !Array.isArray(rtcFoci)) {
return undefined;
}
return rtcFoci.find((focus): focus is LiveKitFocus => focus.type === 'livekit');
}
/**
* Fetches the well-known client configuration from a homeserver
* @param baseUrl - The homeserver base URL
* @returns The well-known response with RTC configuration
*/
export async function fetchWellKnownWithRTC(baseUrl: string): Promise<WellKnownWithRTC> {
const wellKnownUrl = `${baseUrl}/.well-known/matrix/client`;
const response = await fetch(wellKnownUrl);
if (!response.ok) {
throw new Error(`Failed to fetch well-known: ${response.status}`);
}
return response.json();
}
/**
* Requests an OpenID token from the Matrix homeserver
* This token can be used to authenticate with third-party services
* @param homeserverBaseUrl - The Matrix homeserver base URL
* @param userId - The user's Matrix ID
* @param accessToken - The Matrix access token
* @returns The OpenID token response
*/
export async function requestOpenIdToken(
homeserverBaseUrl: string,
userId: string,
accessToken: string
): Promise<OpenIdToken> {
const url = `${homeserverBaseUrl}/_matrix/client/v3/user/${encodeURIComponent(userId)}/openid/request_token`;
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to request OpenID token: ${response.status} - ${errorText}`);
}
return response.json();
}
/**
* Fetches a LiveKit JWT for joining a call using OpenID token authentication
* @param livekitServiceUrl - The LiveKit service URL from well-known or config
* @param roomId - The Matrix room ID to join
* @param userId - The Matrix user ID
* @param deviceId - The Matrix device ID
* @param openIdToken - The OpenID token from the Matrix homeserver
* @returns The LiveKit JWT and server URL
*/
export async function fetchLiveKitJWT(
livekitServiceUrl: string,
roomId: string,
userId: string,
deviceId: string,
openIdToken: OpenIdToken
): Promise<LiveKitJWTResponse> {
// Generate a unique member ID for this participant (used for identity, not room assignment)
const memberId = `${deviceId}_${Date.now()}`;
// slot_id must be constant so all participants in the same Matrix room
// get assigned to the same LiveKit room. The JWT service hashes room_id + slot_id
// to create the LiveKit room name.
const slotId = 'm.call';
const response = await fetch(livekitServiceUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
openid_token: openIdToken,
room_id: roomId,
slot_id: slotId,
member: {
id: memberId,
claimed_user_id: userId,
claimed_device_id: deviceId,
},
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to fetch LiveKit JWT: ${response.status} - ${errorText}`);
}
return response.json();
}
/**
* Hook to get RTC configuration functions
* Provides utilities for fetching well-known data and LiveKit JWTs
*/
export function useRtcConfig() {
const mx = useMatrixClient();
const getWellKnown = useCallback(async (): Promise<WellKnownWithRTC> => {
const baseUrl = mx.getHomeserverUrl();
return fetchWellKnownWithRTC(baseUrl);
}, [mx]);
const getLiveKitJWT = useCallback(
async (roomId: string): Promise<LiveKitJWTResponse | null> => {
const baseUrl = mx.getHomeserverUrl();
const accessToken = mx.getAccessToken();
const deviceId = mx.getDeviceId();
const userId = mx.getSafeUserId();
if (!accessToken || !deviceId) {
throw new Error('Missing access token or device ID');
}
const wellKnown = await fetchWellKnownWithRTC(baseUrl);
const livekitFocus = getLiveKitFocus(wellKnown);
if (!livekitFocus) {
return null;
}
// Get OpenID token from homeserver
const openIdToken = await requestOpenIdToken(baseUrl, userId, accessToken);
// Exchange for LiveKit JWT
return fetchLiveKitJWT(livekitFocus.livekit_service_url, roomId, userId, deviceId, openIdToken);
},
[mx]
);
const isCallSupported = useCallback(async (): Promise<boolean> => {
try {
const baseUrl = mx.getHomeserverUrl();
const wellKnown = await fetchWellKnownWithRTC(baseUrl);
return getLiveKitFocus(wellKnown) !== undefined;
} catch {
return false;
}
}, [mx]);
return {
getWellKnown,
getLiveKitJWT,
isCallSupported,
};
}

View File

@@ -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<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);

View File

@@ -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);

View File

@@ -69,6 +69,8 @@ import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { useRoomCall, useRoomCallMembers } from '../call';
import { CallState, CallType } from '../call/types';
type RoomMenuProps = {
room: Room;
@@ -254,6 +256,170 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
);
});
type CallIndicatorProps = {
roomId: string;
};
/**
* Shows when others are in a call in this room
*/
function CallIndicator({ roomId }: CallIndicatorProps) {
const { othersInCall, isCallActive } = useRoomCallMembers(roomId);
if (!isCallActive || othersInCall.length === 0) {
return null;
}
const userCount = othersInCall.length;
const tooltipText = userCount === 1
? `${othersInCall[0].userId.split(':')[0].slice(1)} is in a call`
: `${userCount} people are in a call`;
return (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>{tooltipText}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<Box
ref={triggerRef}
alignItems="Center"
gap="100"
style={{
padding: `${toRem(4)} ${toRem(8)}`,
borderRadius: toRem(12),
backgroundColor: 'var(--mx-positive-container)',
cursor: 'default',
}}
>
<Icon
size="100"
src={Icons.Phone}
style={{ color: 'var(--mx-positive)' }}
/>
<Text size="T200" style={{ color: 'var(--mx-positive)' }}>
{userCount} in call
</Text>
</Box>
)}
</TooltipProvider>
);
}
type RoomCallButtonsProps = {
roomId: string;
};
/**
* Component for voice/video call buttons in the room header
*/
function RoomCallButtons({ roomId }: RoomCallButtonsProps) {
const {
isInCall,
callState,
activeCall,
startVoiceCall,
startVideoCall,
endCall,
callSupported,
callSupportLoading,
} = useRoomCall(roomId);
// Debug logging
React.useEffect(() => {
// eslint-disable-next-line no-console
console.log('RoomCallButtons render:', { callSupportLoading, callSupported, roomId });
}, [callSupportLoading, callSupported, roomId]);
if (callSupportLoading || !callSupported) {
return null;
}
const isConnecting = callState === CallState.Connecting;
const isConnected = callState === CallState.Connected;
const isActive = isConnecting || isConnected;
const handleVoiceCall = async () => {
if (isInCall) {
await endCall();
} else {
try {
await startVoiceCall();
} catch (error) {
console.error('Failed to start voice call:', error);
}
}
};
const handleVideoCall = async () => {
if (isInCall) {
await endCall();
} else {
try {
await startVideoCall();
} catch (error) {
console.error('Failed to start video call:', error);
}
}
};
return (
<>
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>{isInCall ? 'End Call' : 'Voice Call'}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
onClick={handleVoiceCall}
variant={isActive && activeCall?.callType === CallType.Voice ? 'Critical' : 'Background'}
>
{isConnecting ? (
<Spinner size="100" variant="Secondary" />
) : (
<Icon size="400" src={Icons.Phone} filled={isActive && activeCall?.callType === CallType.Voice} />
)}
</IconButton>
)}
</TooltipProvider>
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>{isInCall ? 'End Call' : 'Video Call'}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
onClick={handleVideoCall}
variant={isActive && activeCall?.callType === CallType.Video ? 'Critical' : 'Background'}
>
{isConnecting ? (
<Spinner size="100" variant="Secondary" />
) : (
<Icon size="400" src={Icons.VideoCamera} filled={isActive && activeCall?.callType === CallType.Video} />
)}
</IconButton>
)}
</TooltipProvider>
</>
);
}
export function RoomViewHeader() {
const navigate = useNavigate();
const mx = useMatrixClient();
@@ -369,7 +535,9 @@ export function RoomViewHeader() {
)}
</Box>
</Box>
<Box shrink="No">
<Box shrink="No" alignItems="Center" gap="100">
<CallIndicator roomId={room.roomId} />
<RoomCallButtons roomId={room.roomId} />
{!ecryptedRoom && (
<TooltipProvider
position="Bottom"

View File

@@ -82,7 +82,7 @@ export const getVideoMsgContent = async (
item: TUploadItem,
mxc: string
): Promise<IContent> => {
const { file, originalFile, encInfo } = item;
const { file, originalFile, encInfo, metadata } = item;
const [videoError, videoEl] = await to(loadVideoElement(getVideoFileUrl(originalFile)));
if (videoError) console.warn(videoError);
@@ -91,6 +91,7 @@ export const getVideoMsgContent = async (
msgtype: MsgType.Video,
filename: file.name,
body: file.name,
[MATRIX_SPOILER_PROPERTY_NAME]: metadata.markedAsSpoiler,
};
if (videoEl) {
const [thumbError, thumbContent] = await to(

View File

@@ -30,6 +30,7 @@ import { Devices } from './devices';
import { EmojisStickers } from './emojis-stickers';
import { DeveloperTools } from './developer-tools';
import { About } from './about';
import { Audio } from './audio';
import { UseStateProvider } from '../../components/UseStateProvider';
import { stopPropagation } from '../../utils/keyboard';
import { LogoutDialog } from '../../components/LogoutDialog';
@@ -38,6 +39,7 @@ export enum SettingsPages {
GeneralPage,
AccountPage,
NotificationPage,
AudioPage,
DevicesPage,
EmojisStickersPage,
DeveloperToolsPage,
@@ -68,6 +70,11 @@ const useSettingsMenuItems = (): SettingsMenuItem[] =>
name: 'Notifications',
icon: Icons.Bell,
},
{
page: SettingsPages.AudioPage,
name: 'Audio & Video',
icon: Icons.Phone,
},
{
page: SettingsPages.DevicesPage,
name: 'Devices',
@@ -219,6 +226,9 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
{activePage === SettingsPages.NotificationPage && (
<Notifications requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.AudioPage && (
<Audio requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.DevicesPage && (
<Devices requestClose={handlePageRequestClose} />
)}

View File

@@ -46,7 +46,7 @@ export function About({ requestClose }: AboutProps) {
<Box direction="Column" gap="100">
<Box gap="100" alignItems="End">
<Text size="H3">Cinny</Text>
<Text size="T200">v4.10.0</Text>
<Text size="T200">v4.10.2</Text>
</Box>
<Text>Yet another matrix client.</Text>
</Box>

View File

@@ -0,0 +1,831 @@
/* eslint-disable no-console */
import React, { useEffect, useState, MouseEventHandler } from 'react';
import {
Box,
Button,
config,
Header,
Icon,
IconButton,
Icons,
Menu,
MenuItem,
PopOut,
RectCords,
Scroll,
Switch,
Text,
toRem,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card';
import { SettingTile } from '../../../components/setting-tile';
import { SequenceCardStyle } from '../styles.css';
import { stopPropagation } from '../../../utils/keyboard';
/** Represents an audio device (microphone or speaker) */
interface AudioDevice {
deviceId: string;
label: string;
kind: MediaDeviceKind;
}
/** Screen share resolution presets */
export type ScreenShareResolution = '720p' | '1080p' | '1440p' | '4k' | 'source';
/** Screen share resolution configs */
export const SCREEN_SHARE_RESOLUTIONS: Record<ScreenShareResolution, { width: number; height: number; label: string }> = {
'720p': { width: 1280, height: 720, label: '720p (HD)' },
'1080p': { width: 1920, height: 1080, label: '1080p (Full HD)' },
'1440p': { width: 2560, height: 1440, label: '1440p (2K)' },
'4k': { width: 3840, height: 2160, label: '4K (Ultra HD)' },
'source': { width: 0, height: 0, label: 'Source Resolution' },
};
/** Screen share bitrate presets in kbps */
export type ScreenShareBitrate = 'low' | 'medium' | 'high' | 'ultra';
/** Screen share bitrate configs */
export const SCREEN_SHARE_BITRATES: Record<ScreenShareBitrate, { bitrate: number; label: string }> = {
'low': { bitrate: 1000, label: 'Low (1 Mbps)' },
'medium': { bitrate: 2500, label: 'Medium (2.5 Mbps)' },
'high': { bitrate: 5000, label: 'High (5 Mbps)' },
'ultra': { bitrate: 10000, label: 'Ultra (10 Mbps)' },
};
/** Stored audio device settings */
interface AudioSettings {
microphoneId: string | null;
speakerId: string | null;
noiseSuppression: boolean;
echoCancellation: boolean;
autoGainControl: boolean;
screenShareResolution: ScreenShareResolution;
screenShareBitrate: ScreenShareBitrate;
screenShareFrameRate: number;
}
const AUDIO_SETTINGS_KEY = 'cinny_audio_settings';
/** Default audio settings */
const DEFAULT_AUDIO_SETTINGS: AudioSettings = {
microphoneId: null,
speakerId: null,
noiseSuppression: true,
echoCancellation: true,
autoGainControl: true,
screenShareResolution: '1080p',
screenShareBitrate: 'high',
screenShareFrameRate: 30,
};
/**
* Loads audio settings from localStorage
* @returns The stored audio settings or defaults
*/
function loadAudioSettings(): AudioSettings {
try {
const stored = localStorage.getItem(AUDIO_SETTINGS_KEY);
if (stored) {
return { ...DEFAULT_AUDIO_SETTINGS, ...JSON.parse(stored) };
}
} catch (e) {
console.error('Failed to load audio settings:', e);
}
return DEFAULT_AUDIO_SETTINGS;
}
/**
* Saves audio settings to localStorage
* @param settings - The audio settings to save
*/
function saveAudioSettings(settings: AudioSettings): void {
try {
localStorage.setItem(AUDIO_SETTINGS_KEY, JSON.stringify(settings));
} catch (e) {
console.error('Failed to save audio settings:', e);
}
}
/**
* Gets the currently stored audio settings
* @returns The stored audio settings
*/
export function getAudioSettings(): AudioSettings {
return loadAudioSettings();
}
type DeviceSelectorProps = {
devices: AudioDevice[];
selectedId: string | null;
onSelect: (deviceId: string) => void;
};
/**
* Dropdown menu for selecting an audio device
*/
function DeviceSelector({ devices, selectedId, onSelect }: DeviceSelectorProps) {
return (
<Menu>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{devices.length === 0 ? (
<MenuItem size="300" variant="Surface" radii="300" disabled>
<Text size="T300">No devices found</Text>
</MenuItem>
) : (
devices.map((device) => (
<MenuItem
key={device.deviceId}
size="300"
variant={device.deviceId === selectedId ? 'Primary' : 'Surface'}
radii="300"
onClick={() => onSelect(device.deviceId)}
>
<Text size="T300" truncate>
{device.label || `Unknown Device (${device.deviceId.slice(0, 8)}...)`}
</Text>
</MenuItem>
))
)}
</Box>
</Menu>
);
}
type DeviceSelectButtonProps = {
devices: AudioDevice[];
selectedId: string | null;
onSelect: (deviceId: string) => void;
};
/**
* Button that opens a device selection dropdown
*/
function DeviceSelectButton({ devices, selectedId, onSelect }: DeviceSelectButtonProps) {
const [menuCords, setMenuCords] = useState<RectCords>();
const selectedDevice = devices.find((d) => d.deviceId === selectedId);
const displayLabel = selectedDevice?.label || selectedDevice?.deviceId.slice(0, 8) || 'Default';
const handleClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (deviceId: string) => {
onSelect(deviceId);
setMenuCords(undefined);
};
return (
<>
<Button
size="300"
variant="Primary"
outlined
fill="Soft"
radii="300"
after={<Icon size="300" src={Icons.ChevronBottom} />}
onClick={handleClick}
style={{ maxWidth: toRem(250) }}
>
<Text size="T300" truncate>
{displayLabel}
</Text>
</Button>
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<DeviceSelector
devices={devices}
selectedId={selectedId}
onSelect={handleSelect}
/>
</FocusTrap>
}
/>
</>
);
}
type ScreenShareResolutionSelectProps = {
value: ScreenShareResolution;
onChange: (value: ScreenShareResolution) => void;
};
/**
* Dropdown for selecting screen share resolution
*/
function ScreenShareResolutionSelect({ value, onChange }: ScreenShareResolutionSelectProps) {
const [menuCords, setMenuCords] = useState<RectCords>();
const handleClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (resolution: ScreenShareResolution) => {
onChange(resolution);
setMenuCords(undefined);
};
return (
<>
<Button
size="300"
variant="Primary"
outlined
fill="Soft"
radii="300"
after={<Icon size="300" src={Icons.ChevronBottom} />}
onClick={handleClick}
style={{ maxWidth: toRem(200) }}
>
<Text size="T300">{SCREEN_SHARE_RESOLUTIONS[value].label}</Text>
</Button>
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{(Object.keys(SCREEN_SHARE_RESOLUTIONS) as ScreenShareResolution[]).map((res) => (
<MenuItem
key={res}
size="300"
variant={res === value ? 'Primary' : 'Surface'}
radii="300"
onClick={() => handleSelect(res)}
>
<Text size="T300">{SCREEN_SHARE_RESOLUTIONS[res].label}</Text>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
/>
</>
);
}
type ScreenShareBitrateSelectProps = {
value: ScreenShareBitrate;
onChange: (value: ScreenShareBitrate) => void;
};
/**
* Dropdown for selecting screen share bitrate
*/
function ScreenShareBitrateSelect({ value, onChange }: ScreenShareBitrateSelectProps) {
const [menuCords, setMenuCords] = useState<RectCords>();
const handleClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (bitrate: ScreenShareBitrate) => {
onChange(bitrate);
setMenuCords(undefined);
};
return (
<>
<Button
size="300"
variant="Primary"
outlined
fill="Soft"
radii="300"
after={<Icon size="300" src={Icons.ChevronBottom} />}
onClick={handleClick}
style={{ maxWidth: toRem(200) }}
>
<Text size="T300">{SCREEN_SHARE_BITRATES[value].label}</Text>
</Button>
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{(Object.keys(SCREEN_SHARE_BITRATES) as ScreenShareBitrate[]).map((br) => (
<MenuItem
key={br}
size="300"
variant={br === value ? 'Primary' : 'Surface'}
radii="300"
onClick={() => handleSelect(br)}
>
<Text size="T300">{SCREEN_SHARE_BITRATES[br].label}</Text>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
/>
</>
);
}
type ScreenShareFrameRateSelectProps = {
value: number;
onChange: (value: number) => void;
};
const FRAME_RATES = [15, 24, 30, 60];
/**
* Dropdown for selecting screen share frame rate
*/
function ScreenShareFrameRateSelect({ value, onChange }: ScreenShareFrameRateSelectProps) {
const [menuCords, setMenuCords] = useState<RectCords>();
const handleClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (frameRate: number) => {
onChange(frameRate);
setMenuCords(undefined);
};
return (
<>
<Button
size="300"
variant="Primary"
outlined
fill="Soft"
radii="300"
after={<Icon size="300" src={Icons.ChevronBottom} />}
onClick={handleClick}
style={{ maxWidth: toRem(200) }}
>
<Text size="T300">{value} FPS</Text>
</Button>
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{FRAME_RATES.map((fr) => (
<MenuItem
key={fr}
size="300"
variant={fr === value ? 'Primary' : 'Surface'}
radii="300"
onClick={() => handleSelect(fr)}
>
<Text size="T300">{fr} FPS</Text>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
/>
</>
);
}
type AudioProps = {
requestClose: () => void;
};
/**
* Audio settings page component
* Allows users to select microphone and speaker devices for calls
*/
export function Audio({ requestClose }: AudioProps) {
const [microphones, setMicrophones] = useState<AudioDevice[]>([]);
const [speakers, setSpeakers] = useState<AudioDevice[]>([]);
const [settings, setSettings] = useState<AudioSettings>(loadAudioSettings);
const [permissionStatus, setPermissionStatus] = useState<'granted' | 'denied' | 'prompt'>('prompt');
useEffect(() => {
const loadDevices = async () => {
try {
// Request permission to access media devices
await navigator.mediaDevices.getUserMedia({ audio: true });
setPermissionStatus('granted');
const devices = await navigator.mediaDevices.enumerateDevices();
const mics = devices
.filter((d) => d.kind === 'audioinput')
.map((d) => ({
deviceId: d.deviceId,
label: d.label,
kind: d.kind,
}));
const spks = devices
.filter((d) => d.kind === 'audiooutput')
.map((d) => ({
deviceId: d.deviceId,
label: d.label,
kind: d.kind,
}));
setMicrophones(mics);
setSpeakers(spks);
// If no device is selected yet, use the first available
if (!settings.microphoneId && mics.length > 0) {
setSettings((prev) => ({ ...prev, microphoneId: mics[0].deviceId }));
}
if (!settings.speakerId && spks.length > 0) {
setSettings((prev) => ({ ...prev, speakerId: spks[0].deviceId }));
}
} catch (e) {
console.error('Failed to enumerate devices:', e);
setPermissionStatus('denied');
}
};
loadDevices();
// Listen for device changes
const handleDeviceChange = () => {
loadDevices();
};
navigator.mediaDevices.addEventListener('devicechange', handleDeviceChange);
return () => {
navigator.mediaDevices.removeEventListener('devicechange', handleDeviceChange);
};
}, [settings.microphoneId, settings.speakerId]);
const handleMicrophoneSelect = (deviceId: string) => {
const newSettings = { ...settings, microphoneId: deviceId };
setSettings(newSettings);
saveAudioSettings(newSettings);
};
const handleSpeakerSelect = (deviceId: string) => {
const newSettings = { ...settings, speakerId: deviceId };
setSettings(newSettings);
saveAudioSettings(newSettings);
};
const handleNoiseSuppressionChange = (enabled: boolean) => {
const newSettings = { ...settings, noiseSuppression: enabled };
setSettings(newSettings);
saveAudioSettings(newSettings);
};
const handleEchoCancellationChange = (enabled: boolean) => {
const newSettings = { ...settings, echoCancellation: enabled };
setSettings(newSettings);
saveAudioSettings(newSettings);
};
const handleAutoGainControlChange = (enabled: boolean) => {
const newSettings = { ...settings, autoGainControl: enabled };
setSettings(newSettings);
saveAudioSettings(newSettings);
};
const handleScreenShareResolutionChange = (resolution: ScreenShareResolution) => {
const newSettings = { ...settings, screenShareResolution: resolution };
setSettings(newSettings);
saveAudioSettings(newSettings);
};
const handleScreenShareBitrateChange = (bitrate: ScreenShareBitrate) => {
const newSettings = { ...settings, screenShareBitrate: bitrate };
setSettings(newSettings);
saveAudioSettings(newSettings);
};
const handleScreenShareFrameRateChange = (frameRate: number) => {
const newSettings = { ...settings, screenShareFrameRate: frameRate };
setSettings(newSettings);
saveAudioSettings(newSettings);
};
const handleTestMicrophone = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: settings.microphoneId ? { deviceId: { exact: settings.microphoneId } } : true,
});
// Create audio context to visualize
const audioContext = new AudioContext();
const analyser = audioContext.createAnalyser();
const microphone = audioContext.createMediaStreamSource(stream);
microphone.connect(analyser);
// Quick test - just log that it works
console.log('🎤 Microphone test: Audio stream acquired');
// Stop after a short delay
setTimeout(() => {
stream.getTracks().forEach((track) => track.stop());
audioContext.close();
console.log('🎤 Microphone test: Complete');
}, 2000);
} catch (e) {
console.error('Microphone test failed:', e);
}
};
const handleTestSpeaker = async () => {
try {
// Create a simple test tone
const audioContext = new AudioContext();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.value = 440; // A4 note
gainNode.gain.value = 0.1; // Low volume
oscillator.start();
console.log('🔊 Speaker test: Playing tone');
// Stop after a short delay
setTimeout(() => {
oscillator.stop();
audioContext.close();
console.log('🔊 Speaker test: Complete');
}, 1000);
} catch (e) {
console.error('Speaker test failed:', e);
}
};
return (
<Page>
<PageHeader>
<Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<IconButton onClick={requestClose}>
<Icon src={Icons.ArrowLeft} />
</IconButton>
<Text size="H4" truncate>
Audio & Video
</Text>
</Box>
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="700">
{permissionStatus === 'denied' && (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<Header size="400">
<Box gap="200" grow="Yes">
<Text size="H4">Permission Required</Text>
</Box>
</Header>
<SettingTile
title="Microphone Access Denied"
description="Please allow microphone access in your browser settings to use audio features."
/>
</SequenceCard>
)}
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<Header size="400">
<Box gap="200" grow="Yes">
<Text size="H4">Microphone</Text>
</Box>
</Header>
<SettingTile
title="Input Device"
description="Select which microphone to use for voice calls."
after={
<Box alignItems="Center" gap="200">
<DeviceSelectButton
devices={microphones}
selectedId={settings.microphoneId}
onSelect={handleMicrophoneSelect}
/>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
onClick={handleTestMicrophone}
>
<Text size="T300">Test</Text>
</Button>
</Box>
}
/>
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<Header size="400">
<Box gap="200" grow="Yes">
<Text size="H4">Speaker</Text>
</Box>
</Header>
<SettingTile
title="Output Device"
description="Select which speaker to use for call audio."
after={
<Box alignItems="Center" gap="200">
<DeviceSelectButton
devices={speakers}
selectedId={settings.speakerId}
onSelect={handleSpeakerSelect}
/>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
onClick={handleTestSpeaker}
>
<Text size="T300">Test</Text>
</Button>
</Box>
}
/>
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<Header size="400">
<Box gap="200" grow="Yes">
<Text size="H4">Audio Processing</Text>
</Box>
</Header>
<SettingTile
title="Noise Suppression"
description="Reduce background noise from your microphone during calls."
after={
<Switch
variant="Primary"
value={settings.noiseSuppression}
onChange={handleNoiseSuppressionChange}
/>
}
/>
<SettingTile
title="Echo Cancellation"
description="Prevent echo when your speakers feed back into your microphone."
after={
<Switch
variant="Primary"
value={settings.echoCancellation}
onChange={handleEchoCancellationChange}
/>
}
/>
<SettingTile
title="Auto Gain Control"
description="Automatically adjust microphone volume for consistent levels."
after={
<Switch
variant="Primary"
value={settings.autoGainControl}
onChange={handleAutoGainControlChange}
/>
}
/>
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<Header size="400">
<Box gap="200" grow="Yes">
<Text size="H4">Screen Sharing</Text>
</Box>
</Header>
<SettingTile
title="Resolution"
description="Maximum resolution when sharing your screen."
after={
<ScreenShareResolutionSelect
value={settings.screenShareResolution}
onChange={handleScreenShareResolutionChange}
/>
}
/>
<SettingTile
title="Bitrate"
description="Video quality for screen sharing. Higher uses more bandwidth."
after={
<ScreenShareBitrateSelect
value={settings.screenShareBitrate}
onChange={handleScreenShareBitrateChange}
/>
}
/>
<SettingTile
title="Frame Rate"
description="Frames per second when sharing your screen."
after={
<ScreenShareFrameRateSelect
value={settings.screenShareFrameRate}
onChange={handleScreenShareFrameRateChange}
/>
}
/>
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<Header size="400">
<Box gap="200" grow="Yes">
<Text size="H4">Tips</Text>
</Box>
</Header>
<Box direction="Column" gap="200" style={{ padding: `0 ${config.space.S300}` }}>
<Text size="T300">
If you don&apos;t see your devices, make sure they are connected and permissions are granted.
</Text>
<Text size="T300">
The &quot;Test&quot; button will help verify your devices are working correctly.
</Text>
<Text size="T300">
Changes are saved automatically and will apply to your next call.
</Text>
</Box>
</SequenceCard>
</Box>
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View File

@@ -0,0 +1 @@
export * from './Audio';

View File

@@ -5,6 +5,10 @@ export type HashRouterConfig = {
basename?: string;
};
export type CallingConfig = {
livekitServiceUrl?: string;
};
export type ClientConfig = {
defaultHomeserver?: number;
homeserverList?: string[];
@@ -17,6 +21,8 @@ export type ClientConfig = {
servers?: string[];
};
calling?: CallingConfig;
hashRouter?: HashRouterConfig;
};

View File

@@ -47,7 +47,10 @@ export const useMemberSort = (index: number, memberSort: MemberSortItem[]): Memb
return item;
};
export const useMemberPowerSort = (creators: Set<string>): MemberSortFn => {
export const useMemberPowerSort = (
creators: Set<string>,
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<string>): 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]
);

View File

@@ -15,7 +15,7 @@ export function AuthFooter() {
target="_blank"
rel="noreferrer"
>
v4.10.0
v4.10.2
</Text>
<Text as="a" size="T300" href="https://twitter.com/cinnyapp" target="_blank" rel="noreferrer">
Twitter

View File

@@ -35,6 +35,7 @@ import { stopPropagation } from '../../utils/keyboard';
import { SyncStatus } from './SyncStatus';
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
import { getFallbackSession } from '../../state/sessions';
import { CallProviderWrapper } from '../../features/call/CallProviderWrapper';
function ClientRootLoading() {
return (
@@ -211,6 +212,7 @@ export function ClientRoot({ children }: ClientRootProps) {
<ClientRootLoading />
) : (
<MatrixClientProvider value={mx}>
<CallProviderWrapper>
<ServerConfigsLoader>
{(serverConfigs) => (
<CapabilitiesProvider value={serverConfigs.capabilities ?? {}}>
@@ -222,6 +224,7 @@ export function ClientRoot({ children }: ClientRootProps) {
</CapabilitiesProvider>
)}
</ServerConfigsLoader>
</CallProviderWrapper>
</MatrixClientProvider>
)}
</SpecVersions>

View File

@@ -24,7 +24,7 @@ export function WelcomePage() {
target="_blank"
rel="noreferrer noopener"
>
v4.10.0
v4.10.2
</a>
</span>
}

View File

@@ -73,7 +73,7 @@ export default defineConfig({
base: buildConfig.base,
server: {
port: 8080,
host: true,
host: '0.0.0.0',
fs: {
// Allow serving files from one level up to the project root
allow: ['..'],