diff --git a/src/app/components/message/Reply.tsx b/src/app/components/message/Reply.tsx index 57bf2af..9a335ba 100644 --- a/src/app/components/message/Reply.tsx +++ b/src/app/components/message/Reply.tsx @@ -2,7 +2,7 @@ import { Box, Icon, Icons, Text, as, color, toRem } from 'folds'; import { EventTimelineSet, Room } from 'matrix-js-sdk'; import React, { MouseEventHandler, ReactNode, useCallback, useMemo } from 'react'; import classNames from 'classnames'; -import { getMemberDisplayName, trimReplyFromBody } from '../../utils/room'; +import { getMemberAvatarMxc, getMemberDisplayName, trimReplyFromBody } from '../../utils/room'; import { getMxIdLocalPart } from '../../utils/matrix'; import { LinePlaceholder } from './placeholder'; import { randomNumberBetween } from '../../utils/common'; @@ -12,6 +12,7 @@ import { scaleSystemEmoji } from '../../plugins/react-custom-html-parser'; import { useRoomEvent } from '../../hooks/useRoomEvent'; import colorMXID from '../../../util/colorMXID'; import { GetMemberPowerTag } from '../../hooks/useMemberPowerTag'; +import { useOtherUserColor } from '../../hooks/useUserColor'; type ReplyLayoutProps = { userColor?: string; @@ -86,10 +87,13 @@ export const Reply = as<'div', ReplyProps>( const { body } = replyEvent?.getContent() ?? {}; const sender = replyEvent?.getSender(); + const senderAvatarMxc = sender ? getMemberAvatarMxc(room, sender) : undefined; + const customUserColor = useOtherUserColor(sender ?? '', senderAvatarMxc); const powerTag = sender ? getMemberPowerTag?.(sender) : undefined; const tagColor = powerTag?.color ? accessibleTagColors?.get(powerTag.color) : undefined; - const usernameColor = legacyUsernameColor ? colorMXID(sender ?? replyEventId) : tagColor; + // Priority: custom user color > tag color (non-legacy) > colorMXID (legacy) + const usernameColor = customUserColor ?? (legacyUsernameColor ? colorMXID(sender ?? replyEventId) : tagColor); const fallbackBody = replyEvent?.isRedacted() ? ( diff --git a/src/app/features/call/CallOverlay.tsx b/src/app/features/call/CallOverlay.tsx index e05b23a..33ab611 100644 --- a/src/app/features/call/CallOverlay.tsx +++ b/src/app/features/call/CallOverlay.tsx @@ -605,7 +605,20 @@ export function CallOverlay() { {isConnecting ? 'Connecting...' : `In Call - ${room?.name ?? 'Unknown Room'}`} {selectedRoomId === activeCall.roomId && ( - (Current Room) + Current Room} + > + {(triggerRef) => ( + + )} + )} {selectedRoomId !== activeCall.roomId && ( diff --git a/src/app/features/call/CallService.ts b/src/app/features/call/CallService.ts index 752e528..53a6c1c 100644 --- a/src/app/features/call/CallService.ts +++ b/src/app/features/call/CallService.ts @@ -31,6 +31,16 @@ type CallEventListener = (event: CallEvent) => void; /** The Matrix state event type for call membership (MSC3401) */ const CALL_MEMBER_EVENT_TYPE = 'org.matrix.msc3401.call.member'; +/** + * Service for managing Matrix RTC calls via LiveKit + * Handles LiveKit connections, media streams, call state, and Matrix room signaling + */ +/** How often to refresh call membership to prevent expiry (45 minutes) */ +const MEMBERSHIP_REFRESH_INTERVAL_MS = 45 * 60 * 1000; + +/** Call membership expiry time (1 hour) */ +const MEMBERSHIP_EXPIRY_MS = 60 * 60 * 1000; + /** * Service for managing Matrix RTC calls via LiveKit * Handles LiveKit connections, media streams, call state, and Matrix room signaling @@ -48,6 +58,12 @@ export class CallService { private livekitJwt: LiveKitJWTResponse | null = null; + private membershipRefreshInterval: ReturnType | null = null; + + private currentCallId: string | null = null; + + private wasMutedBeforeDeafen = false; + /** * Creates a new CallService instance * @param config - Service configuration including homeserver and LiveKit URLs @@ -137,7 +153,7 @@ export class CallService { { device_id: this.config.deviceId, session_id: callId, - expires_ts: Date.now() + 1000 * 60 * 60, // 1 hour expiry + expires_ts: Date.now() + MEMBERSHIP_EXPIRY_MS, feeds: [ { purpose: 'usermedia' }, ], @@ -170,6 +186,44 @@ export class CallService { } } + /** + * Starts periodic refresh of call membership to prevent expiry + * Refreshes every 45 minutes to ensure the 1 hour expiry is never reached + * @param roomId - The Matrix room ID + * @param callId - The unique call ID + */ + private startMembershipRefresh(roomId: string, callId: string): void { + this.stopMembershipRefresh(); + + this.membershipRefreshInterval = setInterval(async () => { + if (!this.activeCall || this.activeCall.roomId !== roomId) { + this.stopMembershipRefresh(); + return; + } + + try { + console.log('Refreshing call membership to prevent expiry'); + await this.sendCallMemberEvent(roomId, callId, true); + console.log('Call membership refreshed successfully'); + } catch (error) { + console.error('Failed to refresh call membership:', error); + } + }, MEMBERSHIP_REFRESH_INTERVAL_MS); + + console.log('Started call membership refresh interval (every 45 minutes)'); + } + + /** + * Stops the membership refresh interval + */ + private stopMembershipRefresh(): void { + if (this.membershipRefreshInterval) { + clearInterval(this.membershipRefreshInterval); + this.membershipRefreshInterval = null; + console.log('Stopped call membership refresh interval'); + } + } + /** * Starts a call in the specified room * @param roomId - The Matrix room ID to start the call in @@ -271,6 +325,10 @@ export class CallService { // Step 5: Send Matrix state event to announce participation // This state event will be rendered in the timeline as a "joined the call" notification await this.sendCallMemberEvent(roomId, callId, true); + this.currentCallId = callId; + + // Step 6: Start periodic membership refresh to prevent expiry after 1 hour + this.startMembershipRefresh(roomId, callId); console.log('Announced call participation to Matrix room'); @@ -651,6 +709,9 @@ export class CallService { const { roomId } = this.activeCall; this.setState(CallState.Disconnecting); + // Stop the membership refresh interval + this.stopMembershipRefresh(); + // Stop any dialing loop and play departure sound getCallSounds().stopDialingLoop(); getCallSounds().playSound(CallSoundType.CallDepart); @@ -674,6 +735,7 @@ export class CallService { this.activeCall = null; this.livekitJwt = null; + this.currentCallId = null; this.emit({ type: CallEventType.StateChanged, @@ -712,6 +774,9 @@ export class CallService { this.livekitRoom.localParticipant.setMetadata(metadata).catch((err) => { console.warn('Failed to set participant metadata:', err); }); + + // Play undeafen sound + getCallSounds().playSound(CallSoundType.Undeafen); } this.livekitRoom.localParticipant.setMicrophoneEnabled(!newMuteState); @@ -749,16 +814,23 @@ export class CallService { }); }); - // Also mute/unmute our mic when deafening (silently - no mute sound) - // Only change mic state if it doesn't match deafen state - if (newDeafenState && !this.activeCall.isMuted) { - // Deafening - also mute mic - this.livekitRoom.localParticipant.setMicrophoneEnabled(false); - this.activeCall.isMuted = true; - } else if (!newDeafenState && this.activeCall.isMuted) { - // Undeafening - also unmute mic - this.livekitRoom.localParticipant.setMicrophoneEnabled(true); - this.activeCall.isMuted = false; + // Handle mic muting when deafening + if (newDeafenState) { + // Remember if user was muted before deafening + this.wasMutedBeforeDeafen = this.activeCall.isMuted; + + // Deafening - always mute mic + if (!this.activeCall.isMuted) { + this.livekitRoom.localParticipant.setMicrophoneEnabled(false); + this.activeCall.isMuted = true; + } + } else { + // Undeafening - only unmute if user wasn't muted before deafening + if (!this.wasMutedBeforeDeafen && this.activeCall.isMuted) { + this.livekitRoom.localParticipant.setMicrophoneEnabled(true); + this.activeCall.isMuted = false; + } + this.wasMutedBeforeDeafen = false; } // Broadcast deafen state to other participants via metadata @@ -943,6 +1015,7 @@ export class CallService { * Cleans up resources */ dispose(): void { + this.stopMembershipRefresh(); this.endCall(); this.listeners.clear(); } diff --git a/src/app/features/message-search/SearchResultGroup.tsx b/src/app/features/message-search/SearchResultGroup.tsx index 62ef9c4..e7a56eb 100644 --- a/src/app/features/message-search/SearchResultGroup.tsx +++ b/src/app/features/message-search/SearchResultGroup.tsx @@ -51,6 +51,147 @@ import { } from '../../hooks/useMemberPowerTag'; import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; +import { useOtherUserColor } from '../../hooks/useUserColor'; + +/** + * Props for SearchResultItem component + */ +type SearchResultItemProps = { + room: Room; + item: ResultItem; + getMemberPowerTag: ReturnType; + accessibleTagColors: Map | undefined; + renderMatrixEvent: ReturnType>; + handleOpenClick: MouseEventHandler; + legacyUsernameColor?: boolean; + hour24Clock: boolean; + dateFormatString: string; +}; + +/** + * Component to render a single search result item with proper hook usage + */ +function SearchResultItem({ + room, + item, + getMemberPowerTag, + accessibleTagColors, + renderMatrixEvent, + handleOpenClick, + legacyUsernameColor, + hour24Clock, + dateFormatString, +}: SearchResultItemProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const { event } = item; + + const displayName = + getMemberDisplayName(room, event.sender) ?? + getMxIdLocalPart(event.sender) ?? + event.sender; + const senderAvatarMxc = getMemberAvatarMxc(room, event.sender); + + const relation = event.content['m.relates_to']; + const mainEventId = + relation?.rel_type === RelationType.Replace ? relation.event_id : event.event_id; + + const getContent = (() => + event.content['m.new_content'] ?? event.content) as GetContentCallback; + + const replyEventId = relation?.['m.in_reply_to']?.event_id; + const threadRootId = + relation?.rel_type === RelationType.Thread ? relation.event_id : undefined; + + // Get custom user color from avatar metadata + const customUserColor = useOtherUserColor(event.sender, senderAvatarMxc); + + const memberPowerTag = getMemberPowerTag(event.sender); + const tagColor = memberPowerTag?.color + ? accessibleTagColors?.get(memberPowerTag.color) + : undefined; + const tagIconSrc = memberPowerTag?.icon + ? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon) + : undefined; + + // Priority: custom user color > tag color (non-legacy) > colorMXID (legacy) + const usernameColor = customUserColor ?? (legacyUsernameColor ? colorMXID(event.sender) : tagColor); + + return ( + + + + } + /> + + + } + > + + + + + + {displayName} + + + {tagIconSrc && } + + + + + Open + + + + {replyEventId && ( + + )} + {renderMatrixEvent(event.type, false, event, displayName, getContent)} + + + ); +} type SearchResultGroupProps = { room: Room; @@ -213,111 +354,20 @@ export function SearchResultGroup({ - {items.map((item) => { - const { event } = item; - - const displayName = - getMemberDisplayName(room, event.sender) ?? - getMxIdLocalPart(event.sender) ?? - event.sender; - const senderAvatarMxc = getMemberAvatarMxc(room, event.sender); - - const relation = event.content['m.relates_to']; - const mainEventId = - relation?.rel_type === RelationType.Replace ? relation.event_id : event.event_id; - - const getContent = (() => - event.content['m.new_content'] ?? event.content) as GetContentCallback; - - const replyEventId = relation?.['m.in_reply_to']?.event_id; - const threadRootId = - relation?.rel_type === RelationType.Thread ? relation.event_id : undefined; - - const memberPowerTag = getMemberPowerTag(event.sender); - const tagColor = memberPowerTag?.color - ? accessibleTagColors?.get(memberPowerTag.color) - : undefined; - const tagIconSrc = memberPowerTag?.icon - ? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon) - : undefined; - - const usernameColor = legacyUsernameColor ? colorMXID(event.sender) : tagColor; - - return ( - - - - } - /> - - - } - > - - - - - - {displayName} - - - {tagIconSrc && } - - - - - Open - - - - {replyEventId && ( - - )} - {renderMatrixEvent(event.type, false, event, displayName, getContent)} - - - ); - })} + {items.map((item) => ( + + ))} ); diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index ae46d2d..69b7400 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -117,6 +117,8 @@ import { useTheme } from '../../hooks/useTheme'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; import { useComposingCheck } from '../../hooks/useComposingCheck'; +import { useOtherUserColor } from '../../hooks/useUserColor'; +import { getMemberAvatarMxc } from '../../utils/room'; interface RoomInputProps { editor: Editor; @@ -142,6 +144,7 @@ export const RoomInput = forwardRef( const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId)); const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId)); const replyUserID = replyDraft?.userId; + const replyAvatarMxc = replyUserID ? getMemberAvatarMxc(room, replyUserID) : undefined; const powerLevelTags = usePowerLevelTags(room, powerLevels); const creatorsTag = useRoomCreatorsTag(); @@ -153,12 +156,16 @@ export const RoomInput = forwardRef( powerLevelTags ); + // Get custom user color from avatar metadata + const replyCustomColor = useOtherUserColor(replyUserID ?? '', replyAvatarMxc); + const replyPowerTag = replyUserID ? getMemberPowerTag(replyUserID) : undefined; const replyPowerColor = replyPowerTag?.color ? accessibleTagColors.get(replyPowerTag.color) : undefined; + // Priority: custom user color > legacy/direct colorMXID > tag color const replyUsernameColor = - legacyUsernameColor || direct ? colorMXID(replyUserID ?? '') : replyPowerColor; + replyCustomColor ?? (legacyUsernameColor || direct ? colorMXID(replyUserID ?? '') : replyPowerColor); const [uploadBoard, setUploadBoard] = useState(true); const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(roomId)); diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 443b6a3..45e4f56 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -80,6 +80,7 @@ import { PowerIcon } from '../../../components/power'; import colorMXID from '../../../../util/colorMXID'; import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag'; import { Presence, useUserPresence } from '../../../hooks/useUserPresence'; +import { useOtherUserColor } from '../../../hooks/useUserColor'; export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void; @@ -736,6 +737,9 @@ export const Message = as<'div', MessageProps>( getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; const senderAvatarMxc = getMemberAvatarMxc(room, senderId); + // Get custom user color from avatar metadata (takes priority) + const customUserColor = useOtherUserColor(senderId, senderAvatarMxc); + const tagColor = memberPowerTag?.color ? accessibleTagColors?.get(memberPowerTag.color) : undefined; @@ -743,7 +747,8 @@ export const Message = as<'div', MessageProps>( ? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon) : undefined; - const usernameColor = legacyUsernameColor ? colorMXID(senderId) : tagColor; + // Priority: custom user color > tag color (non-legacy) > colorMXID (legacy) + const usernameColor = customUserColor ?? (legacyUsernameColor ? colorMXID(senderId) : tagColor); const headerJSX = !collapse && ( pinnedEvent.getContent()) as GetContentCallback; + // Get custom user color from avatar metadata + const customUserColor = useOtherUserColor(sender, senderAvatarMxc); + const memberPowerTag = getMemberPowerTag(sender); const tagColor = memberPowerTag?.color ? accessibleTagColors?.get(memberPowerTag.color) @@ -187,7 +191,8 @@ function PinnedMessage({ ? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon) : undefined; - const usernameColor = legacyUsernameColor ? colorMXID(sender) : tagColor; + // Priority: custom user color > tag color (non-legacy) > colorMXID (legacy) + const usernameColor = customUserColor ?? (legacyUsernameColor ? colorMXID(sender) : tagColor); return ( ; @@ -427,10 +431,131 @@ function Appearance() { } /> + + ); } +/** + * Color picker component for user's profile color + * Stored in avatar PNG metadata, visible to other Paarrot users + */ +function UserColorPicker() { + const [userColor, setUserColor, loading] = useUserColor(); + const [localColor, setLocalColor] = useState(userColor ?? '#3b82f6'); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(); + + // Update local color when avatar color changes + useEffect(() => { + if (userColor) { + setLocalColor(userColor); + } + }, [userColor]); + + const handleColorChange = (newColor: string) => { + setLocalColor(newColor); + setError(undefined); + }; + + const handleSaveColor = async () => { + setSaving(true); + setError(undefined); + try { + await setUserColor(localColor); + } catch (e) { + setError('Failed to save. Make sure you have an avatar set.'); + } + setSaving(false); + }; + + const handleRemoveColor = async () => { + setSaving(true); + setError(undefined); + try { + await setUserColor(undefined); + setLocalColor('#3b82f6'); + } catch (e) { + setError('Failed to remove color.'); + } + setSaving(false); + }; + + return ( + + + {loading ? ( + Loading... + ) : ( + + + + { + setLocalColor(e.target.value); + setError(undefined); + }} + /> + + + {error && ( + {error} + )} + + } + onRemove={userColor ? handleRemoveColor : undefined} + > + {(onOpen) => ( + + )} + + )} + + } + /> + + ); +} + type DateHintProps = { hasChanges: boolean; handleReset: () => void; diff --git a/src/app/hooks/useUserColor.ts b/src/app/hooks/useUserColor.ts new file mode 100644 index 0000000..e44d0ec --- /dev/null +++ b/src/app/hooks/useUserColor.ts @@ -0,0 +1,201 @@ +import { useCallback, useEffect, useState } from 'react'; +import { MatrixClient } from 'matrix-js-sdk'; +import { useMatrixClient } from './useMatrixClient'; +import { useMediaAuthentication } from './useMediaAuthentication'; +import { mxcUrlToHttp } from '../utils/matrix'; +import { embedColorInPNG, extractColorFromPNG, fetchAndExtractColor, removeColorFromPNG } from '../utils/pngMetadata'; + +/** + * Fetches the user's current avatar as raw PNG data + */ +async function fetchAvatarData( + mx: MatrixClient, + avatarMxc: string, + useAuthentication: boolean +): Promise { + const url = mxcUrlToHttp(mx, avatarMxc, useAuthentication); + if (!url) return null; + + try { + const response = await fetch(url); + if (!response.ok) return null; + return await response.arrayBuffer(); + } catch { + return null; + } +} + +/** + * Hook to manage the user's chosen profile color, stored in avatar PNG metadata + * The color is embedded in the PNG tEXt chunk and syncs via the avatar + * @returns The current color, a setter function, and loading state + */ +export function useUserColor(): [ + string | undefined, + (color: string | undefined) => Promise, + boolean +] { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const [color, setColor] = useState(); + const [loading, setLoading] = useState(true); + + // Extract color from current avatar on mount and when profile changes + useEffect(() => { + const loadColor = async () => { + setLoading(true); + try { + const userId = mx.getUserId(); + if (!userId) { + setLoading(false); + return; + } + + const profile = await mx.getProfileInfo(userId); + const avatarUrl = profile.avatar_url; + + if (!avatarUrl) { + setColor(undefined); + setLoading(false); + return; + } + + const httpUrl = mxcUrlToHttp(mx, avatarUrl, useAuthentication); + if (!httpUrl) { + setColor(undefined); + setLoading(false); + return; + } + + const extractedColor = await fetchAndExtractColor(httpUrl); + setColor(extractedColor); + } catch (e) { + console.error('Failed to load user color from avatar:', e); + setColor(undefined); + } + setLoading(false); + }; + + loadColor(); + }, [mx, useAuthentication]); + + const updateColor = useCallback(async (newColor: string | undefined) => { + const userId = mx.getUserId(); + if (!userId) return; + + try { + const profile = await mx.getProfileInfo(userId); + const avatarUrl = profile.avatar_url; + + if (!avatarUrl) { + // No avatar to embed color in + console.warn('Cannot set color: no avatar image set'); + throw new Error('No avatar set'); + } + + // Fetch current avatar + const avatarData = await fetchAvatarData(mx, avatarUrl, useAuthentication); + if (!avatarData) { + console.error('Failed to fetch current avatar'); + throw new Error('Failed to fetch avatar'); + } + + console.log('Original avatar size:', avatarData.byteLength, 'bytes'); + + // Modify PNG metadata + let newAvatarData: Uint8Array | null; + if (newColor) { + newAvatarData = embedColorInPNG(avatarData, newColor); + } else { + newAvatarData = removeColorFromPNG(avatarData); + } + + if (!newAvatarData) { + console.error('Failed to modify avatar PNG metadata - not a valid PNG?'); + throw new Error('Failed to modify PNG metadata'); + } + + console.log('Modified avatar size:', newAvatarData.byteLength, 'bytes'); + + // Verify the color was embedded correctly before uploading + const verifyColor = extractColorFromPNG(newAvatarData); + console.log('Verification - embedded color:', verifyColor); + + if (newColor && verifyColor !== newColor) { + console.error('Color verification failed! Expected:', newColor, 'Got:', verifyColor); + throw new Error('Color embedding verification failed'); + } + + // Upload modified avatar + const blob = new Blob([newAvatarData], { type: 'image/png' }); + const uploadResponse = await mx.uploadContent(blob, { + name: 'avatar.png', + type: 'image/png', + }); + + console.log('Uploaded new avatar:', uploadResponse.content_uri); + + // Update profile with new avatar + await mx.setAvatarUrl(uploadResponse.content_uri); + + setColor(newColor); + console.log('User color updated in avatar:', newColor); + } catch (e) { + console.error('Failed to update user color in avatar:', e); + throw e; + } + }, [mx, useAuthentication]); + + return [color, updateColor, loading]; +} + +// Cache for user colors to avoid refetching for each message +const userColorCache = new Map(); +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes + +/** + * Hook to get another user's chosen profile color from their avatar metadata + * @param userId - The user ID to get color for + * @param avatarMxc - The user's avatar MXC URL + * @returns The user's chosen color or undefined + */ +export function useOtherUserColor(userId: string, avatarMxc: string | undefined): string | undefined { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const [color, setColor] = useState(); + + useEffect(() => { + if (!avatarMxc) { + setColor(undefined); + return; + } + + // Check cache first + const cacheKey = `${userId}:${avatarMxc}`; + const cached = userColorCache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { + setColor(cached.color); + return; + } + + const loadColor = async () => { + const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication); + if (!httpUrl) { + setColor(undefined); + return; + } + + const extractedColor = await fetchAndExtractColor(httpUrl); + + // Cache the result + userColorCache.set(cacheKey, { color: extractedColor, timestamp: Date.now() }); + + setColor(extractedColor); + }; + + loadColor(); + }, [mx, useAuthentication, avatarMxc, userId]); + + return color; +} + diff --git a/src/app/pages/client/inbox/Notifications.tsx b/src/app/pages/client/inbox/Notifications.tsx index 4cc94d9..509fbcf 100644 --- a/src/app/pages/client/inbox/Notifications.tsx +++ b/src/app/pages/client/inbox/Notifications.tsx @@ -97,6 +97,7 @@ import { } from '../../../hooks/useMemberPowerTag'; import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; +import { useOtherUserColor } from '../../../hooks/useUserColor'; type RoomNotificationsGroup = { roomId: string; @@ -200,6 +201,141 @@ const useNotificationTimeline = ( return [notificationTimeline, loadTimeline, silentReloadTimeline]; }; +/** + * Props for NotificationItem component + */ +type NotificationItemProps = { + room: Room; + notification: INotification; + getMemberPowerTag: ReturnType; + accessibleTagColors: Map | undefined; + renderMatrixEvent: ReturnType>; + handleOpenClick: MouseEventHandler; + legacyUsernameColor?: boolean; + hour24Clock: boolean; + dateFormatString: string; +}; + +/** + * Component to render a single notification item with proper hook usage + */ +function NotificationItem({ + room, + notification, + getMemberPowerTag, + accessibleTagColors, + renderMatrixEvent, + handleOpenClick, + legacyUsernameColor, + hour24Clock, + dateFormatString, +}: NotificationItemProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const { event } = notification; + + const displayName = + getMemberDisplayName(room, event.sender) ?? + getMxIdLocalPart(event.sender) ?? + event.sender; + const senderAvatarMxc = getMemberAvatarMxc(room, event.sender); + const getContent = (() => event.content) as GetContentCallback; + + const relation = event.content['m.relates_to']; + const replyEventId = relation?.['m.in_reply_to']?.event_id; + const threadRootId = + relation?.rel_type === RelationType.Thread ? relation.event_id : undefined; + + // Get custom user color from avatar metadata + const customUserColor = useOtherUserColor(event.sender, senderAvatarMxc); + + const memberPowerTag = getMemberPowerTag(event.sender); + const tagColor = memberPowerTag?.color + ? accessibleTagColors?.get(memberPowerTag.color) + : undefined; + const tagIconSrc = memberPowerTag?.icon + ? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon) + : undefined; + + // Priority: custom user color > tag color (non-legacy) > colorMXID (legacy) + const usernameColor = customUserColor ?? (legacyUsernameColor ? colorMXID(event.sender) : tagColor); + + return ( + + + + } + /> + + + } + > + + + + + + {displayName} + + + {tagIconSrc && } + + + + + Open + + + + {replyEventId && ( + + )} + {renderMatrixEvent(event.type, false, event, displayName, getContent)} + + + ); +} + type RoomNotificationsGroupProps = { room: Room; notifications: INotification[]; @@ -439,106 +575,20 @@ function RoomNotificationsGroupComp({ - {notifications.map((notification) => { - const { event } = notification; - - const displayName = - getMemberDisplayName(room, event.sender) ?? - getMxIdLocalPart(event.sender) ?? - event.sender; - const senderAvatarMxc = getMemberAvatarMxc(room, event.sender); - const getContent = (() => event.content) as GetContentCallback; - - const relation = event.content['m.relates_to']; - const replyEventId = relation?.['m.in_reply_to']?.event_id; - const threadRootId = - relation?.rel_type === RelationType.Thread ? relation.event_id : undefined; - - const memberPowerTag = getMemberPowerTag(event.sender); - const tagColor = memberPowerTag?.color - ? accessibleTagColors?.get(memberPowerTag.color) - : undefined; - const tagIconSrc = memberPowerTag?.icon - ? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon) - : undefined; - - const usernameColor = legacyUsernameColor ? colorMXID(event.sender) : tagColor; - - return ( - - - - } - /> - - - } - > - - - - - - {displayName} - - - {tagIconSrc && } - - - - - Open - - - - {replyEventId && ( - - )} - {renderMatrixEvent(event.type, false, event, displayName, getContent)} - - - ); - })} + {notifications.map((notification) => ( + + ))} ); diff --git a/src/app/utils/pngMetadata.ts b/src/app/utils/pngMetadata.ts new file mode 100644 index 0000000..cb3ee35 --- /dev/null +++ b/src/app/utils/pngMetadata.ts @@ -0,0 +1,306 @@ +/** + * Utilities for reading and writing PNG metadata (tEXt chunks) + * Used to embed user color in avatar images + */ + +/* eslint-disable no-bitwise */ +/* eslint-disable no-plusplus */ +/* eslint-disable no-restricted-syntax */ +/* eslint-disable no-console */ + +/** PNG signature bytes */ +const PNG_SIGNATURE = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]); + +/** Key used to store user color in PNG metadata */ +export const PAARROT_COLOR_KEY = 'paarrot:color'; + +let crc32Table: Uint32Array | null = null; +function getCRC32Table(): Uint32Array { + if (crc32Table) return crc32Table; + + crc32Table = new Uint32Array(256); + for (let i = 0; i < 256; i++) { + let c = i; + for (let j = 0; j < 8; j++) { + c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1); + } + crc32Table[i] = c; + } + return crc32Table; +} + +/** + * Calculate CRC32 for a chunk + */ +function crc32(data: Uint8Array): number { + let crc = 0xffffffff; + const table = getCRC32Table(); + + for (let i = 0; i < data.length; i++) { + crc = (crc >>> 8) ^ table[(crc ^ data[i]) & 0xff]; + } + + return (crc ^ 0xffffffff) >>> 0; +} + +/** + * Read a 32-bit big-endian integer from a buffer + */ +function readUint32BE(data: Uint8Array, offset: number): number { + return (data[offset] << 24) | (data[offset + 1] << 16) | (data[offset + 2] << 8) | data[offset + 3]; +} + +/** + * Write a 32-bit big-endian integer to a buffer + */ +function writeUint32BE(value: number): Uint8Array { + return new Uint8Array([ + (value >>> 24) & 0xff, + (value >>> 16) & 0xff, + (value >>> 8) & 0xff, + value & 0xff, + ]); +} + +/** + * Create a tEXt chunk with the given key and value + */ +function createTextChunk(key: string, value: string): Uint8Array { + const keyBytes = new TextEncoder().encode(key); + const valueBytes = new TextEncoder().encode(value); + + // tEXt chunk data: key + null byte + value + const chunkData = new Uint8Array(keyBytes.length + 1 + valueBytes.length); + chunkData.set(keyBytes, 0); + chunkData[keyBytes.length] = 0; // null separator + chunkData.set(valueBytes, keyBytes.length + 1); + + const chunkType = new TextEncoder().encode('tEXt'); + + // Calculate CRC over type + data + const crcData = new Uint8Array(4 + chunkData.length); + crcData.set(chunkType, 0); + crcData.set(chunkData, 4); + const crc = crc32(crcData); + + // Full chunk: length (4) + type (4) + data + crc (4) + const chunk = new Uint8Array(4 + 4 + chunkData.length + 4); + chunk.set(writeUint32BE(chunkData.length), 0); + chunk.set(chunkType, 4); + chunk.set(chunkData, 8); + chunk.set(writeUint32BE(crc), 8 + chunkData.length); + + return chunk; +} + +/** + * Parse PNG chunks from image data + */ +function parseChunks(data: Uint8Array): Array<{ type: string; data: Uint8Array; offset: number; length: number }> { + const chunks: Array<{ type: string; data: Uint8Array; offset: number; length: number }> = []; + let offset = 8; // Skip PNG signature + + while (offset < data.length) { + const length = readUint32BE(data, offset); + const type = new TextDecoder().decode(data.slice(offset + 4, offset + 8)); + const chunkData = data.slice(offset + 8, offset + 8 + length); + + chunks.push({ + type, + data: chunkData, + offset, + length: 12 + length, // length field + type + data + crc + }); + + offset += 12 + length; + + if (type === 'IEND') break; + } + + return chunks; +} + +/** + * Read a tEXt chunk value + */ +function parseTextChunk(data: Uint8Array): { key: string; value: string } | null { + const nullIndex = data.indexOf(0); + if (nullIndex === -1) return null; + + const key = new TextDecoder().decode(data.slice(0, nullIndex)); + const value = new TextDecoder().decode(data.slice(nullIndex + 1)); + + return { key, value }; +} + +/** + * Extract paarrot color from PNG image data + * @param imageData - Raw PNG image data as ArrayBuffer or Uint8Array + * @returns The color string if found, or undefined + */ +export function extractColorFromPNG(imageData: ArrayBuffer | Uint8Array): string | undefined { + const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData); + + console.log('[extractColorFromPNG] Input size:', data.length, 'bytes'); + + // Verify PNG signature + for (let i = 0; i < 8; i++) { + if (data[i] !== PNG_SIGNATURE[i]) { + console.error('[extractColorFromPNG] Not a PNG! Byte', i, 'is', data[i], 'expected', PNG_SIGNATURE[i]); + return undefined; // Not a PNG + } + } + + const chunks = parseChunks(data); + console.log('[extractColorFromPNG] Found', chunks.length, 'chunks:', chunks.map(c => c.type).join(', ')); + + for (const chunk of chunks) { + if (chunk.type === 'tEXt') { + const text = parseTextChunk(chunk.data); + console.log('[extractColorFromPNG] tEXt chunk found, key:', text?.key); + if (text && text.key === PAARROT_COLOR_KEY) { + console.log('[extractColorFromPNG] Found color:', text.value); + return text.value; + } + } + } + + console.log('[extractColorFromPNG] No color found'); + return undefined; +} + +/** + * Embed paarrot color into PNG image data + * @param imageData - Raw PNG image data as ArrayBuffer or Uint8Array + * @param color - The color string to embed (e.g., '#ff5500') + * @returns New PNG data with embedded color, or null if not a valid PNG + */ +export function embedColorInPNG(imageData: ArrayBuffer | Uint8Array, color: string): Uint8Array | null { + const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData); + + console.log('[embedColorInPNG] Input size:', data.length, 'bytes'); + console.log('[embedColorInPNG] First 8 bytes:', Array.from(data.slice(0, 8))); + console.log('[embedColorInPNG] PNG signature expected:', Array.from(PNG_SIGNATURE)); + + // Verify PNG signature + for (let i = 0; i < 8; i++) { + if (data[i] !== PNG_SIGNATURE[i]) { + console.error('[embedColorInPNG] Not a PNG! Byte', i, 'is', data[i], 'expected', PNG_SIGNATURE[i]); + return null; // Not a PNG + } + } + + console.log('[embedColorInPNG] PNG signature verified OK'); + + const chunks = parseChunks(data); + console.log('[embedColorInPNG] Found', chunks.length, 'chunks:', chunks.map(c => c.type).join(', ')); + + // Find IHDR chunk (always first) and any existing paarrot tEXt chunk + let insertOffset = 8; // After signature + let existingPaarrotChunk: { offset: number; length: number } | null = null; + + for (const chunk of chunks) { + if (chunk.type === 'IHDR') { + insertOffset = chunk.offset + chunk.length; // Insert after IHDR + } else if (chunk.type === 'tEXt') { + const text = parseTextChunk(chunk.data); + if (text && text.key === PAARROT_COLOR_KEY) { + existingPaarrotChunk = { offset: chunk.offset, length: chunk.length }; + } + } + } + + // Create new tEXt chunk with color + const colorChunk = createTextChunk(PAARROT_COLOR_KEY, color); + console.log('[embedColorInPNG] Created color chunk, size:', colorChunk.length, 'bytes'); + console.log('[embedColorInPNG] Color being embedded:', color); + + // Build new PNG data + let newData: Uint8Array; + + if (existingPaarrotChunk) { + // Replace existing chunk + console.log('[embedColorInPNG] Replacing existing chunk at offset', existingPaarrotChunk.offset); + newData = new Uint8Array(data.length - existingPaarrotChunk.length + colorChunk.length); + newData.set(data.slice(0, existingPaarrotChunk.offset), 0); + newData.set(colorChunk, existingPaarrotChunk.offset); + newData.set( + data.slice(existingPaarrotChunk.offset + existingPaarrotChunk.length), + existingPaarrotChunk.offset + colorChunk.length + ); + } else { + // Insert new chunk after IHDR + console.log('[embedColorInPNG] Inserting new chunk at offset', insertOffset); + newData = new Uint8Array(data.length + colorChunk.length); + newData.set(data.slice(0, insertOffset), 0); + newData.set(colorChunk, insertOffset); + newData.set(data.slice(insertOffset), insertOffset + colorChunk.length); + } + + console.log('[embedColorInPNG] Output size:', newData.length, 'bytes'); + + return newData; +} + +/** + * Remove paarrot color from PNG image data + * @param imageData - Raw PNG image data as ArrayBuffer or Uint8Array + * @returns New PNG data without the color chunk, or null if not a valid PNG + */ +export function removeColorFromPNG(imageData: ArrayBuffer | Uint8Array): Uint8Array | null { + const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData); + + // Verify PNG signature + for (let i = 0; i < 8; i++) { + if (data[i] !== PNG_SIGNATURE[i]) { + return null; // Not a PNG + } + } + + const chunks = parseChunks(data); + + // Find existing paarrot tEXt chunk + let existingPaarrotChunk: { offset: number; length: number } | null = null; + + for (const chunk of chunks) { + if (chunk.type === 'tEXt') { + const text = parseTextChunk(chunk.data); + if (text && text.key === PAARROT_COLOR_KEY) { + existingPaarrotChunk = { offset: chunk.offset, length: chunk.length }; + break; + } + } + } + + if (!existingPaarrotChunk) { + // No color to remove + return data; + } + + // Build new PNG data without the color chunk + const newData = new Uint8Array(data.length - existingPaarrotChunk.length); + newData.set(data.slice(0, existingPaarrotChunk.offset), 0); + newData.set( + data.slice(existingPaarrotChunk.offset + existingPaarrotChunk.length), + existingPaarrotChunk.offset + ); + + return newData; +} + +/** + * Fetch an image and extract the paarrot color from it + * @param url - The URL of the image to fetch + * @returns The color string if found, or undefined + */ +export async function fetchAndExtractColor(url: string): Promise { + try { + const response = await fetch(url); + if (!response.ok) return undefined; + + const arrayBuffer = await response.arrayBuffer(); + return extractColorFromPNG(arrayBuffer); + } catch { + return undefined; + } +}