feat: add user color customization and metadata handling

- Implemented `useUserColor` hook to manage user profile color stored in avatar PNG metadata.
- Added `useOtherUserColor` hook to fetch and cache colors from other users' avatars.
- Created utility functions for reading and writing PNG metadata, enabling color embedding and extraction.
- Refactored `SearchResultGroup`, `RoomInput`, `Message`, `RoomPinMenu`, and `Notifications` components to utilize user color for display.
- Introduced `UserColorPicker` component in settings for users to select and save their profile color.
- Enhanced notification and message rendering to prioritize custom user colors over legacy colors.
This commit is contained in:
2026-02-06 05:53:37 +11:00
parent 4ca4af0e8b
commit 516000a25f
11 changed files with 1061 additions and 222 deletions

View File

@@ -31,6 +31,16 @@ 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
*/
/** How often to refresh call membership to prevent expiry (45 minutes) */
const MEMBERSHIP_REFRESH_INTERVAL_MS = 45 * 60 * 1000;
/** Call membership expiry time (1 hour) */
const MEMBERSHIP_EXPIRY_MS = 60 * 60 * 1000;
/**
* Service for managing Matrix RTC calls via LiveKit
* Handles LiveKit connections, media streams, call state, and Matrix room signaling
@@ -48,6 +58,12 @@ export class CallService {
private livekitJwt: LiveKitJWTResponse | null = null;
private membershipRefreshInterval: ReturnType<typeof setInterval> | null = null;
private currentCallId: string | null = null;
private wasMutedBeforeDeafen = false;
/**
* Creates a new CallService instance
* @param config - Service configuration including homeserver and LiveKit URLs
@@ -137,7 +153,7 @@ export class CallService {
{
device_id: this.config.deviceId,
session_id: callId,
expires_ts: Date.now() + 1000 * 60 * 60, // 1 hour expiry
expires_ts: Date.now() + MEMBERSHIP_EXPIRY_MS,
feeds: [
{ purpose: 'usermedia' },
],
@@ -170,6 +186,44 @@ export class CallService {
}
}
/**
* Starts periodic refresh of call membership to prevent expiry
* Refreshes every 45 minutes to ensure the 1 hour expiry is never reached
* @param roomId - The Matrix room ID
* @param callId - The unique call ID
*/
private startMembershipRefresh(roomId: string, callId: string): void {
this.stopMembershipRefresh();
this.membershipRefreshInterval = setInterval(async () => {
if (!this.activeCall || this.activeCall.roomId !== roomId) {
this.stopMembershipRefresh();
return;
}
try {
console.log('Refreshing call membership to prevent expiry');
await this.sendCallMemberEvent(roomId, callId, true);
console.log('Call membership refreshed successfully');
} catch (error) {
console.error('Failed to refresh call membership:', error);
}
}, MEMBERSHIP_REFRESH_INTERVAL_MS);
console.log('Started call membership refresh interval (every 45 minutes)');
}
/**
* Stops the membership refresh interval
*/
private stopMembershipRefresh(): void {
if (this.membershipRefreshInterval) {
clearInterval(this.membershipRefreshInterval);
this.membershipRefreshInterval = null;
console.log('Stopped call membership refresh interval');
}
}
/**
* Starts a call in the specified room
* @param roomId - The Matrix room ID to start the call in
@@ -271,6 +325,10 @@ export class CallService {
// Step 5: Send Matrix state event to announce participation
// This state event will be rendered in the timeline as a "joined the call" notification
await this.sendCallMemberEvent(roomId, callId, true);
this.currentCallId = callId;
// Step 6: Start periodic membership refresh to prevent expiry after 1 hour
this.startMembershipRefresh(roomId, callId);
console.log('Announced call participation to Matrix room');
@@ -651,6 +709,9 @@ export class CallService {
const { roomId } = this.activeCall;
this.setState(CallState.Disconnecting);
// Stop the membership refresh interval
this.stopMembershipRefresh();
// Stop any dialing loop and play departure sound
getCallSounds().stopDialingLoop();
getCallSounds().playSound(CallSoundType.CallDepart);
@@ -674,6 +735,7 @@ export class CallService {
this.activeCall = null;
this.livekitJwt = null;
this.currentCallId = null;
this.emit({
type: CallEventType.StateChanged,
@@ -712,6 +774,9 @@ export class CallService {
this.livekitRoom.localParticipant.setMetadata(metadata).catch((err) => {
console.warn('Failed to set participant metadata:', err);
});
// Play undeafen sound
getCallSounds().playSound(CallSoundType.Undeafen);
}
this.livekitRoom.localParticipant.setMicrophoneEnabled(!newMuteState);
@@ -749,16 +814,23 @@ export class CallService {
});
});
// Also mute/unmute our mic when deafening (silently - no mute sound)
// Only change mic state if it doesn't match deafen state
if (newDeafenState && !this.activeCall.isMuted) {
// Deafening - also mute mic
this.livekitRoom.localParticipant.setMicrophoneEnabled(false);
this.activeCall.isMuted = true;
} else if (!newDeafenState && this.activeCall.isMuted) {
// Undeafening - also unmute mic
this.livekitRoom.localParticipant.setMicrophoneEnabled(true);
this.activeCall.isMuted = false;
// Handle mic muting when deafening
if (newDeafenState) {
// Remember if user was muted before deafening
this.wasMutedBeforeDeafen = this.activeCall.isMuted;
// Deafening - always mute mic
if (!this.activeCall.isMuted) {
this.livekitRoom.localParticipant.setMicrophoneEnabled(false);
this.activeCall.isMuted = true;
}
} else {
// Undeafening - only unmute if user wasn't muted before deafening
if (!this.wasMutedBeforeDeafen && this.activeCall.isMuted) {
this.livekitRoom.localParticipant.setMicrophoneEnabled(true);
this.activeCall.isMuted = false;
}
this.wasMutedBeforeDeafen = false;
}
// Broadcast deafen state to other participants via metadata
@@ -943,6 +1015,7 @@ export class CallService {
* Cleans up resources
*/
dispose(): void {
this.stopMembershipRefresh();
this.endCall();
this.listeners.clear();
}