Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
de48e8a
Integrate LiveKit video/audio calling into Relay
rexbron Apr 5, 2026
18e0d76
Fix CallViewModel build errors
rexbron Apr 5, 2026
3deab5e
Fetch LiveKit credentials from homeserver via MatrixRTC (MSC4143)
rexbron Apr 5, 2026
98a1115
Fix tuple label mismatch in callCredentials return
rexbron Apr 5, 2026
be4a050
Fix grey video box: cache VideoViews and update on re-render
rexbron Apr 5, 2026
d03f95e
Fix video track timing race by observing videoTrackRevision in SwiftUI
rexbron Apr 5, 2026
16e3fe3
Use SwiftUIVideoView and proper RoomOptions for LiveKit calls
rexbron Apr 6, 2026
1ec91c7
Add E2EE and MatrixRTC call signaling for Element Call interop
rexbron Apr 6, 2026
653b7d9
Overhaul call UI, fix Element-X interop, and add conditional E2EE
rexbron Apr 9, 2026
2d193c2
Improve call window UX, fix constraint crash, and add error reporting
rexbron Apr 28, 2026
f318458
Fix LiveKit E2EE interop with Element Call (HKDF, identity, timing)
rexbron Apr 20, 2026
500516b
Prefix RTC logs with [RTC] and bridge LiveKit logs to OSLog
rexbron Apr 24, 2026
7134231
Drop runtime power-level mutation; add call.member heartbeat and boun…
rexbron Apr 25, 2026
84201d7
Tile remote participants in group calls
rexbron Apr 25, 2026
3ce460f
Show camera-off placeholder immediately on remote mute
rexbron Apr 25, 2026
aeccc30
Auto-close call window on clean disconnect
rexbron Apr 25, 2026
a54214b
Plug secret leaks in [RTC] development logs
rexbron Apr 25, 2026
07dd527
Restore RelayKit/Widget/WidgetProxy.swift placeholder
rexbron Apr 25, 2026
4764a10
Render call.member events as "started a call" in the surgical map path
rexbron Apr 25, 2026
c896148
Resolve package graph
subpop Apr 28, 2026
4e7a0a7
Restore Secrets.xcconfig as base configuration for all build configs
subpop Apr 28, 2026
d28365a
Restore call button in toolbar
subpop Apr 30, 2026
83b8048
Fix .well-known RTC lookup for delegated homeservers
subpop Apr 30, 2026
10d6fe1
Add Livekit as a framework dependancy
rexbron Apr 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,11 @@ public enum RelayError: LocalizedError, Sendable {
/// A direct message room could not be opened or created.
case dmCreationFailed(String)

// MARK: Calls

/// A call could not be started.
case callFailed(String)

// MARK: LocalizedError

public var errorDescription: String? {
Expand Down Expand Up @@ -160,6 +165,8 @@ public enum RelayError: LocalizedError, Sendable {
"Could Not Update Display Name"
case .dmCreationFailed:
"Could Not Open Conversation"
case .callFailed:
"Call Failed"
}
}

Expand Down Expand Up @@ -213,6 +220,8 @@ public enum RelayError: LocalizedError, Sendable {
reason
case .dmCreationFailed(let reason):
reason
case .callFailed(let reason):
reason
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ public struct TimelineMessage: Identifiable, Sendable, Equatable {
case profileChange
/// A room state change (room name, topic, avatar, encryption, join rules, etc.).
case stateEvent
/// A call-related event (user started, joined, or left a call).
case callEvent
}

/// A group of emoji reactions attached to a message, aggregated by reaction key.
Expand Down Expand Up @@ -357,7 +359,7 @@ public struct TimelineMessage: Identifiable, Sendable, Equatable {
nonisolated public var isSpecialType: Bool {
switch kind {
case .text, .emote, .notice: false
case .membership, .profileChange, .stateEvent: false
case .membership, .profileChange, .stateEvent, .callEvent: false
default: true
}
}
Expand All @@ -366,7 +368,7 @@ public struct TimelineMessage: Identifiable, Sendable, Equatable {
/// rather than a user-authored message.
nonisolated public var isSystemEvent: Bool {
switch kind {
case .membership, .profileChange, .stateEvent: true
case .membership, .profileChange, .stateEvent, .callEvent: true
default: false
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// Copyright 2026 Link Dupont
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import Foundation
import SwiftUI

/// The connection state of a call.
public enum CallState: Sendable, Equatable {
/// No active call.
case idle
/// Establishing connection to the call server.
case connecting
/// Successfully connected; media is flowing.
case connected
/// The call ended cleanly.
case disconnected
/// The call failed with an error message.
case failed(String)
}

/// A snapshot of a single call participant.
public struct CallParticipant: Identifiable, Sendable, Equatable {
/// The participant's identity string (typically their Matrix user ID).
public let id: String
/// The participant's display name, if available.
public let displayName: String?
/// Whether the participant has their camera enabled.
public let isCameraEnabled: Bool
/// Whether the participant has their microphone enabled.
public let isMicrophoneEnabled: Bool
/// Whether the participant is currently speaking.
public let isSpeaking: Bool

public init(
id: String,
displayName: String?,
isCameraEnabled: Bool,
isMicrophoneEnabled: Bool,
isSpeaking: Bool
) {
self.id = id
self.displayName = displayName
self.isCameraEnabled = isCameraEnabled
self.isMicrophoneEnabled = isMicrophoneEnabled
self.isSpeaking = isSpeaking
}
}

/// The view model protocol for a LiveKit-backed audio/video call in a Matrix room.
///
/// ``CallViewModelProtocol`` defines the observable state and actions needed by ``CallView``
/// to render the call UI, control local media, and display remote participants. Concrete
/// implementations include ``CallViewModel`` (backed by the LiveKit Swift SDK) and
/// ``PreviewCallViewModel`` (for SwiftUI previews).
///
/// Video rendering is intentionally opaque: callers request an ``NSView`` via
/// ``makeVideoView(for:)`` to avoid exposing LiveKit types outside of RelayKit.
@MainActor
public protocol CallViewModelProtocol: AnyObject, Observable {
/// The current connection state of the call.
var state: CallState { get }

/// All remote participants currently in the call.
var participants: [CallParticipant] { get }

/// Whether the local user's camera is active.
var isLocalCameraEnabled: Bool { get }

/// Whether the local user's microphone is active.
var isLocalMicrophoneEnabled: Bool { get }

/// The identity of the local participant, set after connection.
var localParticipantID: String? { get }

/// A monotonically increasing counter that is bumped whenever video tracks change
/// (publish, unpublish, camera toggle, etc.). SwiftUI views should read this value
/// to ensure ``NSViewRepresentable`` bridges receive `updateNSView` calls when the
/// underlying video track becomes available.
var videoTrackRevision: UInt { get }

/// Connects to the call using the provided LiveKit server URL and JWT token.
///
/// - Parameters:
/// - url: The WebSocket URL of the LiveKit server (e.g. `"wss://livekit.example.com"`).
/// - token: A signed JWT granting access to the room.
func connect(url: String, token: String, sfuServiceURL: String) async throws

/// Disconnects from the call and cleans up media resources.
func disconnect() async

/// Toggles the local camera on or off.
func toggleCamera() async throws

/// Toggles the local microphone on or off.
func toggleMicrophone() async throws

/// Returns a SwiftUI view that renders the video track of the given participant,
/// or `nil` if the participant has no active video track or is not found.
///
/// - Parameter participantID: The ``CallParticipant/id`` of the participant to render.
func makeVideoView(for participantID: String) -> AnyView?

/// Returns the aspect ratio (width / height) of the participant's currently
/// publishing video track, or `nil` if no track is available or its
/// dimensions haven't been negotiated yet. Tile-based UIs use this to
/// avoid stretching video — each tile can size itself to the source aspect.
///
/// - Parameter participantID: The ``CallParticipant/id`` of the participant.
func videoAspectRatio(for participantID: String) -> CGFloat?
}
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,25 @@ public protocol MatrixServiceProtocol: AnyObject, Observable {
/// verification controller is not available.
func makeSessionVerificationViewModel() async throws -> (any SessionVerificationViewModelProtocol)?

/// Creates a view model for joining or managing a LiveKit audio/video call in a Matrix room.
///
/// - Parameter roomId: The Matrix room identifier for the call.
/// - Returns: A ``CallViewModelProtocol`` instance ready to be connected with a LiveKit
/// URL and token, or `nil` if calling is not supported.
func makeCallViewModel(roomId: String) async -> (any CallViewModelProtocol)?

/// Fetches LiveKit credentials for a Matrix room using the MatrixRTC flow (MSC4143).
///
/// Discovers the SFU URL from the homeserver, obtains an OpenID token, and
/// exchanges it with the SFU's JWT service. The returned URL and token can be
/// passed directly to ``CallViewModelProtocol/connect(url:token:)``.
///
/// - Parameter roomId: The Matrix room identifier.
/// - Returns: A tuple of `(livekitURL, token)` where `livekitURL` is the LiveKit
/// WebSocket URL and `token` is the JWT access token.
/// - Throws: If the homeserver doesn't support MatrixRTC or credential exchange fails.
func callCredentials(for roomId: String) async throws -> (livekitURL: String, token: String, sfuServiceURL: String)

// MARK: Notification Settings (synced via push rules)

/// Returns the default notification mode for rooms of the given type.
Expand Down Expand Up @@ -749,6 +768,8 @@ public extension EnvironmentValues {
}
}

private struct PlaceholderError: Error {}

@Observable
private final class PlaceholderMatrixService: MatrixServiceProtocol {
let activityLog: any ActivityLogProtocol = PlaceholderActivityLog()
Expand Down Expand Up @@ -809,6 +830,10 @@ private final class PlaceholderMatrixService: MatrixServiceProtocol {
func isCurrentSessionVerified() async -> Bool { false }
func encryptionState() async -> EncryptionStatus { EncryptionStatus() }
func makeSessionVerificationViewModel() async throws -> (any SessionVerificationViewModelProtocol)? { nil }
func makeCallViewModel(roomId: String) async -> (any CallViewModelProtocol)? { nil }
func callCredentials(for roomId: String) async throws -> (livekitURL: String, token: String, sfuServiceURL: String) {
throw PlaceholderError()
}
func getDefaultNotificationMode(
isOneToOne: Bool
) async throws -> DefaultNotificationMode { .mentionsAndKeywordsOnly }
Expand Down
41 changes: 41 additions & 0 deletions Relay.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
3BRI00032FC10003001F0EA1 /* RelayInterface in Frameworks */ = {isa = PBXBuildFile; productRef = 3BRI00042FC10004001F0EA1 /* RelayInterface */; };
3BRK00012FC00001001F0EA1 /* RelayKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3BRK00002FC00000001F0EA1 /* RelayKit.framework */; };
3BRK00022FC00002001F0EA1 /* RelayKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3BRK00002FC00000001F0EA1 /* RelayKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
DF41154C2F82F7A30028241B /* LiveKit in Frameworks */ = {isa = PBXBuildFile; productRef = 3BLK00022FD10002001F0EA1 /* LiveKit */; };
DFDAFCB22FA2C56400A27353 /* LiveKit in Frameworks */ = {isa = PBXBuildFile; productRef = DFDAFCB12FA2C56400A27353 /* LiveKit */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand Down Expand Up @@ -96,6 +98,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
DFDAFCB22FA2C56400A27353 /* LiveKit in Frameworks */,
3BRK00012FC00001001F0EA1 /* RelayKit.framework in Frameworks */,
3BRI00032FC10003001F0EA1 /* RelayInterface in Frameworks */,
);
Expand All @@ -105,6 +108,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
DF41154C2F82F7A30028241B /* LiveKit in Frameworks */,
3BMRST012FB10001001F0EA1 /* MatrixRustSDK in Frameworks */,
3BRI00012FC10001001F0EA1 /* RelayInterface in Frameworks */,
);
Expand All @@ -121,6 +125,7 @@
3B4AFD8B2F638A35001F0EA1 /* Relay */,
3BRK00112FC00011001F0EA1 /* RelayKit */,
3B11E0892F9A3F600051F7B3 /* RelayTests */,
DFDAFCAE2FA2C4F700A27353 /* Frameworks */,
3B4AFD8A2F638A35001F0EA1 /* Products */,
);
sourceTree = "<group>";
Expand All @@ -135,6 +140,13 @@
name = Products;
sourceTree = "<group>";
};
DFDAFCAE2FA2C4F700A27353 /* Frameworks */ = {
isa = PBXGroup;
children = (
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */

/* Begin PBXHeadersBuildPhase section */
Expand Down Expand Up @@ -192,6 +204,7 @@
name = Relay;
packageProductDependencies = (
3BRI00042FC10004001F0EA1 /* RelayInterface */,
DFDAFCB12FA2C56400A27353 /* LiveKit */,
);
productName = Relay;
productReference = 3B4AFD892F638A35001F0EA1 /* Relay.app */;
Expand All @@ -217,6 +230,7 @@
packageProductDependencies = (
3BMRST022FB10002001F0EA1 /* MatrixRustSDK */,
3BRI00022FC10002001F0EA1 /* RelayInterface */,
3BLK00022FD10002001F0EA1 /* LiveKit */,
);
productName = RelayKit;
productReference = 3BRK00002FC00000001F0EA1 /* RelayKit.framework */;
Expand Down Expand Up @@ -256,6 +270,7 @@
packageReferences = (
3BRI00052FC10005001F0EA1 /* XCLocalSwiftPackageReference "Packages/RelayInterface" */,
3BMRST032FB10003001F0EA1 /* XCRemoteSwiftPackageReference "matrix-rust-components-swift" */,
3BLK00032FD10003001F0EA1 /* XCRemoteSwiftPackageReference "client-sdk-swift" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 3B4AFD8A2F638A35001F0EA1 /* Products */;
Expand Down Expand Up @@ -528,10 +543,13 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIconDev;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColorDev;
CODE_SIGN_ENTITLEMENTS = Relay/Relay.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 13;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "";
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_INCOMING_NETWORK_CONNECTIONS = YES;
Expand All @@ -558,6 +576,7 @@
MARKETING_VERSION = 0.4.4;
PRODUCT_BUNDLE_IDENTIFIER = app.subpop.Relay;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
REGISTER_APP_GROUPS = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
Expand All @@ -574,10 +593,13 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Relay/Relay.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 13;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "";
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_INCOMING_NETWORK_CONNECTIONS = YES;
Expand All @@ -604,6 +626,7 @@
MARKETING_VERSION = 0.4.4;
PRODUCT_BUNDLE_IDENTIFIER = app.subpop.Relay;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
REGISTER_APP_GROUPS = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
Expand Down Expand Up @@ -730,6 +753,14 @@
/* End XCLocalSwiftPackageReference section */

/* Begin XCRemoteSwiftPackageReference section */
3BLK00032FD10003001F0EA1 /* XCRemoteSwiftPackageReference "client-sdk-swift" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/livekit/client-sdk-swift";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.0.0;
};
};
3BMRST032FB10003001F0EA1 /* XCRemoteSwiftPackageReference "matrix-rust-components-swift" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/matrix-org/matrix-rust-components-swift";
Expand All @@ -741,6 +772,11 @@
/* End XCRemoteSwiftPackageReference section */

/* Begin XCSwiftPackageProductDependency section */
3BLK00022FD10002001F0EA1 /* LiveKit */ = {
isa = XCSwiftPackageProductDependency;
package = 3BLK00032FD10003001F0EA1 /* XCRemoteSwiftPackageReference "client-sdk-swift" */;
productName = LiveKit;
};
3BMRST022FB10002001F0EA1 /* MatrixRustSDK */ = {
isa = XCSwiftPackageProductDependency;
package = 3BMRST032FB10003001F0EA1 /* XCRemoteSwiftPackageReference "matrix-rust-components-swift" */;
Expand All @@ -754,6 +790,11 @@
isa = XCSwiftPackageProductDependency;
productName = RelayInterface;
};
DFDAFCB12FA2C56400A27353 /* LiveKit */ = {
isa = XCSwiftPackageProductDependency;
package = 3BLK00032FD10003001F0EA1 /* XCRemoteSwiftPackageReference "client-sdk-swift" */;
productName = LiveKit;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 3B4AFD812F638A35001F0EA1 /* Project object */;
Expand Down
Loading
Loading