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:
@@ -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 />
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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,17 +814,24 @@ 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;
|
||||||
|
|
||||||
|
// Deafening - always mute mic
|
||||||
|
if (!this.activeCall.isMuted) {
|
||||||
this.livekitRoom.localParticipant.setMicrophoneEnabled(false);
|
this.livekitRoom.localParticipant.setMicrophoneEnabled(false);
|
||||||
this.activeCall.isMuted = true;
|
this.activeCall.isMuted = true;
|
||||||
} else if (!newDeafenState && this.activeCall.isMuted) {
|
}
|
||||||
// Undeafening - also unmute mic
|
} else {
|
||||||
|
// Undeafening - only unmute if user wasn't muted before deafening
|
||||||
|
if (!this.wasMutedBeforeDeafen && this.activeCall.isMuted) {
|
||||||
this.livekitRoom.localParticipant.setMicrophoneEnabled(true);
|
this.livekitRoom.localParticipant.setMicrophoneEnabled(true);
|
||||||
this.activeCall.isMuted = false;
|
this.activeCall.isMuted = false;
|
||||||
}
|
}
|
||||||
|
this.wasMutedBeforeDeafen = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Broadcast deafen state to other participants via metadata
|
// Broadcast deafen state to other participants via metadata
|
||||||
const metadata = JSON.stringify({ isDeafened: newDeafenState });
|
const metadata = JSON.stringify({ isDeafened: newDeafenState });
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 =
|
|
||||||
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}
|
hour24Clock={hour24Clock}
|
||||||
dateFormatString={dateFormatString}
|
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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
201
src/app/hooks/useUserColor.ts
Normal file
201
src/app/hooks/useUserColor.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
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 (
|
|
||||||
<SequenceCard
|
|
||||||
key={notification.event.event_id}
|
key={notification.event.event_id}
|
||||||
style={{ padding: config.space.S400 }}
|
room={room}
|
||||||
variant="SurfaceVariant"
|
notification={notification}
|
||||||
direction="Column"
|
getMemberPowerTag={getMemberPowerTag}
|
||||||
>
|
accessibleTagColors={accessibleTagColors}
|
||||||
<ModernLayout
|
renderMatrixEvent={renderMatrixEvent}
|
||||||
before={
|
handleOpenClick={handleOpenClick}
|
||||||
<AvatarBase>
|
legacyUsernameColor={legacyUsernameColor}
|
||||||
<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}
|
hour24Clock={hour24Clock}
|
||||||
dateFormatString={dateFormatString}
|
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>
|
||||||
);
|
);
|
||||||
|
|||||||
306
src/app/utils/pngMetadata.ts
Normal file
306
src/app/utils/pngMetadata.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user