diff --git a/src/app/components/message/Reply.tsx b/src/app/components/message/Reply.tsx
index 57bf2af..9a335ba 100644
--- a/src/app/components/message/Reply.tsx
+++ b/src/app/components/message/Reply.tsx
@@ -2,7 +2,7 @@ import { Box, Icon, Icons, Text, as, color, toRem } from 'folds';
import { EventTimelineSet, Room } from 'matrix-js-sdk';
import React, { MouseEventHandler, ReactNode, useCallback, useMemo } from 'react';
import classNames from 'classnames';
-import { getMemberDisplayName, trimReplyFromBody } from '../../utils/room';
+import { getMemberAvatarMxc, getMemberDisplayName, trimReplyFromBody } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix';
import { LinePlaceholder } from './placeholder';
import { randomNumberBetween } from '../../utils/common';
@@ -12,6 +12,7 @@ import { scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
import { useRoomEvent } from '../../hooks/useRoomEvent';
import colorMXID from '../../../util/colorMXID';
import { GetMemberPowerTag } from '../../hooks/useMemberPowerTag';
+import { useOtherUserColor } from '../../hooks/useUserColor';
type ReplyLayoutProps = {
userColor?: string;
@@ -86,10 +87,13 @@ export const Reply = as<'div', ReplyProps>(
const { body } = replyEvent?.getContent() ?? {};
const sender = replyEvent?.getSender();
+ const senderAvatarMxc = sender ? getMemberAvatarMxc(room, sender) : undefined;
+ const customUserColor = useOtherUserColor(sender ?? '', senderAvatarMxc);
const powerTag = sender ? getMemberPowerTag?.(sender) : undefined;
const tagColor = powerTag?.color ? accessibleTagColors?.get(powerTag.color) : undefined;
- const usernameColor = legacyUsernameColor ? colorMXID(sender ?? replyEventId) : tagColor;
+ // Priority: custom user color > tag color (non-legacy) > colorMXID (legacy)
+ const usernameColor = customUserColor ?? (legacyUsernameColor ? colorMXID(sender ?? replyEventId) : tagColor);
const fallbackBody = replyEvent?.isRedacted() ? (
diff --git a/src/app/features/call/CallOverlay.tsx b/src/app/features/call/CallOverlay.tsx
index e05b23a..33ab611 100644
--- a/src/app/features/call/CallOverlay.tsx
+++ b/src/app/features/call/CallOverlay.tsx
@@ -605,7 +605,20 @@ export function CallOverlay() {
{isConnecting ? 'Connecting...' : `In Call - ${room?.name ?? 'Unknown Room'}`}
{selectedRoomId === activeCall.roomId && (
- (Current Room)
+ Current Room}
+ >
+ {(triggerRef) => (
+
+ )}
+
)}
{selectedRoomId !== activeCall.roomId && (
diff --git a/src/app/features/call/CallService.ts b/src/app/features/call/CallService.ts
index 752e528..53a6c1c 100644
--- a/src/app/features/call/CallService.ts
+++ b/src/app/features/call/CallService.ts
@@ -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 | 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();
}
diff --git a/src/app/features/message-search/SearchResultGroup.tsx b/src/app/features/message-search/SearchResultGroup.tsx
index 62ef9c4..e7a56eb 100644
--- a/src/app/features/message-search/SearchResultGroup.tsx
+++ b/src/app/features/message-search/SearchResultGroup.tsx
@@ -51,6 +51,147 @@ import {
} from '../../hooks/useMemberPowerTag';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
+import { useOtherUserColor } from '../../hooks/useUserColor';
+
+/**
+ * Props for SearchResultItem component
+ */
+type SearchResultItemProps = {
+ room: Room;
+ item: ResultItem;
+ getMemberPowerTag: ReturnType;
+ accessibleTagColors: Map | undefined;
+ renderMatrixEvent: ReturnType>;
+ handleOpenClick: MouseEventHandler;
+ legacyUsernameColor?: boolean;
+ hour24Clock: boolean;
+ dateFormatString: string;
+};
+
+/**
+ * Component to render a single search result item with proper hook usage
+ */
+function SearchResultItem({
+ room,
+ item,
+ getMemberPowerTag,
+ accessibleTagColors,
+ renderMatrixEvent,
+ handleOpenClick,
+ legacyUsernameColor,
+ hour24Clock,
+ dateFormatString,
+}: SearchResultItemProps) {
+ const mx = useMatrixClient();
+ const useAuthentication = useMediaAuthentication();
+ const { event } = item;
+
+ const displayName =
+ getMemberDisplayName(room, event.sender) ??
+ getMxIdLocalPart(event.sender) ??
+ event.sender;
+ const senderAvatarMxc = getMemberAvatarMxc(room, event.sender);
+
+ const relation = event.content['m.relates_to'];
+ const mainEventId =
+ relation?.rel_type === RelationType.Replace ? relation.event_id : event.event_id;
+
+ const getContent = (() =>
+ event.content['m.new_content'] ?? event.content) as GetContentCallback;
+
+ const replyEventId = relation?.['m.in_reply_to']?.event_id;
+ const threadRootId =
+ relation?.rel_type === RelationType.Thread ? relation.event_id : undefined;
+
+ // Get custom user color from avatar metadata
+ const customUserColor = useOtherUserColor(event.sender, senderAvatarMxc);
+
+ const memberPowerTag = getMemberPowerTag(event.sender);
+ const tagColor = memberPowerTag?.color
+ ? accessibleTagColors?.get(memberPowerTag.color)
+ : undefined;
+ const tagIconSrc = memberPowerTag?.icon
+ ? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon)
+ : undefined;
+
+ // Priority: custom user color > tag color (non-legacy) > colorMXID (legacy)
+ const usernameColor = customUserColor ?? (legacyUsernameColor ? colorMXID(event.sender) : tagColor);
+
+ return (
+
+
+
+ }
+ />
+
+
+ }
+ >
+
+
+
+
+
+ {displayName}
+
+
+ {tagIconSrc && }
+
+
+
+
+
+ Open
+
+
+
+ {replyEventId && (
+
+ )}
+ {renderMatrixEvent(event.type, false, event, displayName, getContent)}
+
+
+ );
+}
type SearchResultGroupProps = {
room: Room;
@@ -213,111 +354,20 @@ export function SearchResultGroup({
- {items.map((item) => {
- const { event } = item;
-
- const displayName =
- getMemberDisplayName(room, event.sender) ??
- getMxIdLocalPart(event.sender) ??
- event.sender;
- const senderAvatarMxc = getMemberAvatarMxc(room, event.sender);
-
- const relation = event.content['m.relates_to'];
- const mainEventId =
- relation?.rel_type === RelationType.Replace ? relation.event_id : event.event_id;
-
- const getContent = (() =>
- event.content['m.new_content'] ?? event.content) as GetContentCallback;
-
- const replyEventId = relation?.['m.in_reply_to']?.event_id;
- const threadRootId =
- relation?.rel_type === RelationType.Thread ? relation.event_id : undefined;
-
- const memberPowerTag = getMemberPowerTag(event.sender);
- const tagColor = memberPowerTag?.color
- ? accessibleTagColors?.get(memberPowerTag.color)
- : undefined;
- const tagIconSrc = memberPowerTag?.icon
- ? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon)
- : undefined;
-
- const usernameColor = legacyUsernameColor ? colorMXID(event.sender) : tagColor;
-
- return (
-
-
-
- }
- />
-
-
- }
- >
-
-
-
-
-
- {displayName}
-
-
- {tagIconSrc && }
-
-
-
-
-
- Open
-
-
-
- {replyEventId && (
-
- )}
- {renderMatrixEvent(event.type, false, event, displayName, getContent)}
-
-
- );
- })}
+ {items.map((item) => (
+
+ ))}
);
diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx
index ae46d2d..69b7400 100644
--- a/src/app/features/room/RoomInput.tsx
+++ b/src/app/features/room/RoomInput.tsx
@@ -117,6 +117,8 @@ import { useTheme } from '../../hooks/useTheme';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import { useComposingCheck } from '../../hooks/useComposingCheck';
+import { useOtherUserColor } from '../../hooks/useUserColor';
+import { getMemberAvatarMxc } from '../../utils/room';
interface RoomInputProps {
editor: Editor;
@@ -142,6 +144,7 @@ export const RoomInput = forwardRef(
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId));
const replyUserID = replyDraft?.userId;
+ const replyAvatarMxc = replyUserID ? getMemberAvatarMxc(room, replyUserID) : undefined;
const powerLevelTags = usePowerLevelTags(room, powerLevels);
const creatorsTag = useRoomCreatorsTag();
@@ -153,12 +156,16 @@ export const RoomInput = forwardRef(
powerLevelTags
);
+ // Get custom user color from avatar metadata
+ const replyCustomColor = useOtherUserColor(replyUserID ?? '', replyAvatarMxc);
+
const replyPowerTag = replyUserID ? getMemberPowerTag(replyUserID) : undefined;
const replyPowerColor = replyPowerTag?.color
? accessibleTagColors.get(replyPowerTag.color)
: undefined;
+ // Priority: custom user color > legacy/direct colorMXID > tag color
const replyUsernameColor =
- legacyUsernameColor || direct ? colorMXID(replyUserID ?? '') : replyPowerColor;
+ replyCustomColor ?? (legacyUsernameColor || direct ? colorMXID(replyUserID ?? '') : replyPowerColor);
const [uploadBoard, setUploadBoard] = useState(true);
const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(roomId));
diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx
index 443b6a3..45e4f56 100644
--- a/src/app/features/room/message/Message.tsx
+++ b/src/app/features/room/message/Message.tsx
@@ -80,6 +80,7 @@ import { PowerIcon } from '../../../components/power';
import colorMXID from '../../../../util/colorMXID';
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
import { Presence, useUserPresence } from '../../../hooks/useUserPresence';
+import { useOtherUserColor } from '../../../hooks/useUserColor';
export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
@@ -736,6 +737,9 @@ export const Message = as<'div', MessageProps>(
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
const senderAvatarMxc = getMemberAvatarMxc(room, senderId);
+ // Get custom user color from avatar metadata (takes priority)
+ const customUserColor = useOtherUserColor(senderId, senderAvatarMxc);
+
const tagColor = memberPowerTag?.color
? accessibleTagColors?.get(memberPowerTag.color)
: undefined;
@@ -743,7 +747,8 @@ export const Message = as<'div', MessageProps>(
? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon)
: undefined;
- const usernameColor = legacyUsernameColor ? colorMXID(senderId) : tagColor;
+ // Priority: custom user color > tag color (non-legacy) > colorMXID (legacy)
+ const usernameColor = customUserColor ?? (legacyUsernameColor ? colorMXID(senderId) : tagColor);
const headerJSX = !collapse && (
pinnedEvent.getContent()) as GetContentCallback;
+ // Get custom user color from avatar metadata
+ const customUserColor = useOtherUserColor(sender, senderAvatarMxc);
+
const memberPowerTag = getMemberPowerTag(sender);
const tagColor = memberPowerTag?.color
? accessibleTagColors?.get(memberPowerTag.color)
@@ -187,7 +191,8 @@ function PinnedMessage({
? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon)
: undefined;
- const usernameColor = legacyUsernameColor ? colorMXID(sender) : tagColor;
+ // Priority: custom user color > tag color (non-legacy) > colorMXID (legacy)
+ const usernameColor = customUserColor ?? (legacyUsernameColor ? colorMXID(sender) : tagColor);
return (
;
@@ -427,10 +431,131 @@ function Appearance() {
} />
+
+
);
}
+/**
+ * Color picker component for user's profile color
+ * Stored in avatar PNG metadata, visible to other Paarrot users
+ */
+function UserColorPicker() {
+ const [userColor, setUserColor, loading] = useUserColor();
+ const [localColor, setLocalColor] = useState(userColor ?? '#3b82f6');
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState();
+
+ // Update local color when avatar color changes
+ useEffect(() => {
+ if (userColor) {
+ setLocalColor(userColor);
+ }
+ }, [userColor]);
+
+ const handleColorChange = (newColor: string) => {
+ setLocalColor(newColor);
+ setError(undefined);
+ };
+
+ const handleSaveColor = async () => {
+ setSaving(true);
+ setError(undefined);
+ try {
+ await setUserColor(localColor);
+ } catch (e) {
+ setError('Failed to save. Make sure you have an avatar set.');
+ }
+ setSaving(false);
+ };
+
+ const handleRemoveColor = async () => {
+ setSaving(true);
+ setError(undefined);
+ try {
+ await setUserColor(undefined);
+ setLocalColor('#3b82f6');
+ } catch (e) {
+ setError('Failed to remove color.');
+ }
+ setSaving(false);
+ };
+
+ return (
+
+
+ {loading ? (
+ Loading...
+ ) : (
+
+
+
+ {
+ setLocalColor(e.target.value);
+ setError(undefined);
+ }}
+ />
+
+
+ {error && (
+ {error}
+ )}
+
+ }
+ onRemove={userColor ? handleRemoveColor : undefined}
+ >
+ {(onOpen) => (
+
+ }
+ >
+ {userColor ? 'Change' : 'Choose'}
+
+ )}
+
+ )}
+
+ }
+ />
+
+ );
+}
+
type DateHintProps = {
hasChanges: boolean;
handleReset: () => void;
diff --git a/src/app/hooks/useUserColor.ts b/src/app/hooks/useUserColor.ts
new file mode 100644
index 0000000..e44d0ec
--- /dev/null
+++ b/src/app/hooks/useUserColor.ts
@@ -0,0 +1,201 @@
+import { useCallback, useEffect, useState } from 'react';
+import { MatrixClient } from 'matrix-js-sdk';
+import { useMatrixClient } from './useMatrixClient';
+import { useMediaAuthentication } from './useMediaAuthentication';
+import { mxcUrlToHttp } from '../utils/matrix';
+import { embedColorInPNG, extractColorFromPNG, fetchAndExtractColor, removeColorFromPNG } from '../utils/pngMetadata';
+
+/**
+ * Fetches the user's current avatar as raw PNG data
+ */
+async function fetchAvatarData(
+ mx: MatrixClient,
+ avatarMxc: string,
+ useAuthentication: boolean
+): Promise {
+ const url = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
+ if (!url) return null;
+
+ try {
+ const response = await fetch(url);
+ if (!response.ok) return null;
+ return await response.arrayBuffer();
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Hook to manage the user's chosen profile color, stored in avatar PNG metadata
+ * The color is embedded in the PNG tEXt chunk and syncs via the avatar
+ * @returns The current color, a setter function, and loading state
+ */
+export function useUserColor(): [
+ string | undefined,
+ (color: string | undefined) => Promise,
+ boolean
+] {
+ const mx = useMatrixClient();
+ const useAuthentication = useMediaAuthentication();
+ const [color, setColor] = useState();
+ const [loading, setLoading] = useState(true);
+
+ // Extract color from current avatar on mount and when profile changes
+ useEffect(() => {
+ const loadColor = async () => {
+ setLoading(true);
+ try {
+ const userId = mx.getUserId();
+ if (!userId) {
+ setLoading(false);
+ return;
+ }
+
+ const profile = await mx.getProfileInfo(userId);
+ const avatarUrl = profile.avatar_url;
+
+ if (!avatarUrl) {
+ setColor(undefined);
+ setLoading(false);
+ return;
+ }
+
+ const httpUrl = mxcUrlToHttp(mx, avatarUrl, useAuthentication);
+ if (!httpUrl) {
+ setColor(undefined);
+ setLoading(false);
+ return;
+ }
+
+ const extractedColor = await fetchAndExtractColor(httpUrl);
+ setColor(extractedColor);
+ } catch (e) {
+ console.error('Failed to load user color from avatar:', e);
+ setColor(undefined);
+ }
+ setLoading(false);
+ };
+
+ loadColor();
+ }, [mx, useAuthentication]);
+
+ const updateColor = useCallback(async (newColor: string | undefined) => {
+ const userId = mx.getUserId();
+ if (!userId) return;
+
+ try {
+ const profile = await mx.getProfileInfo(userId);
+ const avatarUrl = profile.avatar_url;
+
+ if (!avatarUrl) {
+ // No avatar to embed color in
+ console.warn('Cannot set color: no avatar image set');
+ throw new Error('No avatar set');
+ }
+
+ // Fetch current avatar
+ const avatarData = await fetchAvatarData(mx, avatarUrl, useAuthentication);
+ if (!avatarData) {
+ console.error('Failed to fetch current avatar');
+ throw new Error('Failed to fetch avatar');
+ }
+
+ console.log('Original avatar size:', avatarData.byteLength, 'bytes');
+
+ // Modify PNG metadata
+ let newAvatarData: Uint8Array | null;
+ if (newColor) {
+ newAvatarData = embedColorInPNG(avatarData, newColor);
+ } else {
+ newAvatarData = removeColorFromPNG(avatarData);
+ }
+
+ if (!newAvatarData) {
+ console.error('Failed to modify avatar PNG metadata - not a valid PNG?');
+ throw new Error('Failed to modify PNG metadata');
+ }
+
+ console.log('Modified avatar size:', newAvatarData.byteLength, 'bytes');
+
+ // Verify the color was embedded correctly before uploading
+ const verifyColor = extractColorFromPNG(newAvatarData);
+ console.log('Verification - embedded color:', verifyColor);
+
+ if (newColor && verifyColor !== newColor) {
+ console.error('Color verification failed! Expected:', newColor, 'Got:', verifyColor);
+ throw new Error('Color embedding verification failed');
+ }
+
+ // Upload modified avatar
+ const blob = new Blob([newAvatarData], { type: 'image/png' });
+ const uploadResponse = await mx.uploadContent(blob, {
+ name: 'avatar.png',
+ type: 'image/png',
+ });
+
+ console.log('Uploaded new avatar:', uploadResponse.content_uri);
+
+ // Update profile with new avatar
+ await mx.setAvatarUrl(uploadResponse.content_uri);
+
+ setColor(newColor);
+ console.log('User color updated in avatar:', newColor);
+ } catch (e) {
+ console.error('Failed to update user color in avatar:', e);
+ throw e;
+ }
+ }, [mx, useAuthentication]);
+
+ return [color, updateColor, loading];
+}
+
+// Cache for user colors to avoid refetching for each message
+const userColorCache = new Map();
+const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
+
+/**
+ * Hook to get another user's chosen profile color from their avatar metadata
+ * @param userId - The user ID to get color for
+ * @param avatarMxc - The user's avatar MXC URL
+ * @returns The user's chosen color or undefined
+ */
+export function useOtherUserColor(userId: string, avatarMxc: string | undefined): string | undefined {
+ const mx = useMatrixClient();
+ const useAuthentication = useMediaAuthentication();
+ const [color, setColor] = useState();
+
+ useEffect(() => {
+ if (!avatarMxc) {
+ setColor(undefined);
+ return;
+ }
+
+ // Check cache first
+ const cacheKey = `${userId}:${avatarMxc}`;
+ const cached = userColorCache.get(cacheKey);
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
+ setColor(cached.color);
+ return;
+ }
+
+ const loadColor = async () => {
+ const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
+ if (!httpUrl) {
+ setColor(undefined);
+ return;
+ }
+
+ const extractedColor = await fetchAndExtractColor(httpUrl);
+
+ // Cache the result
+ userColorCache.set(cacheKey, { color: extractedColor, timestamp: Date.now() });
+
+ setColor(extractedColor);
+ };
+
+ loadColor();
+ }, [mx, useAuthentication, avatarMxc, userId]);
+
+ return color;
+}
+
diff --git a/src/app/pages/client/inbox/Notifications.tsx b/src/app/pages/client/inbox/Notifications.tsx
index 4cc94d9..509fbcf 100644
--- a/src/app/pages/client/inbox/Notifications.tsx
+++ b/src/app/pages/client/inbox/Notifications.tsx
@@ -97,6 +97,7 @@ import {
} from '../../../hooks/useMemberPowerTag';
import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
+import { useOtherUserColor } from '../../../hooks/useUserColor';
type RoomNotificationsGroup = {
roomId: string;
@@ -200,6 +201,141 @@ const useNotificationTimeline = (
return [notificationTimeline, loadTimeline, silentReloadTimeline];
};
+/**
+ * Props for NotificationItem component
+ */
+type NotificationItemProps = {
+ room: Room;
+ notification: INotification;
+ getMemberPowerTag: ReturnType;
+ accessibleTagColors: Map | undefined;
+ renderMatrixEvent: ReturnType>;
+ handleOpenClick: MouseEventHandler;
+ legacyUsernameColor?: boolean;
+ hour24Clock: boolean;
+ dateFormatString: string;
+};
+
+/**
+ * Component to render a single notification item with proper hook usage
+ */
+function NotificationItem({
+ room,
+ notification,
+ getMemberPowerTag,
+ accessibleTagColors,
+ renderMatrixEvent,
+ handleOpenClick,
+ legacyUsernameColor,
+ hour24Clock,
+ dateFormatString,
+}: NotificationItemProps) {
+ const mx = useMatrixClient();
+ const useAuthentication = useMediaAuthentication();
+ const { event } = notification;
+
+ const displayName =
+ getMemberDisplayName(room, event.sender) ??
+ getMxIdLocalPart(event.sender) ??
+ event.sender;
+ const senderAvatarMxc = getMemberAvatarMxc(room, event.sender);
+ const getContent = (() => event.content) as GetContentCallback;
+
+ const relation = event.content['m.relates_to'];
+ const replyEventId = relation?.['m.in_reply_to']?.event_id;
+ const threadRootId =
+ relation?.rel_type === RelationType.Thread ? relation.event_id : undefined;
+
+ // Get custom user color from avatar metadata
+ const customUserColor = useOtherUserColor(event.sender, senderAvatarMxc);
+
+ const memberPowerTag = getMemberPowerTag(event.sender);
+ const tagColor = memberPowerTag?.color
+ ? accessibleTagColors?.get(memberPowerTag.color)
+ : undefined;
+ const tagIconSrc = memberPowerTag?.icon
+ ? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon)
+ : undefined;
+
+ // Priority: custom user color > tag color (non-legacy) > colorMXID (legacy)
+ const usernameColor = customUserColor ?? (legacyUsernameColor ? colorMXID(event.sender) : tagColor);
+
+ return (
+
+
+
+ }
+ />
+
+
+ }
+ >
+
+
+
+
+
+ {displayName}
+
+
+ {tagIconSrc && }
+
+
+
+
+
+ Open
+
+
+
+ {replyEventId && (
+
+ )}
+ {renderMatrixEvent(event.type, false, event, displayName, getContent)}
+
+
+ );
+}
+
type RoomNotificationsGroupProps = {
room: Room;
notifications: INotification[];
@@ -439,106 +575,20 @@ function RoomNotificationsGroupComp({
- {notifications.map((notification) => {
- const { event } = notification;
-
- const displayName =
- getMemberDisplayName(room, event.sender) ??
- getMxIdLocalPart(event.sender) ??
- event.sender;
- const senderAvatarMxc = getMemberAvatarMxc(room, event.sender);
- const getContent = (() => event.content) as GetContentCallback;
-
- const relation = event.content['m.relates_to'];
- const replyEventId = relation?.['m.in_reply_to']?.event_id;
- const threadRootId =
- relation?.rel_type === RelationType.Thread ? relation.event_id : undefined;
-
- const memberPowerTag = getMemberPowerTag(event.sender);
- const tagColor = memberPowerTag?.color
- ? accessibleTagColors?.get(memberPowerTag.color)
- : undefined;
- const tagIconSrc = memberPowerTag?.icon
- ? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon)
- : undefined;
-
- const usernameColor = legacyUsernameColor ? colorMXID(event.sender) : tagColor;
-
- return (
-
-
-
- }
- />
-
-
- }
- >
-
-
-
-
-
- {displayName}
-
-
- {tagIconSrc && }
-
-
-
-
-
- Open
-
-
-
- {replyEventId && (
-
- )}
- {renderMatrixEvent(event.type, false, event, displayName, getContent)}
-
-
- );
- })}
+ {notifications.map((notification) => (
+
+ ))}
);
diff --git a/src/app/utils/pngMetadata.ts b/src/app/utils/pngMetadata.ts
new file mode 100644
index 0000000..cb3ee35
--- /dev/null
+++ b/src/app/utils/pngMetadata.ts
@@ -0,0 +1,306 @@
+/**
+ * Utilities for reading and writing PNG metadata (tEXt chunks)
+ * Used to embed user color in avatar images
+ */
+
+/* eslint-disable no-bitwise */
+/* eslint-disable no-plusplus */
+/* eslint-disable no-restricted-syntax */
+/* eslint-disable no-console */
+
+/** PNG signature bytes */
+const PNG_SIGNATURE = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
+
+/** Key used to store user color in PNG metadata */
+export const PAARROT_COLOR_KEY = 'paarrot:color';
+
+let crc32Table: Uint32Array | null = null;
+function getCRC32Table(): Uint32Array {
+ if (crc32Table) return crc32Table;
+
+ crc32Table = new Uint32Array(256);
+ for (let i = 0; i < 256; i++) {
+ let c = i;
+ for (let j = 0; j < 8; j++) {
+ c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
+ }
+ crc32Table[i] = c;
+ }
+ return crc32Table;
+}
+
+/**
+ * Calculate CRC32 for a chunk
+ */
+function crc32(data: Uint8Array): number {
+ let crc = 0xffffffff;
+ const table = getCRC32Table();
+
+ for (let i = 0; i < data.length; i++) {
+ crc = (crc >>> 8) ^ table[(crc ^ data[i]) & 0xff];
+ }
+
+ return (crc ^ 0xffffffff) >>> 0;
+}
+
+/**
+ * Read a 32-bit big-endian integer from a buffer
+ */
+function readUint32BE(data: Uint8Array, offset: number): number {
+ return (data[offset] << 24) | (data[offset + 1] << 16) | (data[offset + 2] << 8) | data[offset + 3];
+}
+
+/**
+ * Write a 32-bit big-endian integer to a buffer
+ */
+function writeUint32BE(value: number): Uint8Array {
+ return new Uint8Array([
+ (value >>> 24) & 0xff,
+ (value >>> 16) & 0xff,
+ (value >>> 8) & 0xff,
+ value & 0xff,
+ ]);
+}
+
+/**
+ * Create a tEXt chunk with the given key and value
+ */
+function createTextChunk(key: string, value: string): Uint8Array {
+ const keyBytes = new TextEncoder().encode(key);
+ const valueBytes = new TextEncoder().encode(value);
+
+ // tEXt chunk data: key + null byte + value
+ const chunkData = new Uint8Array(keyBytes.length + 1 + valueBytes.length);
+ chunkData.set(keyBytes, 0);
+ chunkData[keyBytes.length] = 0; // null separator
+ chunkData.set(valueBytes, keyBytes.length + 1);
+
+ const chunkType = new TextEncoder().encode('tEXt');
+
+ // Calculate CRC over type + data
+ const crcData = new Uint8Array(4 + chunkData.length);
+ crcData.set(chunkType, 0);
+ crcData.set(chunkData, 4);
+ const crc = crc32(crcData);
+
+ // Full chunk: length (4) + type (4) + data + crc (4)
+ const chunk = new Uint8Array(4 + 4 + chunkData.length + 4);
+ chunk.set(writeUint32BE(chunkData.length), 0);
+ chunk.set(chunkType, 4);
+ chunk.set(chunkData, 8);
+ chunk.set(writeUint32BE(crc), 8 + chunkData.length);
+
+ return chunk;
+}
+
+/**
+ * Parse PNG chunks from image data
+ */
+function parseChunks(data: Uint8Array): Array<{ type: string; data: Uint8Array; offset: number; length: number }> {
+ const chunks: Array<{ type: string; data: Uint8Array; offset: number; length: number }> = [];
+ let offset = 8; // Skip PNG signature
+
+ while (offset < data.length) {
+ const length = readUint32BE(data, offset);
+ const type = new TextDecoder().decode(data.slice(offset + 4, offset + 8));
+ const chunkData = data.slice(offset + 8, offset + 8 + length);
+
+ chunks.push({
+ type,
+ data: chunkData,
+ offset,
+ length: 12 + length, // length field + type + data + crc
+ });
+
+ offset += 12 + length;
+
+ if (type === 'IEND') break;
+ }
+
+ return chunks;
+}
+
+/**
+ * Read a tEXt chunk value
+ */
+function parseTextChunk(data: Uint8Array): { key: string; value: string } | null {
+ const nullIndex = data.indexOf(0);
+ if (nullIndex === -1) return null;
+
+ const key = new TextDecoder().decode(data.slice(0, nullIndex));
+ const value = new TextDecoder().decode(data.slice(nullIndex + 1));
+
+ return { key, value };
+}
+
+/**
+ * Extract paarrot color from PNG image data
+ * @param imageData - Raw PNG image data as ArrayBuffer or Uint8Array
+ * @returns The color string if found, or undefined
+ */
+export function extractColorFromPNG(imageData: ArrayBuffer | Uint8Array): string | undefined {
+ const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
+
+ console.log('[extractColorFromPNG] Input size:', data.length, 'bytes');
+
+ // Verify PNG signature
+ for (let i = 0; i < 8; i++) {
+ if (data[i] !== PNG_SIGNATURE[i]) {
+ console.error('[extractColorFromPNG] Not a PNG! Byte', i, 'is', data[i], 'expected', PNG_SIGNATURE[i]);
+ return undefined; // Not a PNG
+ }
+ }
+
+ const chunks = parseChunks(data);
+ console.log('[extractColorFromPNG] Found', chunks.length, 'chunks:', chunks.map(c => c.type).join(', '));
+
+ for (const chunk of chunks) {
+ if (chunk.type === 'tEXt') {
+ const text = parseTextChunk(chunk.data);
+ console.log('[extractColorFromPNG] tEXt chunk found, key:', text?.key);
+ if (text && text.key === PAARROT_COLOR_KEY) {
+ console.log('[extractColorFromPNG] Found color:', text.value);
+ return text.value;
+ }
+ }
+ }
+
+ console.log('[extractColorFromPNG] No color found');
+ return undefined;
+}
+
+/**
+ * Embed paarrot color into PNG image data
+ * @param imageData - Raw PNG image data as ArrayBuffer or Uint8Array
+ * @param color - The color string to embed (e.g., '#ff5500')
+ * @returns New PNG data with embedded color, or null if not a valid PNG
+ */
+export function embedColorInPNG(imageData: ArrayBuffer | Uint8Array, color: string): Uint8Array | null {
+ const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
+
+ console.log('[embedColorInPNG] Input size:', data.length, 'bytes');
+ console.log('[embedColorInPNG] First 8 bytes:', Array.from(data.slice(0, 8)));
+ console.log('[embedColorInPNG] PNG signature expected:', Array.from(PNG_SIGNATURE));
+
+ // Verify PNG signature
+ for (let i = 0; i < 8; i++) {
+ if (data[i] !== PNG_SIGNATURE[i]) {
+ console.error('[embedColorInPNG] Not a PNG! Byte', i, 'is', data[i], 'expected', PNG_SIGNATURE[i]);
+ return null; // Not a PNG
+ }
+ }
+
+ console.log('[embedColorInPNG] PNG signature verified OK');
+
+ const chunks = parseChunks(data);
+ console.log('[embedColorInPNG] Found', chunks.length, 'chunks:', chunks.map(c => c.type).join(', '));
+
+ // Find IHDR chunk (always first) and any existing paarrot tEXt chunk
+ let insertOffset = 8; // After signature
+ let existingPaarrotChunk: { offset: number; length: number } | null = null;
+
+ for (const chunk of chunks) {
+ if (chunk.type === 'IHDR') {
+ insertOffset = chunk.offset + chunk.length; // Insert after IHDR
+ } else if (chunk.type === 'tEXt') {
+ const text = parseTextChunk(chunk.data);
+ if (text && text.key === PAARROT_COLOR_KEY) {
+ existingPaarrotChunk = { offset: chunk.offset, length: chunk.length };
+ }
+ }
+ }
+
+ // Create new tEXt chunk with color
+ const colorChunk = createTextChunk(PAARROT_COLOR_KEY, color);
+ console.log('[embedColorInPNG] Created color chunk, size:', colorChunk.length, 'bytes');
+ console.log('[embedColorInPNG] Color being embedded:', color);
+
+ // Build new PNG data
+ let newData: Uint8Array;
+
+ if (existingPaarrotChunk) {
+ // Replace existing chunk
+ console.log('[embedColorInPNG] Replacing existing chunk at offset', existingPaarrotChunk.offset);
+ newData = new Uint8Array(data.length - existingPaarrotChunk.length + colorChunk.length);
+ newData.set(data.slice(0, existingPaarrotChunk.offset), 0);
+ newData.set(colorChunk, existingPaarrotChunk.offset);
+ newData.set(
+ data.slice(existingPaarrotChunk.offset + existingPaarrotChunk.length),
+ existingPaarrotChunk.offset + colorChunk.length
+ );
+ } else {
+ // Insert new chunk after IHDR
+ console.log('[embedColorInPNG] Inserting new chunk at offset', insertOffset);
+ newData = new Uint8Array(data.length + colorChunk.length);
+ newData.set(data.slice(0, insertOffset), 0);
+ newData.set(colorChunk, insertOffset);
+ newData.set(data.slice(insertOffset), insertOffset + colorChunk.length);
+ }
+
+ console.log('[embedColorInPNG] Output size:', newData.length, 'bytes');
+
+ return newData;
+}
+
+/**
+ * Remove paarrot color from PNG image data
+ * @param imageData - Raw PNG image data as ArrayBuffer or Uint8Array
+ * @returns New PNG data without the color chunk, or null if not a valid PNG
+ */
+export function removeColorFromPNG(imageData: ArrayBuffer | Uint8Array): Uint8Array | null {
+ const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
+
+ // Verify PNG signature
+ for (let i = 0; i < 8; i++) {
+ if (data[i] !== PNG_SIGNATURE[i]) {
+ return null; // Not a PNG
+ }
+ }
+
+ const chunks = parseChunks(data);
+
+ // Find existing paarrot tEXt chunk
+ let existingPaarrotChunk: { offset: number; length: number } | null = null;
+
+ for (const chunk of chunks) {
+ if (chunk.type === 'tEXt') {
+ const text = parseTextChunk(chunk.data);
+ if (text && text.key === PAARROT_COLOR_KEY) {
+ existingPaarrotChunk = { offset: chunk.offset, length: chunk.length };
+ break;
+ }
+ }
+ }
+
+ if (!existingPaarrotChunk) {
+ // No color to remove
+ return data;
+ }
+
+ // Build new PNG data without the color chunk
+ const newData = new Uint8Array(data.length - existingPaarrotChunk.length);
+ newData.set(data.slice(0, existingPaarrotChunk.offset), 0);
+ newData.set(
+ data.slice(existingPaarrotChunk.offset + existingPaarrotChunk.length),
+ existingPaarrotChunk.offset
+ );
+
+ return newData;
+}
+
+/**
+ * Fetch an image and extract the paarrot color from it
+ * @param url - The URL of the image to fetch
+ * @returns The color string if found, or undefined
+ */
+export async function fetchAndExtractColor(url: string): Promise {
+ try {
+ const response = await fetch(url);
+ if (!response.ok) return undefined;
+
+ const arrayBuffer = await response.arrayBuffer();
+ return extractColorFromPNG(arrayBuffer);
+ } catch {
+ return undefined;
+ }
+}