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:
@@ -605,7 +605,20 @@ export function CallOverlay() {
|
||||
<Text size="T300" truncate>
|
||||
{isConnecting ? 'Connecting...' : `In Call - ${room?.name ?? 'Unknown Room'}`}
|
||||
{selectedRoomId === activeCall.roomId && (
|
||||
<span style={{ opacity: 0.7, fontSize: '0.85em' }}> (Current Room)</span>
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
offset={4}
|
||||
tooltip={<Tooltip><Text>Current Room</Text></Tooltip>}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<Icon
|
||||
ref={triggerRef}
|
||||
size="100"
|
||||
src={Icons.Check}
|
||||
style={{ marginLeft: '4px', opacity: 0.7, verticalAlign: 'middle' }}
|
||||
/>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</Text>
|
||||
{selectedRoomId !== activeCall.roomId && (
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<typeof useGetMemberPowerTag>;
|
||||
accessibleTagColors: Map<string, string> | undefined;
|
||||
renderMatrixEvent: ReturnType<typeof useMatrixEventRenderer<[IEventWithRoomId, string, GetContentCallback]>>;
|
||||
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 (
|
||||
<SequenceCard
|
||||
key={event.event_id}
|
||||
style={{ padding: config.space.S400 }}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
>
|
||||
<ModernLayout
|
||||
before={
|
||||
<AvatarBase>
|
||||
<Avatar size="300">
|
||||
<UserAvatar
|
||||
userId={event.sender}
|
||||
src={
|
||||
senderAvatarMxc
|
||||
? mxcUrlToHttp(
|
||||
mx,
|
||||
senderAvatarMxc,
|
||||
useAuthentication,
|
||||
48,
|
||||
48,
|
||||
'crop'
|
||||
) ?? undefined
|
||||
: undefined
|
||||
}
|
||||
alt={displayName}
|
||||
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
</AvatarBase>
|
||||
}
|
||||
>
|
||||
<Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
|
||||
<Box gap="200" alignItems="Baseline">
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Username style={{ color: usernameColor }}>
|
||||
<Text as="span" truncate>
|
||||
<UsernameBold>{displayName}</UsernameBold>
|
||||
</Text>
|
||||
</Username>
|
||||
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
|
||||
</Box>
|
||||
<Time
|
||||
ts={event.origin_server_ts}
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
/>
|
||||
</Box>
|
||||
<Box shrink="No" gap="200" alignItems="Center">
|
||||
<Chip
|
||||
data-event-id={mainEventId}
|
||||
onClick={handleOpenClick}
|
||||
variant="Secondary"
|
||||
radii="400"
|
||||
>
|
||||
<Text size="T200">Open</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
</Box>
|
||||
{replyEventId && (
|
||||
<Reply
|
||||
room={room}
|
||||
replyEventId={replyEventId}
|
||||
threadRootId={threadRootId}
|
||||
onClick={handleOpenClick}
|
||||
getMemberPowerTag={getMemberPowerTag}
|
||||
accessibleTagColors={accessibleTagColors}
|
||||
legacyUsernameColor={legacyUsernameColor}
|
||||
/>
|
||||
)}
|
||||
{renderMatrixEvent(event.type, false, event, displayName, getContent)}
|
||||
</ModernLayout>
|
||||
</SequenceCard>
|
||||
);
|
||||
}
|
||||
|
||||
type SearchResultGroupProps = {
|
||||
room: Room;
|
||||
@@ -213,111 +354,20 @@ export function SearchResultGroup({
|
||||
</Box>
|
||||
</Header>
|
||||
<Box direction="Column" gap="100">
|
||||
{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 (
|
||||
<SequenceCard
|
||||
key={event.event_id}
|
||||
style={{ padding: config.space.S400 }}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
>
|
||||
<ModernLayout
|
||||
before={
|
||||
<AvatarBase>
|
||||
<Avatar size="300">
|
||||
<UserAvatar
|
||||
userId={event.sender}
|
||||
src={
|
||||
senderAvatarMxc
|
||||
? mxcUrlToHttp(
|
||||
mx,
|
||||
senderAvatarMxc,
|
||||
useAuthentication,
|
||||
48,
|
||||
48,
|
||||
'crop'
|
||||
) ?? undefined
|
||||
: undefined
|
||||
}
|
||||
alt={displayName}
|
||||
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
</AvatarBase>
|
||||
}
|
||||
>
|
||||
<Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
|
||||
<Box gap="200" alignItems="Baseline">
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Username style={{ color: usernameColor }}>
|
||||
<Text as="span" truncate>
|
||||
<UsernameBold>{displayName}</UsernameBold>
|
||||
</Text>
|
||||
</Username>
|
||||
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
|
||||
</Box>
|
||||
<Time
|
||||
ts={event.origin_server_ts}
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
/>
|
||||
</Box>
|
||||
<Box shrink="No" gap="200" alignItems="Center">
|
||||
<Chip
|
||||
data-event-id={mainEventId}
|
||||
onClick={handleOpenClick}
|
||||
variant="Secondary"
|
||||
radii="400"
|
||||
>
|
||||
<Text size="T200">Open</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
</Box>
|
||||
{replyEventId && (
|
||||
<Reply
|
||||
room={room}
|
||||
replyEventId={replyEventId}
|
||||
threadRootId={threadRootId}
|
||||
onClick={handleOpenClick}
|
||||
getMemberPowerTag={getMemberPowerTag}
|
||||
accessibleTagColors={accessibleTagColors}
|
||||
legacyUsernameColor={legacyUsernameColor}
|
||||
/>
|
||||
)}
|
||||
{renderMatrixEvent(event.type, false, event, displayName, getContent)}
|
||||
</ModernLayout>
|
||||
</SequenceCard>
|
||||
);
|
||||
})}
|
||||
{items.map((item) => (
|
||||
<SearchResultItem
|
||||
key={item.event.event_id}
|
||||
room={room}
|
||||
item={item}
|
||||
getMemberPowerTag={getMemberPowerTag}
|
||||
accessibleTagColors={accessibleTagColors}
|
||||
renderMatrixEvent={renderMatrixEvent}
|
||||
handleOpenClick={handleOpenClick}
|
||||
legacyUsernameColor={legacyUsernameColor}
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -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<HTMLDivElement, RoomInputProps>(
|
||||
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<HTMLDivElement, RoomInputProps>(
|
||||
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));
|
||||
|
||||
@@ -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 && (
|
||||
<Box
|
||||
|
||||
@@ -86,6 +86,7 @@ import {
|
||||
useGetMemberPowerTag,
|
||||
} from '../../../hooks/useMemberPowerTag';
|
||||
import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
|
||||
import { useOtherUserColor } from '../../../hooks/useUserColor';
|
||||
|
||||
type PinnedMessageProps = {
|
||||
room: Room;
|
||||
@@ -179,6 +180,9 @@ function PinnedMessage({
|
||||
const senderAvatarMxc = getMemberAvatarMxc(room, sender);
|
||||
const getContent = (() => 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 (
|
||||
<ModernLayout
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
color,
|
||||
config,
|
||||
Header,
|
||||
Icon,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
Text,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { HexColorPicker } from 'react-colorful';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
@@ -50,6 +52,8 @@ import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
|
||||
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
|
||||
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { HexColorPickerPopOut } from '../../../components/HexColorPickerPopOut';
|
||||
import { useUserColor } from '../../../hooks/useUserColor';
|
||||
|
||||
type ThemeSelectorProps = {
|
||||
themeNames: Record<string, string>;
|
||||
@@ -427,10 +431,131 @@ function Appearance() {
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile title="Page Zoom" after={<PageZoomInput />} />
|
||||
</SequenceCard>
|
||||
|
||||
<UserColorPicker />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string>();
|
||||
|
||||
// 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 (
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Profile Color"
|
||||
description="Embedded in your avatar. Other Paarrot users will see this color."
|
||||
after={
|
||||
<Box alignItems="Center" gap="200">
|
||||
{loading ? (
|
||||
<Text size="T300" style={{ opacity: 0.7 }}>Loading...</Text>
|
||||
) : (
|
||||
<HexColorPickerPopOut
|
||||
picker={
|
||||
<Box direction="Column" gap="200">
|
||||
<HexColorPicker color={localColor} onChange={handleColorChange} />
|
||||
<Box gap="100" alignItems="Center">
|
||||
<Input
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
style={{ width: toRem(100) }}
|
||||
value={localColor}
|
||||
onChange={(e) => {
|
||||
setLocalColor(e.target.value);
|
||||
setError(undefined);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Primary"
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
onClick={handleSaveColor}
|
||||
disabled={saving}
|
||||
>
|
||||
<Text size="B300">{saving ? 'Saving...' : 'Save'}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
{error && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>{error}</Text>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
onRemove={userColor ? handleRemoveColor : undefined}
|
||||
>
|
||||
{(onOpen) => (
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
onClick={onOpen}
|
||||
disabled={saving}
|
||||
before={
|
||||
<Box
|
||||
style={{
|
||||
width: toRem(16),
|
||||
height: toRem(16),
|
||||
borderRadius: toRem(4),
|
||||
backgroundColor: userColor ?? localColor,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Text size="T300">{userColor ? 'Change' : 'Choose'}</Text>
|
||||
</Button>
|
||||
)}
|
||||
</HexColorPickerPopOut>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
);
|
||||
}
|
||||
|
||||
type DateHintProps = {
|
||||
hasChanges: boolean;
|
||||
handleReset: () => void;
|
||||
|
||||
Reference in New Issue
Block a user