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

@@ -2,7 +2,7 @@ import { Box, Icon, Icons, Text, as, color, toRem } from 'folds';
import { EventTimelineSet, Room } from 'matrix-js-sdk'; import { EventTimelineSet, Room } from 'matrix-js-sdk';
import React, { MouseEventHandler, ReactNode, useCallback, useMemo } from 'react'; import React, { MouseEventHandler, ReactNode, useCallback, useMemo } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { getMemberDisplayName, trimReplyFromBody } from '../../utils/room'; import { getMemberAvatarMxc, getMemberDisplayName, trimReplyFromBody } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix'; import { getMxIdLocalPart } from '../../utils/matrix';
import { LinePlaceholder } from './placeholder'; import { LinePlaceholder } from './placeholder';
import { randomNumberBetween } from '../../utils/common'; import { randomNumberBetween } from '../../utils/common';
@@ -12,6 +12,7 @@ import { scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
import { useRoomEvent } from '../../hooks/useRoomEvent'; import { useRoomEvent } from '../../hooks/useRoomEvent';
import colorMXID from '../../../util/colorMXID'; import colorMXID from '../../../util/colorMXID';
import { GetMemberPowerTag } from '../../hooks/useMemberPowerTag'; import { GetMemberPowerTag } from '../../hooks/useMemberPowerTag';
import { useOtherUserColor } from '../../hooks/useUserColor';
type ReplyLayoutProps = { type ReplyLayoutProps = {
userColor?: string; userColor?: string;
@@ -86,10 +87,13 @@ export const Reply = as<'div', ReplyProps>(
const { body } = replyEvent?.getContent() ?? {}; const { body } = replyEvent?.getContent() ?? {};
const sender = replyEvent?.getSender(); const sender = replyEvent?.getSender();
const senderAvatarMxc = sender ? getMemberAvatarMxc(room, sender) : undefined;
const customUserColor = useOtherUserColor(sender ?? '', senderAvatarMxc);
const powerTag = sender ? getMemberPowerTag?.(sender) : undefined; const powerTag = sender ? getMemberPowerTag?.(sender) : undefined;
const tagColor = powerTag?.color ? accessibleTagColors?.get(powerTag.color) : 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() ? ( const fallbackBody = replyEvent?.isRedacted() ? (
<MessageDeletedContent /> <MessageDeletedContent />

View File

@@ -605,7 +605,20 @@ export function CallOverlay() {
<Text size="T300" truncate> <Text size="T300" truncate>
{isConnecting ? 'Connecting...' : `In Call - ${room?.name ?? 'Unknown Room'}`} {isConnecting ? 'Connecting...' : `In Call - ${room?.name ?? 'Unknown Room'}`}
{selectedRoomId === activeCall.roomId && ( {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> </Text>
{selectedRoomId !== activeCall.roomId && ( {selectedRoomId !== activeCall.roomId && (

View File

@@ -31,6 +31,16 @@ type CallEventListener = (event: CallEvent) => void;
/** The Matrix state event type for call membership (MSC3401) */ /** The Matrix state event type for call membership (MSC3401) */
const CALL_MEMBER_EVENT_TYPE = 'org.matrix.msc3401.call.member'; 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 * Service for managing Matrix RTC calls via LiveKit
* Handles LiveKit connections, media streams, call state, and Matrix room signaling * Handles LiveKit connections, media streams, call state, and Matrix room signaling
@@ -48,6 +58,12 @@ export class CallService {
private livekitJwt: LiveKitJWTResponse | null = null; 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 * Creates a new CallService instance
* @param config - Service configuration including homeserver and LiveKit URLs * @param config - Service configuration including homeserver and LiveKit URLs
@@ -137,7 +153,7 @@ export class CallService {
{ {
device_id: this.config.deviceId, device_id: this.config.deviceId,
session_id: callId, session_id: callId,
expires_ts: Date.now() + 1000 * 60 * 60, // 1 hour expiry expires_ts: Date.now() + MEMBERSHIP_EXPIRY_MS,
feeds: [ feeds: [
{ purpose: 'usermedia' }, { 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 * Starts a call in the specified room
* @param roomId - The Matrix room ID to start the call in * @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 // Step 5: Send Matrix state event to announce participation
// This state event will be rendered in the timeline as a "joined the call" notification // This state event will be rendered in the timeline as a "joined the call" notification
await this.sendCallMemberEvent(roomId, callId, true); 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'); console.log('Announced call participation to Matrix room');
@@ -651,6 +709,9 @@ export class CallService {
const { roomId } = this.activeCall; const { roomId } = this.activeCall;
this.setState(CallState.Disconnecting); this.setState(CallState.Disconnecting);
// Stop the membership refresh interval
this.stopMembershipRefresh();
// Stop any dialing loop and play departure sound // Stop any dialing loop and play departure sound
getCallSounds().stopDialingLoop(); getCallSounds().stopDialingLoop();
getCallSounds().playSound(CallSoundType.CallDepart); getCallSounds().playSound(CallSoundType.CallDepart);
@@ -674,6 +735,7 @@ export class CallService {
this.activeCall = null; this.activeCall = null;
this.livekitJwt = null; this.livekitJwt = null;
this.currentCallId = null;
this.emit({ this.emit({
type: CallEventType.StateChanged, type: CallEventType.StateChanged,
@@ -712,6 +774,9 @@ export class CallService {
this.livekitRoom.localParticipant.setMetadata(metadata).catch((err) => { this.livekitRoom.localParticipant.setMetadata(metadata).catch((err) => {
console.warn('Failed to set participant metadata:', err); console.warn('Failed to set participant metadata:', err);
}); });
// Play undeafen sound
getCallSounds().playSound(CallSoundType.Undeafen);
} }
this.livekitRoom.localParticipant.setMicrophoneEnabled(!newMuteState); this.livekitRoom.localParticipant.setMicrophoneEnabled(!newMuteState);
@@ -749,16 +814,23 @@ export class CallService {
}); });
}); });
// Also mute/unmute our mic when deafening (silently - no mute sound) // Handle mic muting when deafening
// Only change mic state if it doesn't match deafen state if (newDeafenState) {
if (newDeafenState && !this.activeCall.isMuted) { // Remember if user was muted before deafening
// Deafening - also mute mic this.wasMutedBeforeDeafen = this.activeCall.isMuted;
this.livekitRoom.localParticipant.setMicrophoneEnabled(false);
this.activeCall.isMuted = true; // Deafening - always mute mic
} else if (!newDeafenState && this.activeCall.isMuted) { if (!this.activeCall.isMuted) {
// Undeafening - also unmute mic this.livekitRoom.localParticipant.setMicrophoneEnabled(false);
this.livekitRoom.localParticipant.setMicrophoneEnabled(true); this.activeCall.isMuted = true;
this.activeCall.isMuted = false; }
} 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 // Broadcast deafen state to other participants via metadata
@@ -943,6 +1015,7 @@ export class CallService {
* Cleans up resources * Cleans up resources
*/ */
dispose(): void { dispose(): void {
this.stopMembershipRefresh();
this.endCall(); this.endCall();
this.listeners.clear(); this.listeners.clear();
} }

View File

@@ -51,6 +51,147 @@ import {
} from '../../hooks/useMemberPowerTag'; } from '../../hooks/useMemberPowerTag';
import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; 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 = { type SearchResultGroupProps = {
room: Room; room: Room;
@@ -213,111 +354,20 @@ export function SearchResultGroup({
</Box> </Box>
</Header> </Header>
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
{items.map((item) => { {items.map((item) => (
const { event } = item; <SearchResultItem
key={item.event.event_id}
const displayName = room={room}
getMemberDisplayName(room, event.sender) ?? item={item}
getMxIdLocalPart(event.sender) ?? getMemberPowerTag={getMemberPowerTag}
event.sender; accessibleTagColors={accessibleTagColors}
const senderAvatarMxc = getMemberAvatarMxc(room, event.sender); renderMatrixEvent={renderMatrixEvent}
handleOpenClick={handleOpenClick}
const relation = event.content['m.relates_to']; legacyUsernameColor={legacyUsernameColor}
const mainEventId = hour24Clock={hour24Clock}
relation?.rel_type === RelationType.Replace ? relation.event_id : event.event_id; dateFormatString={dateFormatString}
/>
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>
);
})}
</Box> </Box>
</Box> </Box>
); );

View File

@@ -117,6 +117,8 @@ import { useTheme } from '../../hooks/useTheme';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import { useComposingCheck } from '../../hooks/useComposingCheck'; import { useComposingCheck } from '../../hooks/useComposingCheck';
import { useOtherUserColor } from '../../hooks/useUserColor';
import { getMemberAvatarMxc } from '../../utils/room';
interface RoomInputProps { interface RoomInputProps {
editor: Editor; editor: Editor;
@@ -142,6 +144,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId)); const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId)); const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId));
const replyUserID = replyDraft?.userId; const replyUserID = replyDraft?.userId;
const replyAvatarMxc = replyUserID ? getMemberAvatarMxc(room, replyUserID) : undefined;
const powerLevelTags = usePowerLevelTags(room, powerLevels); const powerLevelTags = usePowerLevelTags(room, powerLevels);
const creatorsTag = useRoomCreatorsTag(); const creatorsTag = useRoomCreatorsTag();
@@ -153,12 +156,16 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
powerLevelTags powerLevelTags
); );
// Get custom user color from avatar metadata
const replyCustomColor = useOtherUserColor(replyUserID ?? '', replyAvatarMxc);
const replyPowerTag = replyUserID ? getMemberPowerTag(replyUserID) : undefined; const replyPowerTag = replyUserID ? getMemberPowerTag(replyUserID) : undefined;
const replyPowerColor = replyPowerTag?.color const replyPowerColor = replyPowerTag?.color
? accessibleTagColors.get(replyPowerTag.color) ? accessibleTagColors.get(replyPowerTag.color)
: undefined; : undefined;
// Priority: custom user color > legacy/direct colorMXID > tag color
const replyUsernameColor = const replyUsernameColor =
legacyUsernameColor || direct ? colorMXID(replyUserID ?? '') : replyPowerColor; replyCustomColor ?? (legacyUsernameColor || direct ? colorMXID(replyUserID ?? '') : replyPowerColor);
const [uploadBoard, setUploadBoard] = useState(true); const [uploadBoard, setUploadBoard] = useState(true);
const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(roomId)); const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(roomId));

View File

@@ -80,6 +80,7 @@ import { PowerIcon } from '../../../components/power';
import colorMXID from '../../../../util/colorMXID'; import colorMXID from '../../../../util/colorMXID';
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag'; import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
import { Presence, useUserPresence } from '../../../hooks/useUserPresence'; import { Presence, useUserPresence } from '../../../hooks/useUserPresence';
import { useOtherUserColor } from '../../../hooks/useUserColor';
export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void; export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
@@ -736,6 +737,9 @@ export const Message = as<'div', MessageProps>(
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
const senderAvatarMxc = getMemberAvatarMxc(room, senderId); const senderAvatarMxc = getMemberAvatarMxc(room, senderId);
// Get custom user color from avatar metadata (takes priority)
const customUserColor = useOtherUserColor(senderId, senderAvatarMxc);
const tagColor = memberPowerTag?.color const tagColor = memberPowerTag?.color
? accessibleTagColors?.get(memberPowerTag.color) ? accessibleTagColors?.get(memberPowerTag.color)
: undefined; : undefined;
@@ -743,7 +747,8 @@ export const Message = as<'div', MessageProps>(
? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon) ? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon)
: undefined; : 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 && ( const headerJSX = !collapse && (
<Box <Box

View File

@@ -86,6 +86,7 @@ import {
useGetMemberPowerTag, useGetMemberPowerTag,
} from '../../../hooks/useMemberPowerTag'; } from '../../../hooks/useMemberPowerTag';
import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag'; import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
import { useOtherUserColor } from '../../../hooks/useUserColor';
type PinnedMessageProps = { type PinnedMessageProps = {
room: Room; room: Room;
@@ -179,6 +180,9 @@ function PinnedMessage({
const senderAvatarMxc = getMemberAvatarMxc(room, sender); const senderAvatarMxc = getMemberAvatarMxc(room, sender);
const getContent = (() => pinnedEvent.getContent()) as GetContentCallback; const getContent = (() => pinnedEvent.getContent()) as GetContentCallback;
// Get custom user color from avatar metadata
const customUserColor = useOtherUserColor(sender, senderAvatarMxc);
const memberPowerTag = getMemberPowerTag(sender); const memberPowerTag = getMemberPowerTag(sender);
const tagColor = memberPowerTag?.color const tagColor = memberPowerTag?.color
? accessibleTagColors?.get(memberPowerTag.color) ? accessibleTagColors?.get(memberPowerTag.color)
@@ -187,7 +191,8 @@ function PinnedMessage({
? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon) ? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon)
: undefined; : 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 ( return (
<ModernLayout <ModernLayout

View File

@@ -12,6 +12,7 @@ import {
Box, Box,
Button, Button,
Chip, Chip,
color,
config, config,
Header, Header,
Icon, Icon,
@@ -27,6 +28,7 @@ import {
Text, Text,
toRem, toRem,
} from 'folds'; } from 'folds';
import { HexColorPicker } from 'react-colorful';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { Page, PageContent, PageHeader } from '../../../components/page'; import { Page, PageContent, PageHeader } from '../../../components/page';
@@ -50,6 +52,8 @@ import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing'; import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
import { useDateFormatItems } from '../../../hooks/useDateFormat'; import { useDateFormatItems } from '../../../hooks/useDateFormat';
import { SequenceCardStyle } from '../styles.css'; import { SequenceCardStyle } from '../styles.css';
import { HexColorPickerPopOut } from '../../../components/HexColorPickerPopOut';
import { useUserColor } from '../../../hooks/useUserColor';
type ThemeSelectorProps = { type ThemeSelectorProps = {
themeNames: Record<string, string>; themeNames: Record<string, string>;
@@ -427,10 +431,131 @@ function Appearance() {
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column"> <SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile title="Page Zoom" after={<PageZoomInput />} /> <SettingTile title="Page Zoom" after={<PageZoomInput />} />
</SequenceCard> </SequenceCard>
<UserColorPicker />
</Box> </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 = { type DateHintProps = {
hasChanges: boolean; hasChanges: boolean;
handleReset: () => void; handleReset: () => void;

View File

@@ -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<ArrayBuffer | null> {
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<void>,
boolean
] {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [color, setColor] = useState<string | undefined>();
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<string, { color: string | undefined; timestamp: number }>();
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<string | undefined>();
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;
}

View File

@@ -97,6 +97,7 @@ import {
} from '../../../hooks/useMemberPowerTag'; } from '../../../hooks/useMemberPowerTag';
import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag'; import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useOtherUserColor } from '../../../hooks/useUserColor';
type RoomNotificationsGroup = { type RoomNotificationsGroup = {
roomId: string; roomId: string;
@@ -200,6 +201,141 @@ const useNotificationTimeline = (
return [notificationTimeline, loadTimeline, silentReloadTimeline]; return [notificationTimeline, loadTimeline, silentReloadTimeline];
}; };
/**
* Props for NotificationItem component
*/
type NotificationItemProps = {
room: Room;
notification: INotification;
getMemberPowerTag: ReturnType<typeof useGetMemberPowerTag>;
accessibleTagColors: Map<string, string> | undefined;
renderMatrixEvent: ReturnType<typeof useMatrixEventRenderer<[IRoomEvent, string, GetContentCallback]>>;
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 (
<SequenceCard
key={notification.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={event.event_id}
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 RoomNotificationsGroupProps = { type RoomNotificationsGroupProps = {
room: Room; room: Room;
notifications: INotification[]; notifications: INotification[];
@@ -439,106 +575,20 @@ function RoomNotificationsGroupComp({
</Box> </Box>
</Header> </Header>
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
{notifications.map((notification) => { {notifications.map((notification) => (
const { event } = notification; <NotificationItem
key={notification.event.event_id}
const displayName = room={room}
getMemberDisplayName(room, event.sender) ?? notification={notification}
getMxIdLocalPart(event.sender) ?? getMemberPowerTag={getMemberPowerTag}
event.sender; accessibleTagColors={accessibleTagColors}
const senderAvatarMxc = getMemberAvatarMxc(room, event.sender); renderMatrixEvent={renderMatrixEvent}
const getContent = (() => event.content) as GetContentCallback; handleOpenClick={handleOpenClick}
legacyUsernameColor={legacyUsernameColor}
const relation = event.content['m.relates_to']; hour24Clock={hour24Clock}
const replyEventId = relation?.['m.in_reply_to']?.event_id; dateFormatString={dateFormatString}
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={notification.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={event.event_id}
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>
);
})}
</Box> </Box>
</Box> </Box>
); );

View File

@@ -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<string | undefined> {
try {
const response = await fetch(url);
if (!response.ok) return undefined;
const arrayBuffer = await response.arrayBuffer();
return extractColorFromPNG(arrayBuffer);
} catch {
return undefined;
}
}