13 Commits

Author SHA1 Message Date
a3caafaf20 feat: Implement image carousel functionality with metadata handling and UI enhancements 2026-05-17 13:24:44 +10:00
b9b0dce40b fix: Remove unnecessary size prop from Modal in ShareRoomPicker component 2026-05-13 11:12:14 +10:00
b2fd65c8cb feat: Enhance editor functionality with autocorrect handling and text replacement
feat: Implement confirmation dialog for room sharing in ShareRoomPicker

fix: Improve server input synchronization and blur handling in ServerPicker

feat: Integrate UnifiedPush for background sync in Android, including pusher management

refactor: Streamline Android share handling and file upload processes

fix: Update notification handling to use Capacitor's local notifications API
2026-05-13 10:40:30 +10:00
7ef9939d8a fix: Change Code component display style from inline-block to inline 2026-05-13 08:00:22 +10:00
43bd6320b8 Refactor code structure for improved readability and maintainability 2026-05-13 05:47:11 +10:00
aaf8089ba6 feat: Trim whitespace from message bodies and enhance emoji usage integration 2026-05-13 05:17:23 +10:00
38a43a3a99 feat: Add emoji usage tracking hook and integrate with EmojiBoard for usage statistics 2026-05-13 04:59:13 +10:00
4fdedb87c8 feat: Implement microphone monitoring feature; enhance text overflow handling and line height across components 2026-05-13 03:37:32 +10:00
5d4d518af8 feat: Add detailed logging for user profile components and image metadata extraction; enhance color handling utilities 2026-04-30 20:23:44 +10:00
cad4852878 feat: Enhance scrolling behavior and styles across components; add centralized scroll utilities
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 22:51:50 +10:00
e6af56a417 Merge branch 'main' of http://synbox.ruv.wtf:8418/litruv/cinny 2026-04-28 15:47:31 +10:00
ffec244113 feat: Refactor Android share and background sync utilities; streamline notification handling with dynamic imports 2026-04-28 15:46:19 +10:00
52abffb4c9 feat: integrate scroll to latest behavior setting into unread info calculation 2026-04-26 19:10:06 +10:00
40 changed files with 1702 additions and 343 deletions

BIN
build_output.txt Normal file

Binary file not shown.

35
package-lock.json generated
View File

@@ -12,8 +12,10 @@
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
"@capacitor/core": "8.3.4",
"@capacitor/local-notifications": "8.2.0",
"@fontsource/inter": "4.5.14",
"@paarrot/plugin-manager": "file:../../PluginManager",
"@paarrot/plugin-manager": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git",
"@tanstack/react-query": "5.24.1",
"@tanstack/react-query-devtools": "5.24.1",
"@tanstack/react-virtual": "3.2.0",
@@ -114,15 +116,6 @@
"node": ">=16.0.0"
}
},
"../../PluginManager": {
"name": "@paarrot/plugin-manager",
"version": "1.0.0",
"devDependencies": {
"@types/node": "^25.6.0",
"rimraf": "^5.0.0",
"typescript": "^5.4.0"
}
},
"node_modules/@ampproject/remapping": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
@@ -1678,6 +1671,24 @@
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@capacitor/core": {
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.3.4.tgz",
"integrity": "sha512-CqRQCkb6HXxcx/N7s+hHTN6ef2CmamFiRMITwm4qB840ph56mS42bzUgn6tKCP+RZjdDweiRHj9ytDDeN6jFag==",
"license": "MIT",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/@capacitor/local-notifications": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/@capacitor/local-notifications/-/local-notifications-8.2.0.tgz",
"integrity": "sha512-fvLY0w2w4MiX+DD4+Wv4DOwOLdzKZsMDwAcRv/Juudd+QbKbn69s6cM3xVqPwAiDqfnqsY4/S8xtQD6M73wY2A==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@emotion/hash": {
"version": "0.9.2",
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
@@ -2344,8 +2355,8 @@
}
},
"node_modules/@paarrot/plugin-manager": {
"resolved": "../../PluginManager",
"link": true
"version": "1.0.0",
"resolved": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git#6c389381df3966568a40c39d8661f9a4300f7819"
},
"node_modules/@react-aria/breadcrumbs": {
"version": "3.5.20",

View File

@@ -23,6 +23,8 @@
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
"@capacitor/core": "8.3.4",
"@capacitor/local-notifications": "8.2.0",
"@fontsource/inter": "4.5.14",
"@paarrot/plugin-manager": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git",
"@tanstack/react-query": "5.24.1",

View File

@@ -159,6 +159,24 @@ export function RenderMessageContent({
</>
);
// Check for custom renderer from plugins BEFORE standard type handling
// so plugins can intercept any msgtype including custom ones.
const customRendererEarly = pluginRegistry.getRenderer('message');
if (customRendererEarly) {
try {
const messageDataEarly = {
msgtype: msgType,
...getContent(),
};
const customContentEarly = customRendererEarly(messageDataEarly, () => null);
if (customContentEarly) {
return customContentEarly as React.ReactElement;
}
} catch (error) {
console.error('[RenderMessageContent] Custom renderer error (early):', error);
}
}
if (msgType === MsgType.Text) {
return (
<MText

View File

@@ -6,6 +6,8 @@ import React, {
forwardRef,
useCallback,
useState,
useEffect,
useRef,
} from 'react';
import { Box, Scroll, Text } from 'folds';
import { Descendant, Editor, createEditor, Transforms, Range, Element as SlateElement, Text as SlateText, Point } from 'slate';
@@ -167,6 +169,48 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
[editor, onKeyDown]
);
const handleBeforeInput = useCallback(
(event: Event) => {
const inputEvent = event as InputEvent;
// Handle autocorrect replacement that causes text duplication
if (inputEvent.inputType === 'insertReplacementText' ||
inputEvent.inputType === 'insertFromComposition') {
const { selection } = editor;
if (!selection) return;
// Get the data being inserted
const data = inputEvent.data || inputEvent.dataTransfer?.getData('text/plain');
if (data) {
event.preventDefault();
// If there's selected text, delete it first
if (selection && !Range.isCollapsed(selection)) {
Transforms.delete(editor, { at: selection });
}
// Insert the replacement text
editor.insertText(data);
}
}
},
[editor]
);
const editableRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const editableElement = editableRef.current?.querySelector('[data-slate-editor="true"]');
if (!editableElement) return;
editableElement.addEventListener('beforeinput', handleBeforeInput, { capture: true });
return () => {
editableElement.removeEventListener('beforeinput', handleBeforeInput, { capture: true });
};
}, [handleBeforeInput]);
const renderPlaceholder = useCallback(
({ attributes, children }: RenderPlaceholderProps) => (
<span {...attributes} className={css.EditorPlaceholderContainer}>
@@ -196,6 +240,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
size="300"
visibility="Hover"
hideTrack
ref={editableRef}
>
<Editable
data-editable-name={editableName}

View File

@@ -21,7 +21,7 @@ import { useEmojiGroupIcons } from './useEmojiGroupIcons';
import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard';
import { useRelevantImagePacks } from '../../hooks/useImagePacks';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRecentEmoji } from '../../hooks/useRecentEmoji';
import { useEmojiUsage } from '../../hooks/useEmojiUsage';
import { isUserId, mxcUrlToHttp } from '../../utils/matrix';
import { editableActiveElement, targetFromEvent } from '../../utils/dom';
import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch';
@@ -53,6 +53,7 @@ import {
import { EmojiBoardTab, EmojiType } from './types';
import { VirtualTile } from '../virtualizer';
const MOST_USED_GROUP_ID = 'most_used_group';
const RECENT_GROUP_ID = 'recent_group';
const SEARCH_GROUP_ID = 'search_group';
@@ -72,19 +73,29 @@ const useGroups = (
imagePacks: ImagePack[]
): [EmojiGroupItem[], StickerGroupItem[]] => {
const mx = useMatrixClient();
const { getTopEmojis } = useEmojiUsage();
const recentEmojis = useRecentEmoji(mx, 21);
const labels = useEmojiGroupLabels();
const emojiGroupItems = useMemo(() => {
const g: EmojiGroupItem[] = [];
if (tab !== EmojiBoardTab.Emoji) return g;
g.push({
id: RECENT_GROUP_ID,
name: 'Recent',
items: recentEmojis,
});
// Add most-used emojis
const topShortcodes = getTopEmojis(21);
if (topShortcodes.length > 0) {
const mostUsedEmojis = topShortcodes
.map((shortcode) => emojis.find((e) => e.shortcode === shortcode))
.filter((e): e is IEmoji => e !== undefined);
if (mostUsedEmojis.length > 0) {
g.push({
id: MOST_USED_GROUP_ID,
name: 'Most Used',
items: mostUsedEmojis,
});
}
}
imagePacks.forEach((pack) => {
let label = pack.meta.name;
@@ -108,7 +119,7 @@ const useGroups = (
});
return g;
}, [mx, recentEmojis, labels, imagePacks, tab]);
}, [mx, labels, imagePacks, tab, getTopEmojis]);
const stickerGroupItems = useMemo(() => {
const g: StickerGroupItem[] = [];
@@ -187,10 +198,10 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP
<Sidebar>
<SidebarStack>
<GroupIcon
active={activeGroupId === RECENT_GROUP_ID}
id={RECENT_GROUP_ID}
label="Recent"
icon={Icons.RecentClock}
active={activeGroupId === MOST_USED_GROUP_ID}
id={MOST_USED_GROUP_ID}
label="Most Used"
icon={Icons.Heart}
onClick={handleScrollToGroup}
/>
</SidebarStack>
@@ -378,13 +389,30 @@ export function EmojiBoard({
addToRecentEmoji = true,
}: EmojiBoardProps) {
const mx = useMatrixClient();
const { trackEmojiUsage, getMostUsedEmoji } = useEmojiUsage();
const emojiTab = tab === EmojiBoardTab.Emoji;
const usage = emojiTab ? ImageUsage.Emoticon : ImageUsage.Sticker;
// Get default emoji - use most used or fallback to slight_smile
const defaultPreview = useMemo((): PreviewData => {
if (!emojiTab) return undefined;
const mostUsedShortcode = getMostUsedEmoji();
if (mostUsedShortcode) {
// Find emoji by shortcode
const emoji = emojis.find((e) => e.shortcode === mostUsedShortcode);
if (emoji) {
return { key: emoji.unicode, shortcode: emoji.shortcode };
}
}
// Fallback to default
return DefaultEmojiPreview;
}, [emojiTab, getMostUsedEmoji]);
const previewAtom = useMemo(
() => createPreviewDataAtom(emojiTab ? DefaultEmojiPreview : undefined),
[emojiTab]
() => createPreviewDataAtom(defaultPreview),
[defaultPreview]
);
const activeGroupIdAtom = useMemo(() => atom<string | undefined>(undefined), []);
const setActiveGroupId = useSetAtom(activeGroupIdAtom);
@@ -439,6 +467,10 @@ export function EmojiBoard({
onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
if (!evt.altKey && !evt.shiftKey && addToRecentEmoji) {
addRecentEmoji(mx, emojiInfo.data);
// Track emoji usage for favorites
trackEmojiUsage(emojiInfo.shortcode).catch((err) => {
console.error('Failed to track emoji usage:', err);
});
}
}
if (emojiInfo.type === EmojiType.CustomEmoji) {

View File

@@ -7,6 +7,7 @@ export const Image = style([
display: 'block',
maxWidth: '100%',
maxHeight: '100%',
borderRadius: 'inherit',
imageRendering: 'auto',
backfaceVisibility: 'hidden',
transform: 'translateZ(0)',

View File

@@ -80,7 +80,10 @@ export function MText({ edited, content, renderBody, renderUrlsPreview, style }:
const { body, formatted_body: customBody } = content;
if (typeof body !== 'string') return <BrokenContent />;
const trimmedBody = trimReplyFromBody(body);
const trimmedBody = trimReplyFromBody(body).trim();
const trimmedCustomBody = typeof customBody === 'string'
? customBody.trim().replace(/(<br\s*\/?>\s*)+$/gi, '')
: undefined;
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
@@ -93,7 +96,7 @@ export function MText({ edited, content, renderBody, renderUrlsPreview, style }:
>
{renderBody({
body: trimmedBody,
customBody: typeof customBody === 'string' ? customBody : undefined,
customBody: trimmedCustomBody,
})}
{edited && <MessageEditedContent />}
</MessageTextBody>
@@ -119,7 +122,10 @@ export function MEmote({
const { body, formatted_body: customBody } = content;
if (typeof body !== 'string') return <BrokenContent />;
const trimmedBody = trimReplyFromBody(body);
const trimmedBody = trimReplyFromBody(body).trim();
const trimmedCustomBody = typeof customBody === 'string'
? customBody.trim().replace(/(<br\s*\/?>\s*)+$/gi, '')
: undefined;
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
@@ -133,7 +139,7 @@ export function MEmote({
<b>{`${displayName} `}</b>
{renderBody({
body: trimmedBody,
customBody: typeof customBody === 'string' ? customBody : undefined,
customBody: trimmedCustomBody,
})}
{edited && <MessageEditedContent />}
</MessageTextBody>
@@ -152,7 +158,10 @@ export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNot
const { body, formatted_body: customBody } = content;
if (typeof body !== 'string') return <BrokenContent />;
const trimmedBody = trimReplyFromBody(body);
const trimmedBody = trimReplyFromBody(body).trim();
const trimmedCustomBody = typeof customBody === 'string'
? customBody.trim().replace(/(<br\s*\/?>\s*)+$/gi, '')
: undefined;
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
@@ -165,7 +174,7 @@ export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNot
>
{renderBody({
body: trimmedBody,
customBody: typeof customBody === 'string' ? customBody : undefined,
customBody: trimmedCustomBody,
})}
{edited && <MessageEditedContent />}
</MessageTextBody>
@@ -197,7 +206,7 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
}
return (
<Attachment outlined={outlined}>
<Attachment outlined={outlined} transparent>
<AttachmentBox>
{renderImageContent({
body: content.body || 'Image',

View File

@@ -32,7 +32,9 @@ export const Reply = style({
export const ReplyContent = style({
opacity: config.opacity.P300,
overflow: 'clip',
overflowY: 'clip',
lineHeight: '1.5',
selectors: {
[`${Reply}:hover &`]: {
opacity: config.opacity.P500,

View File

@@ -8,6 +8,8 @@ export const RelativeBase = style([
display: 'flex',
maxWidth: '100%',
maxHeight: '100%',
borderRadius: '7px',
overflow: 'hidden',
},
]);
@@ -19,6 +21,8 @@ export const AbsoluteContainer = style([
justifyContent: 'center',
maxWidth: '100%',
maxHeight: '100%',
borderRadius: 'inherit',
overflow: 'hidden',
},
]);

View File

@@ -188,8 +188,10 @@ export const BubbleLeftArrow = style({
export const Username = style({
overflow: 'hidden',
overflowY: 'clip',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
lineHeight: '1.5',
// Allow text selection while keeping button clickable
userSelect: 'text',
WebkitUserSelect: 'text',
@@ -210,6 +212,9 @@ export const UsernameBold = style({
export const MessageTextBody = recipe({
base: {
wordBreak: 'break-word',
overflow: 'clip',
overflowY: 'clip',
lineHeight: '1.5',
},
variants: {
preWrap: {

View File

@@ -37,6 +37,13 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
const bannerUrl = useOtherUserBanner(userId, avatarMxc); // Now returns blob URL directly
const profileStyle = useOtherUserProfileStyle(userId, avatarMxc);
console.log('[UserHero]', {
userId,
avatarMxc,
bannerUrl,
profileStyle,
});
return (
<Box
direction="Column"
@@ -125,6 +132,13 @@ export function UserHeroName({ displayName, userId, avatarMxc }: UserHeroNamePro
const username = getMxIdLocalPart(userId);
const userColor = useOtherUserColor(userId, avatarMxc);
console.log('[UserHeroName]', {
userId,
avatarMxc,
userColor,
fallbackColor: colorMXID(userId),
});
return (
<Box grow="Yes" direction="Column" gap="100">
<Box alignItems="Baseline" gap="200" wrap="Wrap">

View File

@@ -26,7 +26,8 @@ import { DirectCreateSearchParams } from '../../pages/paths';
import { getContrastingTextColor, stripAlphaFromColor, getTextShadowColor } from '../../utils/common';
import classNames from 'classnames';
import { BreakWord, LineClamp3 } from '../../styles/Text.css';
import colorMXID from '../../../util/colorMXID';
import colorMXID, { getColorMXIDValue } from '../../../util/colorMXID';
import { useOtherUserColor } from '../../hooks/useUserColor';
type UserRoomProfileProps = {
userId: string;
@@ -64,6 +65,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
const presence = useUserPresence(userId);
const profileStyle = useOtherUserProfileStyle(userId, avatarMxc);
const userColor = useOtherUserColor(userId, avatarMxc);
// Build gradient CSS if configured
const gradientStyle = profileStyle.gradient
@@ -87,9 +89,25 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
(canKickUser && membership === Membership.Join) ||
(canBanUser && membership !== Membership.Ban);
const profileColor = profileStyle.avatarBorderColor ? stripAlphaFromColor(profileStyle.avatarBorderColor) : colorMXID(userId);
// Use userColor for text, or fall back to colorMXID
const profileColor = userColor ? userColor : getColorMXIDValue(userId);
const profileTextShadow = `0 1px 4px ${getTextShadowColor(profileColor)}`;
const pillBgColor = profileStyle.avatarBorderColor ? stripAlphaFromColor(profileStyle.avatarBorderColor) : undefined;
// Use avatarBorderColor for pill background only if it has visible alpha (not fully transparent)
const hasVisibleBorder = profileStyle.avatarBorderColor && !profileStyle.avatarBorderColor.endsWith('00');
const pillBgColor = hasVisibleBorder ? stripAlphaFromColor(profileStyle.avatarBorderColor!) : undefined;
console.log('[UserRoomProfile]', {
userId,
avatarMxc,
userColor,
profileStyle,
profileColor,
profileTextShadow,
hasVisibleBorder,
pillBgColor,
fallbackColorMXID: getColorMXIDValue(userId),
});
return (
<Box direction="Column">

View File

@@ -5,6 +5,7 @@ import FocusTrap from 'focus-trap-react';
import {
Avatar,
Box,
Button,
Header,
Icon,
IconButton,
@@ -62,6 +63,7 @@ export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerPro
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const scrollRef = useRef<HTMLDivElement>(null);
const [confirmRoomId, setConfirmRoomId] = useState<string | null>(null);
const mDirects = useAtomValue(mDirectAtom);
const allRoomsSet = useAllJoinedRoomsSet();
@@ -107,149 +109,208 @@ export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerPro
const handleRoomClick: React.MouseEventHandler<HTMLButtonElement> = (evt) => {
const roomId = evt.currentTarget.getAttribute('data-room-id');
if (roomId) {
onPick(roomId);
setConfirmRoomId(roomId);
}
};
const handleConfirm = useCallback(() => {
if (confirmRoomId) {
onPick(confirmRoomId);
}
}, [confirmRoomId, onPick]);
const handleCancelConfirm = useCallback(() => {
setConfirmRoomId(null);
}, []);
const previewText = share.text?.slice(0, 80) ?? share.subject?.slice(0, 80);
const fileCount = share.files.length;
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,
onDeactivate: onDismiss,
escapeDeactivates: stopPropagation,
<>
<Overlay open>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,
onDeactivate: onDismiss,
escapeDeactivates: stopPropagation,
}}
>
<Box
grow="Yes"
direction="Column"
style={{
position: 'fixed',
inset: 0,
zIndex: 999,
backgroundColor: '#1a1d23',
paddingTop: '60px',
}}
>
<Modal size="300">
<Box grow="Yes" direction="Column" style={{ maxHeight: '85vh' }}>
<Header
size="500"
style={{ padding: config.space.S200, paddingLeft: config.space.S400 }}
>
<Box grow="Yes" direction="Column" gap="100">
<Text size="H4">Share to Room</Text>
{(previewText || fileCount > 0) && (
<Text size="T200" priority="300" style={{ opacity: 0.7 }}>
{previewText
? `"${previewText}${(share.text?.length ?? 0) > 80 ? '…' : ''}"`
: `${fileCount} file${fileCount !== 1 ? 's' : ''}`}
</Text>
)}
</Box>
<Box shrink="No">
<IconButton size="300" radii="300" onClick={onDismiss}>
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Header>
<Header
size="500"
style={{ padding: config.space.S400 }}
>
<Box grow="Yes" direction="Column" gap="100" style={{ minWidth: 0, maxWidth: '80%' }}>
<Text size="H4">Share to Room</Text>
{(previewText || fileCount > 0) && (
<Text size="T200" priority="300" style={{ opacity: 0.7, wordBreak: 'break-word', whiteSpace: 'normal' }}>
{previewText
? `"${previewText}${(share.text?.length ?? 0) > 80 ? '…' : ''}"`
: `${fileCount} file${fileCount !== 1 ? 's' : ''}`}
</Text>
)}
</Box>
<Box shrink="No">
<IconButton size="300" radii="300" onClick={onDismiss}>
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Header>
<Box
style={{
padding: `0 ${config.space.S300}`,
paddingTop: config.space.S200,
paddingBottom: config.space.S200,
}}
>
<Input
onChange={handleSearchChange}
before={<Icon size="200" src={Icons.Search} />}
placeholder="Search rooms…"
size="400"
variant="Background"
outlined
autoFocus
/>
</Box>
<Box
style={{
padding: config.space.S200,
paddingTop: config.space.S300,
}}
>
<Input
onChange={handleSearchChange}
before={<Icon size="200" src={Icons.Search} />}
placeholder="Search rooms…"
size="400"
variant="Background"
outlined
autoFocus
style={{ width: '100%' }}
/>
</Box>
<Scroll ref={scrollRef} size="300" hideTrack style={{ flex: 1, minHeight: 0 }}>
<Scroll ref={scrollRef} size="300" hideTrack style={{ flex: 1, minHeight: 0 }}>
<Box style={{ padding: config.space.S300 }} direction="Column">
{vItems.length === 0 && (
<Box
style={{ padding: config.space.S300, paddingTop: 0 }}
style={{ padding: `${config.space.S700} 0` }}
alignItems="Center"
justifyContent="Center"
direction="Column"
gap="100"
>
{vItems.length === 0 && (
<Box
style={{ padding: `${config.space.S700} 0` }}
alignItems="Center"
justifyContent="Center"
direction="Column"
gap="100"
<Text size="H6" align="Center">
{searchResult ? 'No Match Found' : 'No Rooms'}
</Text>
<Text size="T200" align="Center" priority="300">
{searchResult
? `No rooms found for "${searchResult.query}".`
: 'You have no rooms to share into.'}
</Text>
</Box>
)}
<Box style={{ position: 'relative', height: virtualizer.getTotalSize() }}>
{vItems.map((vItem) => {
const roomId = items[vItem.index];
const room = getRoom(roomId);
if (!room) return null;
const isDm = mDirects.has(roomId);
const avatarSrc = isDm
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
: getRoomAvatarUrl(mx, room, 96, useAuthentication);
return (
<VirtualTile
virtualItem={vItem}
style={{ paddingBottom: config.space.S100 }}
ref={virtualizer.measureElement}
key={roomId}
>
<Text size="H6" align="Center">
{searchResult ? 'No Match Found' : 'No Rooms'}
</Text>
<Text size="T200" align="Center" priority="300">
{searchResult
? `No rooms found for "${searchResult.query}".`
: 'You have no rooms to share into.'}
</Text>
</Box>
)}
<Box
style={{ position: 'relative', height: virtualizer.getTotalSize() }}
>
{vItems.map((vItem) => {
const roomId = items[vItem.index];
const room = getRoom(roomId);
if (!room) return null;
const isDm = mDirects.has(roomId);
const avatarSrc = isDm
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
: getRoomAvatarUrl(mx, room, 96, useAuthentication);
return (
<VirtualTile
virtualItem={vItem}
style={{ paddingBottom: config.space.S100 }}
ref={virtualizer.measureElement}
key={roomId}
>
<MenuItem
data-room-id={roomId}
onClick={handleRoomClick}
variant="Surface"
size="400"
radii="400"
before={
<Avatar size="300" radii={isDm ? '400' : '300'}>
{avatarSrc ? (
<RoomAvatar
roomId={roomId}
src={avatarSrc}
alt={room.name}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(room.name)}
</Text>
)}
/>
) : (
<RoomIcon room={room} size="200" filled />
<MenuItem
data-room-id={roomId}
onClick={handleRoomClick}
variant="Surface"
size="400"
radii="400"
before={
<Avatar size="300" radii={isDm ? '400' : '300'}>
{avatarSrc ? (
<RoomAvatar
roomId={roomId}
src={avatarSrc}
alt={room.name}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(room.name)}
</Text>
)}
</Avatar>
}
>
<Box grow="Yes" direction="Column">
<Text size="B400" truncate>
{room.name}
</Text>
</Box>
</MenuItem>
</VirtualTile>
);
})}
/>
) : (
<RoomIcon room={room} size="200" filled />
)}
</Avatar>
}
>
<Box grow="Yes" direction="Column">
<Text size="B400" truncate>
{room.name}
</Text>
</Box>
</MenuItem>
</VirtualTile>
);
})}
</Box>
</Box>
</Scroll>
</Box>
</FocusTrap>
</Overlay>
{confirmRoomId && (() => {
const room = getRoom(confirmRoomId);
const roomName = room?.name ?? confirmRoomId;
const isDm = mDirects.has(confirmRoomId);
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleCancelConfirm,
escapeDeactivates: stopPropagation,
}}
>
<Modal style={{ maxWidth: '280px' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S300 }}>
<Box direction="Column" gap="100">
<Text size="H4">Share to {isDm ? 'Person' : 'Room'}?</Text>
<Text size="T300" priority="300">
Send to <strong>{roomName}</strong>?
</Text>
</Box>
<Box gap="200" justifyContent="End">
<Button
onClick={handleCancelConfirm}
variant="Secondary"
size="400"
>
<Text size="B400">Cancel</Text>
</Button>
<Button
onClick={handleConfirm}
variant="Primary"
size="400"
>
<Text size="B400">Share</Text>
</Button>
</Box>
</Box>
</Scroll>
</Box>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
})()}
</>
);
}

View File

@@ -125,6 +125,17 @@ import { useOtherUserColor } from '../../hooks/useUserColor';
import { getMemberAvatarMxc } from '../../utils/room';
import { convertEmoticons, convertEmoticonsInHtml } from '../../utils/emoticonConverter';
/**
* Creates a UUID used to group image events into one carousel.
*/
const createCarouselUuid = (): string => {
if (typeof globalThis.crypto?.randomUUID === 'function') {
return globalThis.crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
};
interface RoomInputProps {
editor: Editor;
fileDropContainerRef: RefObject<HTMLElement>;
@@ -305,12 +316,34 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
};
const handleSendUpload = async (uploads: UploadSuccess[]) => {
const imageUploads = uploads.filter((upload) => {
const fileItem = selectedFiles.find((f) => f.file === upload.file);
return !!fileItem && fileItem.file.type.startsWith('image');
});
const carouselUuid = imageUploads.length > 1 ? createCarouselUuid() : undefined;
const imageUploadIndexByFile = new Map(
imageUploads.map((upload, index) => [upload.file, index] as const)
);
const contentsPromises = uploads.map(async (upload) => {
const fileItem = selectedFiles.find((f) => f.file === upload.file);
if (!fileItem) throw new Error('Broken upload');
if (fileItem.file.type.startsWith('image')) {
return getImageMsgContent(mx, fileItem, upload.mxc);
const imageIndex = imageUploadIndexByFile.get(upload.file);
return getImageMsgContent(
mx,
fileItem,
upload.mxc,
carouselUuid !== undefined && imageIndex !== undefined
? {
uuid: carouselUuid,
index: imageIndex,
total: imageUploads.length,
}
: undefined
);
}
if (fileItem.file.type.startsWith('video')) {
return getVideoMsgContent(mx, fileItem, upload.mxc);
@@ -405,10 +438,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
return;
}
if (plainText === '') return;
let body = plainText.trim();
let formattedBody = customHtml.trim().replace(/(<br\s*\/?>\s*)+$/gi, '');
let body = plainText;
let formattedBody = customHtml;
if (body === '') return;
// Apply emoticon conversion if enabled
if (autoConvertEmoticons) {

View File

@@ -1,5 +1,6 @@
import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
import { DefaultReset, config } from 'folds';
import { style } from '@vanilla-extract/css';
import { DefaultReset, color, config, toRem } from 'folds';
export const TimelineFloat = recipe({
base: [
@@ -28,3 +29,82 @@ export const TimelineFloat = recipe({
});
export type TimelineFloatVariants = RecipeVariants<typeof TimelineFloat>;
export const CarouselScroller = style([
DefaultReset,
{
display: 'flex',
gap: config.space.S200,
overflowX: 'auto',
overflowY: 'hidden',
paddingBottom: config.space.S100,
width: '100%',
cursor: 'grab',
userSelect: 'none',
},
]);
export const CarouselScrollerDragging = style([
DefaultReset,
{
cursor: 'grabbing',
},
]);
export const CarouselItem = style([
DefaultReset,
{
flex: '0 0 auto',
width: `min(${toRem(380)}, 72vw)`,
maxWidth: '100%',
},
]);
export const CarouselEdgeGradient = recipe({
base: [
DefaultReset,
{
position: 'absolute',
top: 0,
height: '100%',
width: toRem(16),
pointerEvents: 'none',
zIndex: 1,
},
],
variants: {
position: {
Left: {
left: 0,
background: `linear-gradient(to right, ${color.Surface.Container}, rgba(116, 116, 116, 0))`,
},
Right: {
right: 0,
background: `linear-gradient(to left, ${color.Surface.Container}, rgba(116, 116, 116, 0))`,
},
},
},
});
export const CarouselScrollBtn = recipe({
base: [
DefaultReset,
{
position: 'absolute',
top: '50%',
zIndex: 2,
},
],
variants: {
position: {
Left: {
left: 0,
transform: 'translate(-25%, -50%)',
},
Right: {
right: 0,
transform: 'translate(25%, -50%)',
},
},
},
});

View File

@@ -19,6 +19,7 @@ import {
IContent,
MatrixClient,
MatrixEvent,
MsgType,
Room,
RoomEvent,
RoomEventHandlerMap,
@@ -35,6 +36,7 @@ import {
Chip,
ContainerColor,
Icon,
IconButton,
Icons,
Line,
Scroll,
@@ -52,7 +54,7 @@ import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
import { useAlive } from '../../hooks/useAlive';
import { editableActiveElement, scrollToBottom } from '../../utils/dom';
import { autoScrollToBottom, editableActiveElement } from '../../utils/dom';
import {
DefaultPlaceholder,
CompactPlaceholder,
@@ -62,6 +64,7 @@ import {
Time,
MessageNotDecryptedContent,
RedactedContent,
MImage,
MSticker,
ImageContent,
EventContent,
@@ -85,7 +88,7 @@ import {
reactionOrEditEvent,
} from '../../utils/room';
import { useSetting } from '../../state/hooks/settings';
import { MessageLayout, settingsAtom } from '../../state/settings';
import { MessageLayout, ScrollToLatestBehavior, settingsAtom } from '../../state/settings';
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
import { Reactions, Message, Event, EncryptedContent } from './message';
import { useMemberEventParser } from '../../hooks/useMemberEventParser';
@@ -105,6 +108,12 @@ import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
import { activeThreadIdAtomFamily } from '../../state/activeThread';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
import {
IImageContent,
PAARROT_CAROUSEL_INDEX_PROPERTY_NAME,
PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME,
PAARROT_CAROUSEL_UUID_PROPERTY_NAME,
} from '../../../types/matrix/common';
import { useKeyDown } from '../../hooks/useKeyDown';
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
import { RenderMessageContent } from '../../components/RenderMessageContent';
@@ -146,6 +155,55 @@ interface CallEventInfo {
mEvent: MatrixEvent;
}
interface CarouselEventMetadata {
uuid: string;
index: number;
total: number;
content: IImageContent;
}
interface CarouselRenderableEvent {
mEvent: MatrixEvent;
mEventId: string;
item: number;
content: IImageContent;
index: number;
}
/**
* Extracts carousel metadata from an m.image room message.
*/
const getCarouselEventMetadata = (mEvent: MatrixEvent): CarouselEventMetadata | undefined => {
if (mEvent.getType() !== MessageEvent.RoomMessage || mEvent.isRedacted()) {
return undefined;
}
const content = mEvent.getContent() as IImageContent;
const uuid = content[PAARROT_CAROUSEL_UUID_PROPERTY_NAME];
const index = content[PAARROT_CAROUSEL_INDEX_PROPERTY_NAME];
const total = content[PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME];
if (content.msgtype !== MsgType.Image) {
return undefined;
}
if (typeof uuid !== 'string') {
return undefined;
}
if (typeof index !== 'number' || !Number.isInteger(index) || index < 0) {
return undefined;
}
if (typeof total !== 'number' || !Number.isInteger(total) || total < 1) {
return undefined;
}
return {
uuid,
index,
total,
content,
};
};
const TimelineFloat = as<'div', css.TimelineFloatVariants>(
({ position, className, ...props }, ref) => (
<Box
@@ -169,6 +227,183 @@ const TimelineDivider = as<'div', { variant?: ContainerColor | 'Inherit' }>(
)
);
type CarouselScrollerProps = {
children: React.ReactNode;
};
const CarouselScroller = ({ children }: CarouselScrollerProps) => {
const scrollRef = useRef<HTMLDivElement>(null);
const backAnchorRef = useRef<HTMLDivElement>(null);
const frontAnchorRef = useRef<HTMLDivElement>(null);
const [backVisible, setBackVisible] = useState(true);
const [frontVisible, setFrontVisible] = useState(true);
const dragStateRef = useRef({
startX: 0,
startScrollLeft: 0,
dragging: false,
moved: false,
suppressClick: false,
});
const [isDragging, setIsDragging] = useState(false);
const intersectionObserver = useIntersectionObserver(
useCallback((entries) => {
const backAnchor = backAnchorRef.current;
const frontAnchor = frontAnchorRef.current;
const backEntry = backAnchor && getIntersectionObserverEntry(backAnchor, entries);
const frontEntry = frontAnchor && getIntersectionObserverEntry(frontAnchor, entries);
if (backEntry) {
setBackVisible(backEntry.isIntersecting);
}
if (frontEntry) {
setFrontVisible(frontEntry.isIntersecting);
}
}, []),
useCallback(
() => ({
root: scrollRef.current,
rootMargin: '10px',
}),
[]
)
);
useEffect(() => {
const backAnchor = backAnchorRef.current;
const frontAnchor = frontAnchorRef.current;
if (backAnchor) intersectionObserver?.observe(backAnchor);
if (frontAnchor) intersectionObserver?.observe(frontAnchor);
return () => {
if (backAnchor) intersectionObserver?.unobserve(backAnchor);
if (frontAnchor) intersectionObserver?.unobserve(frontAnchor);
};
}, [intersectionObserver]);
const handleMouseDown = (evt: React.MouseEvent<HTMLDivElement>) => {
if (evt.button !== 0) return;
const scroll = scrollRef.current;
if (!scroll) return;
const dragState = dragStateRef.current;
dragState.startX = evt.clientX;
dragState.startScrollLeft = scroll.scrollLeft;
dragState.dragging = true;
dragState.moved = false;
dragState.suppressClick = false;
setIsDragging(true);
};
const handleMouseMove = (evt: React.MouseEvent<HTMLDivElement>) => {
const dragState = dragStateRef.current;
if (!dragState.dragging) {
return;
}
evt.preventDefault();
const scroll = scrollRef.current;
if (!scroll) return;
const deltaX = evt.clientX - dragState.startX;
if (Math.abs(deltaX) > 5) {
dragState.moved = true;
}
scroll.scrollLeft = dragState.startScrollLeft - deltaX;
};
const endDrag = () => {
const dragState = dragStateRef.current;
if (!dragState.dragging) return;
if (dragState.moved) {
dragState.suppressClick = true;
}
dragState.dragging = false;
setIsDragging(false);
};
const handleClickCapture = (evt: React.MouseEvent<HTMLDivElement>) => {
const dragState = dragStateRef.current;
if (!dragState.suppressClick) {
return;
}
dragState.suppressClick = false;
evt.preventDefault();
evt.stopPropagation();
};
const handleScrollFront = () => {
const scroll = scrollRef.current;
if (!scroll) return;
scroll.scrollTo({
left: scroll.scrollLeft + scroll.offsetWidth / 1.3,
behavior: 'smooth',
});
};
const handleScrollBack = () => {
const scroll = scrollRef.current;
if (!scroll) return;
scroll.scrollTo({
left: scroll.scrollLeft - scroll.offsetWidth / 1.3,
behavior: 'smooth',
});
};
return (
<Box style={{ position: 'relative' }}>
<Box
ref={scrollRef}
className={classNames(css.CarouselScroller, isDragging && css.CarouselScrollerDragging)}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={endDrag}
onMouseLeave={endDrag}
onClickCapture={handleClickCapture}
>
<div ref={backAnchorRef} />
{children}
<div ref={frontAnchorRef} />
</Box>
{!backVisible && (
<>
<div className={css.CarouselEdgeGradient({ position: 'Left' })} />
<IconButton
className={css.CarouselScrollBtn({ position: 'Left' })}
variant="Secondary"
radii="Pill"
size="300"
outlined
onClick={handleScrollBack}
>
<Icon size="300" src={Icons.ArrowLeft} />
</IconButton>
</>
)}
{!frontVisible && (
<>
<div className={css.CarouselEdgeGradient({ position: 'Right' })} />
<IconButton
className={css.CarouselScrollBtn({ position: 'Right' })}
variant="Primary"
radii="Pill"
size="300"
outlined
onClick={handleScrollFront}
>
<Icon size="300" src={Icons.ArrowRight} />
</IconButton>
</>
)}
</Box>
);
};
export const getLiveTimeline = (room: Room): EventTimeline =>
room.getUnfilteredTimelineSet().getLiveTimeline();
@@ -474,6 +709,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const [scrollToLatestBehavior] = useSetting(settingsAtom, 'scrollToLatestBehavior');
// Track consecutive call member events for collapsing
const pendingCallEventsRef = useRef<CallEventInfo[]>([]);
@@ -513,10 +749,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const space = useSpaceOptionally();
const markAsRead = useMarkAsRead(mx);
const location = useLocation();
const scrollToLatest = (location.state as { scrollToLatest?: boolean } | null)?.scrollToLatest;
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
const [unreadInfo, setUnreadInfo] = useState(() => getRoomUnreadInfo(room, true));
const [unreadInfo, setUnreadInfo] = useState(() =>
getRoomUnreadInfo(room, scrollToLatestBehavior !== ScrollToLatestBehavior.Always)
);
const readUptoEventIdRef = useRef<string>();
if (unreadInfo) {
readUptoEventIdRef.current = unreadInfo.readUptoEventId;
@@ -527,16 +766,18 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const atBottomRef = useRef(atBottom);
atBottomRef.current = atBottom;
const shouldAutoScrollRef = useRef<boolean>(true);
const [latestUnreadMessage, setLatestUnreadMessage] = useState<{
sender: string;
content: string;
} | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const timelineContentRef = useRef<HTMLDivElement>(null);
const timelineContentHeightRef = useRef(0);
const scrollToBottomRef = useRef({
count: 0,
smooth: true,
force: false,
});
const [focusItem, setFocusItem] = useState<
@@ -636,6 +877,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
setTimeline(getInitialTimeline(room));
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = false;
scrollToBottomRef.current.force = true;
}, [alive, room])
);
@@ -644,52 +886,49 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
useCallback(
(mEvt: MatrixEvent) => {
const isOwnMessage = mEvt.getSender() === mx.getUserId();
const shouldScroll = atBottomRef.current || (shouldAutoScrollRef.current && isOwnMessage);
const shouldStickToBottom = atBottomRef.current || isOwnMessage;
if (shouldScroll) {
if (isOwnMessage) {
shouldAutoScrollRef.current = true;
}
if (document.hasFocus() && (!unreadInfo || isOwnMessage)) {
requestAnimationFrame(() => markAsRead(mEvt.getRoomId()!, hideActivity));
}
if (!document.hasFocus() && !unreadInfo) {
setUnreadInfo(getRoomUnreadInfo(room));
}
// Always update timeline with new message
setTimeline((ct) => ({
...ct,
range: {
start: ct.range.start + 1,
end: ct.range.end + 1,
},
}));
// Handle own messages and focus
if (isOwnMessage) {
setLatestUnreadMessage(null);
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = true;
setTimeline((ct) => ({
...ct,
range: {
start: ct.range.start + 1,
end: ct.range.end + 1,
},
}));
return;
if (document.hasFocus() && (!unreadInfo)) {
const roomId = mEvt.getRoomId();
if (roomId) {
requestAnimationFrame(() => markAsRead(roomId, hideActivity));
}
}
} else if (!document.hasFocus() && !unreadInfo) {
// Show unread notification for other users' messages when unfocused
setUnreadInfo(getRoomUnreadInfo(room));
}
if (!isOwnMessage) {
shouldAutoScrollRef.current = false;
const senderName = getMemberDisplayName(room, mEvt.getSender() ?? '')
?? getMxIdLocalPart(mEvt.getSender() ?? '')
?? 'Unknown';
// Preserve the pre-update bottom state so a newly added message keeps the view pinned.
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = !isOwnMessage;
scrollToBottomRef.current.force = shouldStickToBottom;
// Show unread message preview for incoming messages when not at bottom
if (!isOwnMessage && !atBottomRef.current) {
const senderName =
getMemberDisplayName(room, mEvt.getSender() ?? '') ??
getMxIdLocalPart(mEvt.getSender() ?? '') ??
'Unknown';
const content = mEvt.getContent();
const messageText = content.body ?? content.msgtype ?? 'New message';
setLatestUnreadMessage({
sender: senderName,
content: messageText.length > 100 ? messageText.substring(0, 100) + '...' : messageText,
content: messageText.length > 100 ? `${messageText.substring(0, 100)}...` : messageText,
});
}
setTimeline((ct) => ({ ...ct }));
if (!unreadInfo) {
setUnreadInfo(getRoomUnreadInfo(room));
}
},
[mx, room, unreadInfo, hideActivity, markAsRead]
)
@@ -750,13 +989,35 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
if (!editorBaseEntry || !scrollElement) return;
if (atBottomRef.current) {
scrollToBottom(scrollElement);
autoScrollToBottom(scrollElement, false, true);
}
};
}, [getScrollElement, roomInputRef]),
useCallback(() => roomInputRef.current, [roomInputRef])
);
useResizeObserver(
useCallback((entries) => {
const timelineContent = timelineContentRef.current;
const scrollElement = getScrollElement();
if (!timelineContent || !scrollElement) return;
const timelineEntry = getResizeObserverEntry(timelineContent, entries);
if (!timelineEntry) return;
const nextHeight = timelineEntry.contentRect.height;
const previousHeight = timelineContentHeightRef.current;
timelineContentHeightRef.current = nextHeight;
if (previousHeight === 0 || nextHeight <= previousHeight || !atBottomRef.current) {
return;
}
autoScrollToBottom(scrollElement, false, true);
}, [getScrollElement]),
useCallback(() => timelineContentRef.current, [])
);
const tryAutoMarkAsRead = useCallback(() => {
const readUptoEventId = readUptoEventIdRef.current;
if (!readUptoEventId) {
@@ -774,7 +1035,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
useCallback((entry: IntersectionObserverEntry) => {
if (!entry.isIntersecting) {
setAtBottom(false);
shouldAutoScrollRef.current = false;
}
}, []),
{ wait: 1000 }
@@ -788,7 +1048,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
if (targetEntry) debounceSetAtBottom(targetEntry);
if (targetEntry?.isIntersecting && atLiveEndRef.current) {
setAtBottom(true);
shouldAutoScrollRef.current = true;
setLatestUnreadMessage(null);
if (document.hasFocus()) {
tryAutoMarkAsRead();
@@ -861,22 +1120,21 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
// Scroll to latest when clicking on already-selected room
useEffect(() => {
const scrollToLatest = (location.state as any)?.scrollToLatest;
if (scrollToLatest) {
shouldAutoScrollRef.current = true;
setLatestUnreadMessage(null);
setUnreadInfo(undefined);
setTimeline(getInitialTimeline(room));
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = false;
scrollToBottomRef.current.force = true;
}
}, [(location.state as any)?.scrollToLatest, room]);
}, [scrollToLatest, room]);
// Scroll to bottom on initial timeline load
useLayoutEffect(() => {
const scrollEl = scrollRef.current;
if (scrollEl) {
scrollToBottom(scrollEl);
autoScrollToBottom(scrollEl, false, true);
}
}, []);
@@ -923,8 +1181,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
useLayoutEffect(() => {
if (scrollToBottomCount > 0) {
const scrollEl = scrollRef.current;
if (scrollEl)
scrollToBottom(scrollEl, scrollToBottomRef.current.smooth ? 'smooth' : 'instant');
if (scrollEl) {
autoScrollToBottom(
scrollEl,
scrollToBottomRef.current.smooth,
scrollToBottomRef.current.force
);
}
}
}, [scrollToBottomCount]);
@@ -977,11 +1240,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
if (eventId) {
navigateRoom(room.roomId, undefined, { replace: true });
}
shouldAutoScrollRef.current = true;
setLatestUnreadMessage(null);
setTimeline(getInitialTimeline(room));
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = false;
scrollToBottomRef.current.force = true;
};
const handleJumpToUnread = () => {
@@ -1720,6 +1983,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
let isPrevRendered = false;
let newDivider = false;
let dayDivider = false;
const groupedCarouselEventIds = new Set<string>();
// Clear pending call events at the start of each render
pendingCallEventsRef.current = [];
@@ -1841,6 +2105,106 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
);
};
const renderImageCarouselMessage = (
carouselEvents: CarouselRenderableEvent[],
mEventId: string,
mEvent: MatrixEvent,
item: number,
timelineSet: EventTimelineSet,
collapse: boolean
): React.ReactNode => {
const reactionRelations = getEventReactions(timelineSet, mEventId);
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
const hasReactions = reactions && reactions.length > 0;
const { replyEventId, threadRootId } = mEvent;
const highlighted = focusItem?.index === item && focusItem.highlight;
const senderId = mEvent.getSender() ?? '';
const orderedCarouselEvents = [...carouselEvents].sort((a, b) => a.index - b.index);
return (
<Message
key={`carousel-${mEventId}`}
data-message-item={item}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
messageSpacing={messageSpacing}
messageLayout={messageLayout}
collapse={collapse}
highlight={highlighted}
edit={editId === mEventId}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
canSendReaction={canSendReaction}
canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms}
relations={hasReactions ? reactionRelations : undefined}
onUserClick={handleUserClick}
onUsernameClick={handleUsernameClick}
onReplyClick={handleReplyClick}
onReactionToggle={handleReactionToggle}
onEditId={handleEdit}
reply={
replyEventId && (
<Reply
room={room}
timelineSet={timelineSet}
replyEventId={replyEventId}
threadRootId={threadRootId}
onClick={handleOpenReply}
onOpenThread={handleOpenThread}
getMemberPowerTag={getMemberPowerTag}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
/>
)
}
reactions={
reactionRelations && (
<Reactions
style={{ marginTop: config.space.S200 }}
room={room}
relations={reactionRelations}
mEventId={mEventId}
canSendReaction={canSendReaction}
onReactionToggle={handleReactionToggle}
/>
)
}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
memberPowerTag={getMemberPowerTag(senderId)}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
>
{mEvent.isRedacted() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
) : (
<CarouselScroller>
{orderedCarouselEvents.map((carouselEvent) => (
<Box key={carouselEvent.mEventId} className={css.CarouselItem}>
<MImage
content={carouselEvent.content}
renderImageContent={(props) => (
<ImageContent
{...props}
autoPlay={mediaAutoLoad}
renderImage={(p) => <Image {...p} loading="lazy" />}
renderViewer={(p) => <ImageViewer {...p} />}
/>
)}
outlined={messageLayout === MessageLayout.Bubble}
/>
</Box>
))}
</CarouselScroller>
)}
</Message>
);
};
const eventRenderer = (item: number, index: number, allItems: number[]) => {
const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item);
if (!eventTimeline) return null;
@@ -1874,17 +2238,77 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
prevEvent.getType() === mEvent.getType() &&
minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2;
const rawEventJSX = reactionOrEditEvent(mEvent)
? null
: renderMatrixEvent(
mEvent.getType(),
typeof mEvent.getStateKey() === 'string',
mEventId,
mEvent,
item,
timelineSet,
collapsed
);
if (groupedCarouselEventIds.has(mEventId)) {
prevEvent = mEvent;
isPrevRendered = true;
return null;
}
const getEventByItem = (
timelineItem: number
): { event: MatrixEvent; eventId: string } | undefined => {
const [tm, bIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, timelineItem);
if (!tm) return undefined;
const event = getTimelineEvent(tm, getTimelineRelativeIndex(timelineItem, bIndex));
const eventId = event?.getId();
if (!event || !eventId) return undefined;
return { event, eventId };
};
const carouselMetadata = getCarouselEventMetadata(mEvent);
const carouselEvents: CarouselRenderableEvent[] = [];
if (carouselMetadata && eventSender) {
carouselEvents.push({
mEvent,
mEventId,
item,
content: carouselMetadata.content,
index: carouselMetadata.index,
});
for (let nextIndex = index + 1; nextIndex < allItems.length; nextIndex += 1) {
const nextEvt = getEventByItem(allItems[nextIndex]);
if (!nextEvt) break;
const nextSender = nextEvt.event.getSender();
const nextMeta = getCarouselEventMetadata(nextEvt.event);
if (!nextMeta || nextSender !== eventSender || nextMeta.uuid !== carouselMetadata.uuid) {
break;
}
groupedCarouselEventIds.add(nextEvt.eventId);
carouselEvents.push({
mEvent: nextEvt.event,
mEventId: nextEvt.eventId,
item: allItems[nextIndex],
content: nextMeta.content,
index: nextMeta.index,
});
if (carouselEvents.length >= carouselMetadata.total) {
break;
}
}
}
let rawEventJSX: React.ReactNode = null;
if (!reactionOrEditEvent(mEvent)) {
rawEventJSX =
carouselEvents.length > 1
? renderImageCarouselMessage(carouselEvents, mEventId, mEvent, item, timelineSet, collapsed)
: renderMatrixEvent(
mEvent.getType(),
typeof mEvent.getStateKey() === 'string',
mEventId,
mEvent,
item,
timelineSet,
collapsed
);
}
// Check if this is a call event that should be grouped
const isCallEventPending = rawEventJSX === ('CALL_EVENT_PENDING' as unknown as React.ReactNode);
@@ -1992,6 +2416,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
)}
<Scroll ref={scrollRef} visibility="Hover">
<Box
ref={timelineContentRef}
direction="Column"
justifyContent="End"
style={{ minHeight: '100%', padding: `${config.space.S600} 0` }}

View File

@@ -24,4 +24,7 @@ export const RoomViewTyping = style([
]);
export const TypingText = style({
flexGrow: 1,
overflow: 'clip',
overflowY: 'clip',
lineHeight: '1.5',
});

View File

@@ -96,7 +96,7 @@ import {
} from '../../utils/matrix';
import { GetContentCallback, MessageEvent } from '../../../types/matrix/room';
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
import { scrollToBottom } from '../../utils/dom';
import { autoScrollToBottom, scrollElementIntoView } from '../../utils/dom';
type ThreadViewProps = {
room: Room;
@@ -406,7 +406,7 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
// Scroll to bottom on mount and when new events arrive.
useLayoutEffect(() => {
const el = scrollRef.current;
if (el) scrollToBottom(el);
if (el) autoScrollToBottom(el);
}, [events.length]);
const handleClose = useCallback(() => {
@@ -481,7 +481,7 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
const el = scrollRef.current?.querySelector(
`[data-message-id="${targetId}"]`
) as HTMLElement | null;
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
if (el) scrollElementIntoView(el, { behavior: 'smooth', block: 'center' });
}, []);
const handleReactionToggle = useCallback(

View File

@@ -62,6 +62,8 @@ import {
import { MessageLayout, MessageSpacing } from '../../../state/settings';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
import { useEmojiUsage } from '../../../hooks/useEmojiUsage';
import { emojis } from '../../../plugins/emoji';
import * as css from './styles.css';
import { EventReaders } from '../../../components/event-readers';
import { TextViewer } from '../../../components/text-viewer';
@@ -91,10 +93,13 @@ type MessageQuickReactionsProps = {
};
export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>(
({ onReaction, ...props }, ref) => {
const mx = useMatrixClient();
const recentEmojis = useRecentEmoji(mx, 4);
const { getTopEmojis } = useEmojiUsage();
const topShortcodes = getTopEmojis(4);
const mostUsedEmojis = topShortcodes
.map((shortcode) => emojis.find((e) => e.shortcode === shortcode))
.filter((e): e is IEmoji => e !== undefined);
if (recentEmojis.length === 0) return <span />;
if (mostUsedEmojis.length === 0) return <span />;
return (
<>
<Box
@@ -105,7 +110,7 @@ export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>(
{...props}
ref={ref}
>
{recentEmojis.map((emoji) => (
{mostUsedEmojis.map((emoji) => (
<IconButton
key={emoji.unicode}
className={css.MessageQuickReaction}

View File

@@ -4,6 +4,9 @@ import {
IThumbnailContent,
MATRIX_BLUR_HASH_PROPERTY_NAME,
MATRIX_SPOILER_PROPERTY_NAME,
PAARROT_CAROUSEL_INDEX_PROPERTY_NAME,
PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME,
PAARROT_CAROUSEL_UUID_PROPERTY_NAME,
} from '../../../types/matrix/common';
import {
getImageFileUrl,
@@ -18,6 +21,12 @@ import { TUploadItem } from '../../state/room/roomInputDrafts';
import { encodeBlurHash } from '../../utils/blurHash';
import { scaleYDimension } from '../../utils/common';
type CarouselMetadata = {
uuid: string;
index: number;
total: number;
};
const generateThumbnailContent = async (
mx: MatrixClient,
img: HTMLImageElement | HTMLVideoElement,
@@ -46,7 +55,8 @@ const generateThumbnailContent = async (
export const getImageMsgContent = async (
mx: MatrixClient,
item: TUploadItem,
mxc: string
mxc: string,
carouselMetadata?: CarouselMetadata
): Promise<IContent> => {
const { file, originalFile, encInfo, metadata } = item;
const [imgError, imgEl] = await to(loadImageElement(getImageFileUrl(originalFile)));
@@ -74,6 +84,11 @@ export const getImageMsgContent = async (
} else {
content.url = mxc;
}
if (carouselMetadata) {
content[PAARROT_CAROUSEL_UUID_PROPERTY_NAME] = carouselMetadata.uuid;
content[PAARROT_CAROUSEL_INDEX_PROPERTY_NAME] = carouselMetadata.index;
content[PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME] = carouselMetadata.total;
}
return content;
};

View File

@@ -55,7 +55,7 @@ import { useUserProfileStyle } from '../../../hooks/useUserProfileStyle';
import { useUserPresence } from '../../../hooks/useUserPresence';
import { AvatarPresence, PresenceBadge } from '../../../components/presence';
import { BreakWord, LineClamp3 } from '../../../styles/Text.css';
import colorMXID from '../../../../util/colorMXID';
import colorMXID, { getColorMXIDValue } from '../../../../util/colorMXID';
import {
extractMetadataFromImage,
embedMetadataInImage,
@@ -574,7 +574,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
: profileStyle.avatarBorderColor;
const previewPillBgColor = previewBorderColor ? stripAlphaFromColor(previewBorderColor) : undefined;
const previewProfileColor = userColor || colorMXID(userId);
const previewProfileColor = userColor || getColorMXIDValue(userId);
const previewTextShadow = `0 1px 4px ${getTextShadowColor(previewProfileColor)}`;

View File

@@ -454,7 +454,11 @@ export function Audio({ requestClose }: AudioProps) {
const [micTestState, setMicTestState] = useState<'idle' | 'testing' | 'done' | 'error'>('idle');
const [micLevel, setMicLevel] = useState(0);
const [speakerTestState, setSpeakerTestState] = useState<'idle' | 'testing' | 'done' | 'error'>('idle');
const [micMonitoring, setMicMonitoring] = useState(false);
const micAnimFrameRef = useRef<number | null>(null);
const micMonitorContextRef = useRef<AudioContext | null>(null);
const micMonitorStreamRef = useRef<MediaStream | null>(null);
const micMonitorGainRef = useRef<GainNode | null>(null);
const [showRemoteCursor, setShowRemoteCursor] = useSetting(settingsAtom, 'showRemoteCursor');
useEffect(() => {
@@ -627,6 +631,101 @@ export function Audio({ requestClose }: AudioProps) {
}
}, [speakerTestState]);
const handleMicMonitoringToggle = useCallback(async () => {
if (micMonitoring) {
// Stop monitoring
if (micAnimFrameRef.current !== null) {
cancelAnimationFrame(micAnimFrameRef.current);
micAnimFrameRef.current = null;
}
if (micMonitorStreamRef.current) {
micMonitorStreamRef.current.getTracks().forEach((track) => track.stop());
micMonitorStreamRef.current = null;
}
if (micMonitorContextRef.current) {
micMonitorContextRef.current.close();
micMonitorContextRef.current = null;
}
micMonitorGainRef.current = null;
setMicLevel(0);
setMicMonitoring(false);
} else {
// Start monitoring
try {
const constraints: MediaStreamConstraints = {
audio: settings.microphoneId
? {
deviceId: { exact: settings.microphoneId },
noiseSuppression: settings.noiseSuppression,
echoCancellation: settings.echoCancellation,
autoGainControl: settings.autoGainControl,
}
: {
noiseSuppression: settings.noiseSuppression,
echoCancellation: settings.echoCancellation,
autoGainControl: settings.autoGainControl,
},
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
const audioContext = new AudioContext();
const analyser = audioContext.createAnalyser();
analyser.fftSize = 256;
const dataArray = new Uint8Array(analyser.frequencyBinCount);
const microphone = audioContext.createMediaStreamSource(stream);
const gainNode = audioContext.createGain();
// Connect microphone to analyser for level monitoring
microphone.connect(analyser);
// Connect microphone to speakers for playback
microphone.connect(gainNode);
gainNode.connect(audioContext.destination);
gainNode.gain.value = 1.0; // Full volume playback
micMonitorContextRef.current = audioContext;
micMonitorStreamRef.current = stream;
micMonitorGainRef.current = gainNode;
const tick = () => {
analyser.getByteFrequencyData(dataArray);
const avg = dataArray.reduce((a, b) => a + b, 0) / dataArray.length;
setMicLevel(Math.min(100, Math.round((avg / 255) * 100 * 3)));
micAnimFrameRef.current = requestAnimationFrame(tick);
};
micAnimFrameRef.current = requestAnimationFrame(tick);
setMicMonitoring(true);
} catch (e) {
console.error('Microphone monitoring failed:', e);
}
}
}, [micMonitoring, settings.microphoneId, settings.noiseSuppression, settings.echoCancellation, settings.autoGainControl]);
// Cleanup monitoring on unmount or when settings change
useEffect(() => {
return () => {
if (micAnimFrameRef.current !== null) {
cancelAnimationFrame(micAnimFrameRef.current);
}
if (micMonitorStreamRef.current) {
micMonitorStreamRef.current.getTracks().forEach((track) => track.stop());
}
if (micMonitorContextRef.current) {
micMonitorContextRef.current.close();
}
};
}, []);
// Restart monitoring when mic settings change
useEffect(() => {
if (micMonitoring) {
handleMicMonitoringToggle(); // Stop
setTimeout(() => handleMicMonitoringToggle(), 100); // Restart
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings.microphoneId, settings.noiseSuppression, settings.echoCancellation, settings.autoGainControl]);
return (
<Page>
<PageHeader>
@@ -686,21 +785,35 @@ export function Audio({ requestClose }: AudioProps) {
onSelect={handleMicrophoneSelect}
/>
<Box direction="Column" gap="100" alignItems="End">
<Button
size="300"
variant={micTestState === 'error' ? 'Critical' : micTestState === 'done' ? 'Success' : 'Secondary'}
fill="Soft"
radii="300"
onClick={handleTestMicrophone}
disabled={micTestState === 'testing'}
>
<Text size="T300">
{micTestState === 'testing' ? 'Testing...' : micTestState === 'done' ? 'Done ✓' : micTestState === 'error' ? 'Failed' : 'Test'}
</Text>
</Button>
{micTestState === 'testing' && (
<Box style={{ width: toRem(80), height: toRem(6), background: color.Surface.Container, borderRadius: toRem(3), overflow: 'hidden' }}>
<Box style={{ width: `${micLevel}%`, height: '100%', background: color.Primary.Main, transition: 'width 0.05s ease' }} />
<Box gap="200" alignItems="Center">
<Button
size="300"
variant={micTestState === 'error' ? 'Critical' : micTestState === 'done' ? 'Success' : 'Secondary'}
fill="Soft"
radii="300"
onClick={handleTestMicrophone}
disabled={micTestState === 'testing' || micMonitoring}
>
<Text size="T300">
{micTestState === 'testing' ? 'Testing...' : micTestState === 'done' ? 'Done ✓' : micTestState === 'error' ? 'Failed' : 'Test'}
</Text>
</Button>
<Button
size="300"
variant={micMonitoring ? 'Primary' : 'Secondary'}
fill={micMonitoring ? 'Solid' : 'Soft'}
radii="300"
onClick={handleMicMonitoringToggle}
disabled={micTestState === 'testing'}
>
<Text size="T300">
{micMonitoring ? 'Stop' : 'Monitor'}
</Text>
</Button>
</Box>
{(micTestState === 'testing' || micMonitoring) && (
<Box style={{ width: toRem(160), height: toRem(6), background: color.Surface.Container, borderRadius: toRem(3), overflow: 'hidden' }}>
<Box style={{ width: `${micLevel}%`, height: '100%', background: micMonitoring ? color.Success.Main : color.Primary.Main, transition: 'width 0.05s ease' }} />
</Box>
)}
</Box>

View File

@@ -0,0 +1,104 @@
import { useCallback, useMemo } from 'react';
import { useMatrixClient } from './useMatrixClient';
import { useAccountData } from './useAccountData';
const PAARROT_EMOJI_USAGE_EVENT = 'paarrot.favemojis';
const MAX_TRACKED_EMOJIS = 50;
export type EmojiUsageData = {
[shortcode: string]: {
count: number;
lastUsed: number;
};
};
/**
* Hook for tracking emoji usage and storing favorites in Matrix account data
* Stores emoji usage stats under paarrot.favemojis in user's account data
*/
export function useEmojiUsage() {
const mx = useMatrixClient();
const event = useAccountData(PAARROT_EMOJI_USAGE_EVENT);
const emojiUsage = useMemo((): EmojiUsageData => {
const content = event?.getContent<EmojiUsageData>();
return content || {};
}, [event]);
/**
* Track emoji usage (max 1 per message as per spec)
* @param shortcode - Emoji shortcode (e.g., 'thumbsup', 'smile')
*/
const trackEmojiUsage = useCallback(
async (shortcode: string) => {
const now = Date.now();
const currentUsage = { ...emojiUsage };
// Update or create emoji entry
currentUsage[shortcode] = {
count: (currentUsage[shortcode]?.count || 0) + 1,
lastUsed: now,
};
// Keep only top MAX_TRACKED_EMOJIS by usage count
const entries = Object.entries(currentUsage);
if (entries.length > MAX_TRACKED_EMOJIS) {
entries.sort((a, b) => b[1].count - a[1].count);
const topEmojis = entries.slice(0, MAX_TRACKED_EMOJIS);
const newUsage: EmojiUsageData = {};
topEmojis.forEach(([key, value]) => {
newUsage[key] = value;
});
await mx.setAccountData(PAARROT_EMOJI_USAGE_EVENT, newUsage);
} else {
await mx.setAccountData(PAARROT_EMOJI_USAGE_EVENT, currentUsage);
}
},
[mx, emojiUsage]
);
/**
* Get most used emoji shortcode
* @returns Shortcode of most used emoji, or undefined if none tracked
*/
const getMostUsedEmoji = useCallback((): string | undefined => {
const entries = Object.entries(emojiUsage);
if (entries.length === 0) return undefined;
// Sort by count descending, then by lastUsed descending
entries.sort((a, b) => {
if (b[1].count !== a[1].count) {
return b[1].count - a[1].count;
}
return b[1].lastUsed - a[1].lastUsed;
});
return entries[0][0];
}, [emojiUsage]);
/**
* Get top N most used emojis
* @param count - Number of top emojis to return
* @returns Array of shortcodes sorted by usage
*/
const getTopEmojis = useCallback(
(count: number = 10): string[] => {
const entries = Object.entries(emojiUsage);
entries.sort((a, b) => {
if (b[1].count !== a[1].count) {
return b[1].count - a[1].count;
}
return b[1].lastUsed - a[1].lastUsed;
});
return entries.slice(0, count).map(([shortcode]) => shortcode);
},
[emojiUsage]
);
return {
emojiUsage,
trackEmojiUsage,
getMostUsedEmoji,
getTopEmojis,
};
}

View File

@@ -73,6 +73,7 @@ export function useUserBanner(): [
const profile = await mx.getProfileInfo(userId);
const avatarUrl = profile.avatar_url;
console.log('[useUserBanner.loadBanner] Loading banner from avatar:', avatarUrl);
if (!avatarUrl) {
setBanner(undefined);
@@ -90,6 +91,7 @@ export function useUserBanner(): [
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
console.log('[useUserBanner.loadBanner] Extracted banner:', metadata.banner);
setBanner(metadata.banner);
} catch {
setBanner(undefined);
@@ -104,7 +106,8 @@ export function useUserBanner(): [
if (!userId) return undefined;
const user = mx.getUser(userId);
const onAvatarChange: UserEventHandlerMap[UserEvent.AvatarUrl] = () => {
const onAvatarChange: UserEventHandlerMap[UserEvent.AvatarUrl] = (newAvatarUrl) => {
console.log('[useUserBanner] Avatar changed event fired, new URL:', newAvatarUrl);
loadBanner();
};
@@ -143,12 +146,16 @@ export function useUserBanner(): [
// Get existing metadata to preserve
const existingMetadata = extractMetadataFromImage(avatarData);
console.log('[updateBanner] Existing metadata:', existingMetadata);
// Modify image metadata with new banner, preserving color
const newMetadata: ImageMetadata = {
color: existingMetadata.color,
banner: newBanner,
avatarBorderColor: existingMetadata.avatarBorderColor,
gradient: existingMetadata.gradient,
};
console.log('[updateBanner] New metadata to embed:', newMetadata);
const newAvatarData = embedMetadataInImage(avatarData, newMetadata);
@@ -158,10 +165,13 @@ export function useUserBanner(): [
// Verify the banner was embedded correctly before uploading
const verifyMetadata = extractMetadataFromImage(newAvatarData);
console.log('[updateBanner] Verification - extracted metadata from new avatar:', verifyMetadata);
if (newBanner && verifyMetadata.banner !== newBanner) {
console.error('[updateBanner] Banner verification FAILED!', 'Expected:', newBanner, 'Got:', verifyMetadata.banner);
throw new Error('Banner verification failed');
}
console.log('[updateBanner] Banner verification passed');
// Upload modified avatar with correct MIME type
const mimeType = getMimeType(format);
@@ -172,21 +182,26 @@ export function useUserBanner(): [
name: `avatar.${extension}`,
type: mimeType,
});
console.log('[updateBanner] Avatar uploaded successfully, MXC:', uploadResponse.content_uri);
// Update profile with new avatar
try {
console.log('[updateBanner] Setting avatar URL to:', uploadResponse.content_uri);
await Promise.race([
mx.setAvatarUrl(uploadResponse.content_uri),
new Promise((_, reject) => {
setTimeout(() => reject(new Error('setAvatarUrl timeout')), 30000);
})
]);
console.log('[updateBanner] setAvatarUrl completed');
// Manually sync user object to ensure event listeners are triggered
const user = mx.getUser(userId);
if (user && user.avatarUrl !== uploadResponse.content_uri) {
console.log('[updateBanner] Manually syncing user avatar URL');
user.setAvatarUrl(uploadResponse.content_uri);
}
console.log('[updateBanner] Banner update complete');
} catch (error) {
throw new Error(`Failed to update avatar URL: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
@@ -217,11 +232,14 @@ export function useOtherUserBanner(userId: string, avatarMxc: string | undefined
let blobUrl: string | undefined;
if (!avatarMxc) {
console.log('[useOtherUserBanner] No avatarMxc provided for', userId);
setBannerBlobUrl(undefined);
return undefined;
}
const loadBanner = async () => {
console.log('[useOtherUserBanner] Loading banner for', userId, 'avatarMxc:', avatarMxc);
// Check cache first
const cacheKey = `${userId}:${avatarMxc}`;
const cached = userBannerCache.get(cacheKey);
@@ -230,11 +248,15 @@ export function useOtherUserBanner(userId: string, avatarMxc: string | undefined
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
// Use cached banner MXC
console.log('[useOtherUserBanner] Using cached bannerMxc:', cached.bannerMxc);
bannerMxc = cached.bannerMxc;
} else {
// Fetch banner MXC from avatar metadata
const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
console.log('[useOtherUserBanner] Fetching metadata from:', httpUrl);
if (!httpUrl) {
console.log('[useOtherUserBanner] Failed to get HTTP URL');
setBannerBlobUrl(undefined);
return;
}
@@ -243,6 +265,8 @@ export function useOtherUserBanner(userId: string, avatarMxc: string | undefined
const accessToken = getCurrentAccessToken();
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
console.log('[useOtherUserBanner] Extracted metadata:', metadata);
bannerMxc = metadata.banner;
// Cache the banner MXC (not the blob URL)
@@ -250,17 +274,23 @@ export function useOtherUserBanner(userId: string, avatarMxc: string | undefined
}
if (!bannerMxc) {
console.log('[useOtherUserBanner] No banner MXC found');
setBannerBlobUrl(undefined);
return;
}
console.log('[useOtherUserBanner] Fetching banner image from MXC:', bannerMxc);
// Fetch the banner image data with authentication
const bannerHttpUrl = mxcUrlToHttp(mx, bannerMxc, useAuthentication);
if (!bannerHttpUrl) {
console.log('[useOtherUserBanner] Failed to convert banner MXC to HTTP URL');
setBannerBlobUrl(undefined);
return;
}
console.log('[useOtherUserBanner] Banner HTTP URL:', bannerHttpUrl);
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
const headers: HeadersInit = {};

View File

@@ -260,10 +260,19 @@ const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
export function useOtherUserColor(userId: string, avatarMxc: string | undefined): string | undefined {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [color, setColor] = useState<string | undefined>();
// Check cache synchronously BEFORE initializing state to avoid flash
const cacheKey = avatarMxc ? `${userId}:${avatarMxc}` : '';
const cachedEntry = cacheKey ? userColorCache.get(cacheKey) : undefined;
const cachedColor = cachedEntry && Date.now() - cachedEntry.timestamp < CACHE_TTL_MS
? cachedEntry.color
: undefined;
const [color, setColor] = useState<string | undefined>(cachedColor);
useEffect(() => {
if (!avatarMxc) {
console.log('[useOtherUserColor] No avatarMxc provided for', userId);
setColor(undefined);
return;
}
@@ -272,13 +281,16 @@ export function useOtherUserColor(userId: string, avatarMxc: string | undefined)
const cacheKey = `${userId}:${avatarMxc}`;
const cached = userColorCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
console.log('[useOtherUserColor] Using cached color for', userId, ':', cached.color);
setColor(cached.color);
return;
}
const loadColor = async () => {
const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
console.log('[useOtherUserColor] Loading color for', userId, 'from', httpUrl);
if (!httpUrl) {
console.log('[useOtherUserColor] Failed to get HTTP URL for', avatarMxc);
setColor(undefined);
return;
}
@@ -287,6 +299,8 @@ export function useOtherUserColor(userId: string, avatarMxc: string | undefined)
const accessToken = getCurrentAccessToken();
const extractedColor = await fetchAndExtractColor(httpUrl, useAuthentication ? accessToken : null);
console.log('[useOtherUserColor] Extracted color for', userId, ':', extractedColor);
// Cache the result
userColorCache.set(cacheKey, { color: extractedColor, timestamp: Date.now() });

View File

@@ -212,22 +212,29 @@ export function useOtherUserProfileStyle(userId: string, avatarMxc: string | und
useEffect(() => {
if (!avatarMxc) {
console.log('[useOtherUserProfileStyle] No avatarMxc provided for', userId);
setStyle({});
return;
}
const loadStyle = async () => {
console.log('[useOtherUserProfileStyle] Loading style for', userId, 'avatarMxc:', avatarMxc);
// Check cache first
const cacheKey = `${userId}:${avatarMxc}`;
const cached = userStyleCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
console.log('[useOtherUserProfileStyle] Using cached style:', cached.style);
setStyle(cached.style);
return;
}
const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
console.log('[useOtherUserProfileStyle] Fetching metadata from:', httpUrl);
if (!httpUrl) {
console.log('[useOtherUserProfileStyle] Failed to get HTTP URL');
setStyle({});
return;
}
@@ -236,11 +243,15 @@ export function useOtherUserProfileStyle(userId: string, avatarMxc: string | und
const accessToken = getCurrentAccessToken();
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
console.log('[useOtherUserProfileStyle] Extracted metadata:', metadata);
const styleData: ProfileStyleData = {
avatarBorderColor: metadata.avatarBorderColor,
gradient: metadata.gradient,
};
console.log('[useOtherUserProfileStyle] Final styleData:', styleData);
// Cache the result
userStyleCache.set(cacheKey, { style: styleData, timestamp: Date.now() });
setStyle(styleData);

View File

@@ -5,6 +5,7 @@ import {
getScrollInfo,
isInScrollView,
isIntersectingScrollView,
scrollToPosition,
} from '../utils/dom';
const PAGINATOR_ANCHOR_ATTR = 'data-paginator-anchor';
@@ -218,7 +219,7 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
scrollTo = element.offsetTop - Math.round(scrollInfo.viewHeight) + element.clientHeight;
}
scrollElement.scrollTo({
scrollToPosition(scrollElement, {
top: scrollTo - (opts?.offset ?? 0),
behavior: opts?.behavior,
});
@@ -253,10 +254,12 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
if (!itemElement) {
const scrollElement = getScrollElement();
scrollElement?.scrollTo({
top: opts?.offset ?? 0,
behavior: opts?.behavior,
});
if (scrollElement) {
scrollToPosition(scrollElement, {
top: opts?.offset ?? 0,
behavior: opts?.behavior,
});
}
return true;
}
return scrollToElement(itemElement, opts);
@@ -364,7 +367,7 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
const offsetAddition = offsetTop - oldOffsetTop;
const restoreTop = oldScrollTop + offsetAddition;
scrollEl.scrollTo({
scrollToPosition(scrollEl, {
top: restoreTop,
behavior: 'instant',
});

View File

@@ -344,7 +344,7 @@ async function sendMessage(matrixClient: any, roomId: string, message: string) {
const content = {
msgtype: 'm.text',
body: message,
body: message.trim(),
};
const result = await matrixClient.sendMessage(roomId, content);

View File

@@ -1,5 +1,6 @@
import React, {
ChangeEventHandler,
FocusEventHandler,
KeyboardEventHandler,
MouseEventHandler,
useEffect,
@@ -39,8 +40,13 @@ export function ServerPicker({
const serverInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
// sync input with it outside server changes
if (serverInputRef.current && serverInputRef.current.value !== server) {
// Only sync input when server changes externally (e.g., from menu selection)
// and input is not focused to avoid cursor jumping during typing
if (
serverInputRef.current &&
serverInputRef.current.value !== server &&
document.activeElement !== serverInputRef.current
) {
serverInputRef.current.value = server;
}
}, [server]);
@@ -52,6 +58,13 @@ export function ServerPicker({
if (inputServer) debounceServerSelect(inputServer);
};
const handleBlur: FocusEventHandler<HTMLInputElement> = (evt) => {
const inputServer = evt.target.value.trim();
if (inputServer && inputServer !== server) {
onServerChange(inputServer);
}
};
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
if (evt.key === 'ArrowDown') {
evt.preventDefault();
@@ -85,6 +98,7 @@ export function ServerPicker({
outlined
defaultValue={server}
onChange={handleServerChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
size="500"
readOnly={!allowCustomServer}

View File

@@ -517,86 +517,118 @@ function TaskbarFlashStopper() {
function AndroidShareIntentHandler() {
const mx = useMatrixClient();
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
const navigate = useNavigate();
const [pendingShare, setPendingShare] = useState<AndroidSharePayload | null>(null);
const [pickedRoomId, setPickedRoomId] = useState<string | null>(null);
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(pickedRoomId ?? '__android_share__'));
const setUploadItems = useSetAtom(roomIdToUploadItemsAtomFamily(pickedRoomId ?? '__android_share__'));
const mDirects = useAtomValue(mDirectAtom);
const roomToParents = useAtomValue(roomToParentsAtom);
const applyPendingShare = useCallback(
async (share: AndroidSharePayload, roomId: string) => {
const room = mx.getRoom(roomId);
if (!room) return false;
// Send text message if present
const nextParts = [share.subject?.trim(), share.text?.trim()].filter(
(value): value is string => !!value
);
if (nextParts.length > 0) {
const existingText = toPlainText(msgDraft, isMarkdown).trim();
const nextText = existingText ? `${existingText}\n${nextParts.join('\n')}` : nextParts.join('\n');
setMsgDraft(plainToEditorInput(nextText, isMarkdown));
const textContent = {
msgtype: 'm.text' as const,
body: nextParts.join('\n'),
};
await mx.sendMessage(roomId, textContent);
}
// Upload and send files if present
if (share.files.length > 0) {
const uploadItems: TUploadItem[] = [];
for (const sharedFile of share.files) {
const originalFile = await materializeSharedFile(sharedFile, share.receivedAt);
let fileToUpload = originalFile;
let encInfo: any = undefined;
if (room.hasEncryptionStateEvent()) {
const encrypted = await encryptFile(originalFile);
uploadItems.push({
file: encrypted.file,
originalFile,
metadata: { markedAsSpoiler: false },
encInfo: encrypted.encInfo,
});
continue;
fileToUpload = encrypted.file;
encInfo = encrypted.encInfo;
}
uploadItems.push({
file: originalFile,
originalFile,
metadata: { markedAsSpoiler: false },
encInfo: undefined,
});
}
// Upload file
const uploadResult = await mx.uploadContent(fileToUpload);
const mxc = uploadResult?.content_uri;
if (!mxc) continue;
setUploadItems({
type: 'PUT',
item: uploadItems,
});
// Determine message type and send
const fileType = originalFile.type;
let msgtype = 'm.file' as const;
if (fileType.startsWith('image/')) msgtype = 'm.image' as const;
else if (fileType.startsWith('video/')) msgtype = 'm.video' as const;
else if (fileType.startsWith('audio/')) msgtype = 'm.audio' as const;
const fileContent: any = {
msgtype,
body: originalFile.name,
filename: originalFile.name,
info: {
mimetype: originalFile.type,
size: originalFile.size,
},
};
if (encInfo) {
fileContent.file = {
...encInfo,
url: mxc,
};
} else {
fileContent.url = mxc;
}
await mx.sendMessage(roomId, fileContent);
}
}
await clearPendingAndroidShare();
return true;
},
[isMarkdown, msgDraft, mx, setMsgDraft, setUploadItems]
[mx]
);
const handlePickRoom = useCallback(
(roomId: string) => {
setPickedRoomId(roomId);
if (!pendingShare) return;
applyPendingShare(pendingShare, roomId)
.then((applied) => {
if (applied) {
setPendingShare(null);
setPickedRoomId(null);
// Navigate to the selected room
const isDirect = mDirects.has(roomId);
if (isDirect) {
navigate(getDirectRoomPath(roomId));
} else {
const parents = roomToParents.get(roomId);
const parent = parents && parents.length > 0 ? parents[0] : undefined;
if (parent) {
navigate(getSpaceRoomPath(parent, roomId));
} else {
navigate(getHomeRoomPath(roomId));
}
}
}
})
.catch((err) => {
console.error('[AndroidShare] Failed to apply share after pick:', err);
setPendingShare(null);
setPickedRoomId(null);
});
},
[applyPendingShare, pendingShare]
[applyPendingShare, pendingShare, navigate, mDirects, roomToParents]
);
const handleDismiss = useCallback(() => {
clearPendingAndroidShare().catch(() => {});
setPendingShare(null);
setPickedRoomId(null);
}, []);
useEffect(() => {

View File

@@ -16,13 +16,21 @@ export const MarginSpaced = style({
},
});
export const Paragraph = style([DefaultReset]);
export const Paragraph = style([
DefaultReset,
{
overflow: 'hidden',
lineHeight: '1.5',
},
]);
export const Heading = style([
DefaultReset,
MarginSpaced,
{
marginTop: config.space.S400,
overflow: 'hidden',
lineHeight: '1.5',
selectors: {
'&:first-child': {
marginTop: 0,
@@ -38,6 +46,8 @@ export const BlockQuote = style([
paddingLeft: config.space.S200,
borderLeft: `${config.borderWidth.B700} solid ${color.SurfaceVariant.ContainerLine}`,
fontStyle: 'italic',
overflow: 'hidden',
lineHeight: '1.5',
},
]);
@@ -62,7 +72,9 @@ export const Code = style([
CodeFont,
{
padding: `0 ${config.space.S100}`,
display: 'inline-block',
display: 'inline',
overflow: 'hidden',
lineHeight: '1.5',
},
]);
@@ -108,6 +120,8 @@ export const Spoiler = recipe({
padding: `0 ${config.space.S100}`,
backgroundColor: color.SurfaceVariant.ContainerActive,
borderRadius: config.radii.R300,
overflow: 'hidden',
lineHeight: '1.5',
selectors: {
'&[aria-pressed=true]': {
color: 'transparent',
@@ -132,6 +146,7 @@ export const CodeBlock = style([
fontStyle: 'normal',
position: 'relative',
overflow: 'hidden',
lineHeight: '1.5',
},
]);
export const CodeBlockHeader = style([
@@ -167,6 +182,8 @@ export const List = style([
{
padding: `0 ${config.space.S100}`,
paddingLeft: config.space.S600,
overflow: 'hidden',
lineHeight: '1.5',
},
]);
@@ -194,6 +211,8 @@ export const Mention = recipe({
padding: `0 ${toRem(2)}`,
borderRadius: config.radii.R300,
fontWeight: config.fontWeight.W500,
overflow: 'hidden',
lineHeight: '1.5',
},
],
variants: {
@@ -219,6 +238,8 @@ export const Command = recipe({
padding: `0 ${toRem(2)}`,
borderRadius: config.radii.R300,
fontWeight: config.fontWeight.W500,
overflow: 'hidden',
lineHeight: '1.5',
},
],
variants: {
@@ -287,5 +308,7 @@ export const highlightText = style([
{
backgroundColor: 'yellow',
color: 'black',
overflow: 'hidden',
lineHeight: '1.5',
},
]);

View File

@@ -172,6 +172,32 @@ export type ScrollInfo = {
viewHeight: number;
scrollable: boolean;
};
export type ScrollBehavior = 'auto' | 'instant' | 'smooth';
export type ScrollPosition = {
top?: number;
left?: number;
behavior?: ScrollBehavior;
};
/**
* Centralized wrapper for element scrolling.
*/
export const scrollToPosition = (scrollEl: HTMLElement, position: ScrollPosition): void => {
scrollEl.scrollTo(position);
};
/**
* Centralized wrapper for scrolling a target into the current viewport.
*/
export const scrollElementIntoView = (
element: HTMLElement,
options?: ScrollIntoViewOptions
): void => {
element.scrollIntoView(options);
};
export const getScrollInfo = (target: HTMLElement): ScrollInfo => ({
offsetTop: Math.round(target.offsetTop),
top: Math.round(target.scrollTop),
@@ -180,17 +206,44 @@ export const getScrollInfo = (target: HTMLElement): ScrollInfo => ({
scrollable: target.scrollHeight > target.offsetHeight,
});
export const scrollToBottom = (scrollEl: HTMLElement, behavior?: 'auto' | 'instant' | 'smooth') => {
scrollEl.scrollTo({
export const scrollToBottom = (scrollEl: HTMLElement, behavior?: ScrollBehavior) => {
scrollToPosition(scrollEl, {
top: Math.round(scrollEl.scrollHeight - scrollEl.offsetHeight),
behavior,
});
};
/**
* Check if scroll element is at or near the bottom (within threshold).
* Useful for "sticky to bottom" behavior in chat-like interfaces.
* @param scrollEl - The scrollable element
* @param threshold - Distance from bottom in pixels (default: 50px)
* @returns true if scrolled to bottom within threshold
*/
export const isScrolledToBottom = (scrollEl: HTMLElement, threshold = 50): boolean => {
const { scrollTop, offsetHeight, scrollHeight } = scrollEl;
const scrollPosition = scrollTop + offsetHeight;
return scrollHeight - scrollPosition <= threshold;
};
/**
* Shared auto-scroll behavior for chat-like timelines that should stay pinned to bottom.
* Only scrolls if already near the bottom unless a call site explicitly forces it.
*/
export const autoScrollToBottom = (
scrollEl: HTMLElement,
smooth = false,
force = false
): void => {
if (force || isScrolledToBottom(scrollEl)) {
scrollToBottom(scrollEl, smooth ? 'smooth' : 'instant');
}
};
export const copyToClipboard = (text: string) => {
// Try Electron clipboard first (if available)
if ('electron' in window && (window as any).electron?.clipboard?.writeText) {
(window as any).electron.clipboard.writeText(text);
if ('electron' in window && window.electron?.clipboard?.writeText) {
window.electron.clipboard.writeText(text);
return;
}

View File

@@ -170,6 +170,8 @@ export function detectImageFormat(data: ArrayBuffer | Uint8Array): ImageFormat {
export function extractColorFromImage(imageData: ArrayBuffer | Uint8Array): string | undefined {
const format = detectImageFormat(imageData);
console.log('[extractColorFromImage] Detected format:', format);
switch (format) {
case 'png':
return extractColorFromPNG(imageData);
@@ -211,14 +213,19 @@ export function extractBannerFromImage(imageData: ArrayBuffer | Uint8Array): str
export function extractMetadataFromImage(imageData: ArrayBuffer | Uint8Array): ImageMetadata {
const format = detectImageFormat(imageData);
console.log('[extractMetadataFromImage] Detected format:', format);
switch (format) {
case 'png':
return extractMetadataFromPNG(imageData);
const pngMetadata = extractMetadataFromPNG(imageData);
console.log('[extractMetadataFromImage] PNG metadata:', pngMetadata);
return pngMetadata;
// For other formats, extract color only for now
case 'webp':
case 'jpeg':
case 'gif': {
const color = extractColorFromImage(imageData);
console.log('[extractMetadataFromImage] Non-PNG color:', color);
return color ? { color } : {};
}
default:
@@ -344,6 +351,10 @@ export function getExtension(format: ImageFormat): string {
const avatarMetadataCache = new LRUCache<string, { metadata: ImageMetadata; timestamp: number }>(100);
const METADATA_CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
// In-flight fetch tracking to prevent duplicate parallel requests
const inflightColorFetches = new Map<string, Promise<string | undefined>>();
const inflightMetadataFetches = new Map<string, Promise<ImageMetadata>>();
/**
* Fetch and extract color from an image URL (supports all formats)
* @param url - HTTP URL to fetch the image from
@@ -354,29 +365,51 @@ export async function fetchAndExtractColor(url: string, accessToken?: string | n
// Check if full metadata is cached
const cached = avatarMetadataCache.get(url);
if (cached && Date.now() - cached.timestamp < METADATA_CACHE_TTL_MS) {
console.log('[fetchAndExtractColor] Using cached color for', url, ':', cached.metadata.color);
return cached.metadata.color;
}
try {
const response = await fetch(url, {
method: 'GET',
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
});
if (!response.ok) return undefined;
const data = await response.arrayBuffer();
const color = extractColorFromImage(data);
// Cache the color result as part of metadata
avatarMetadataCache.set(url, {
metadata: { color },
timestamp: Date.now()
});
return color;
} catch {
return undefined;
// Check if fetch already in progress
const inflightKey = `${url}:${accessToken || ''}`;
const existingFetch = inflightColorFetches.get(inflightKey);
if (existingFetch) {
console.log('[fetchAndExtractColor] Reusing in-flight fetch for', url);
return existingFetch;
}
console.log('[fetchAndExtractColor] Fetching and extracting color from', url);
const fetchPromise = (async () => {
try {
const response = await fetch(url, {
method: 'GET',
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
});
console.log('[fetchAndExtractColor] Fetch response status:', response.ok, response.status);
if (!response.ok) return undefined;
const data = await response.arrayBuffer();
console.log('[fetchAndExtractColor] Downloaded', data.byteLength, 'bytes');
const color = extractColorFromImage(data);
console.log('[fetchAndExtractColor] Extracted color:', color);
// Don't cache here - fetchAndExtractMetadata will cache full metadata
// This avoids cache collision where color-only fetch overwrites full metadata
return color;
} catch (error) {
console.error('[fetchAndExtractColor] Error fetching/extracting color:', error);
return undefined;
} finally {
inflightColorFetches.delete(inflightKey);
}
})();
inflightColorFetches.set(inflightKey, fetchPromise);
return fetchPromise;
}
/**
@@ -389,27 +422,51 @@ export async function fetchAndExtractMetadata(url: string, accessToken?: string
// Check cache first
const cached = avatarMetadataCache.get(url);
if (cached && Date.now() - cached.timestamp < METADATA_CACHE_TTL_MS) {
console.log('[fetchAndExtractMetadata] Using cached metadata for', url, ':', cached.metadata);
return cached.metadata;
}
try {
const response = await fetch(url, {
method: 'GET',
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
});
if (!response.ok) return {};
const data = await response.arrayBuffer();
const metadata = extractMetadataFromImage(data);
// Cache the result
avatarMetadataCache.set(url, {
metadata,
timestamp: Date.now()
});
return metadata;
} catch {
return {};
// Check if fetch already in progress
const inflightKey = `${url}:${accessToken || ''}`;
const existingFetch = inflightMetadataFetches.get(inflightKey);
if (existingFetch) {
console.log('[fetchAndExtractMetadata] Reusing in-flight fetch for', url);
return existingFetch;
}
console.log('[fetchAndExtractMetadata] Fetching metadata from', url);
const fetchPromise = (async () => {
try {
const response = await fetch(url, {
method: 'GET',
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
});
console.log('[fetchAndExtractMetadata] Fetch response status:', response.ok, response.status);
if (!response.ok) return {};
const data = await response.arrayBuffer();
console.log('[fetchAndExtractMetadata] Downloaded', data.byteLength, 'bytes');
const metadata = extractMetadataFromImage(data);
console.log('[fetchAndExtractMetadata] Extracted metadata:', metadata);
// Cache the result
avatarMetadataCache.set(url, {
metadata,
timestamp: Date.now()
});
return metadata;
} catch {
return {};
} finally {
inflightMetadataFetches.delete(inflightKey);
}
})();
inflightMetadataFetches.set(inflightKey, fetchPromise);
return fetchPromise;
}

View File

@@ -153,21 +153,30 @@ export function extractColorFromPNG(imageData: ArrayBuffer | Uint8Array): string
// Verify PNG signature
for (let i = 0; i < 8; i++) {
if (data[i] !== PNG_SIGNATURE[i]) {
console.warn('[extractColorFromPNG] Not a valid PNG');
return undefined; // Not a PNG
}
}
const chunks = parseChunks(data);
console.log('[extractColorFromPNG] Found', chunks.length, 'chunks');
let textChunksFound = 0;
for (const chunk of chunks) {
if (chunk.type === 'tEXt') {
textChunksFound++;
const text = parseTextChunk(chunk.data);
if (text && text.key === PAARROT_COLOR_KEY) {
return text.value;
if (text) {
console.log('[extractColorFromPNG] Found tEXt chunk:', text.key, '=', text.value);
if (text.key === PAARROT_COLOR_KEY) {
console.log('[extractColorFromPNG] Found paarrot:color =', text.value);
return text.value;
}
}
}
}
console.log('[extractColorFromPNG] No paarrot:color found (found', textChunksFound, 'tEXt chunks)');
return undefined;
}
@@ -214,16 +223,21 @@ export function extractMetadataFromPNG(imageData: ArrayBuffer | Uint8Array): Ima
// Verify PNG signature
for (let i = 0; i < 8; i++) {
if (data[i] !== PNG_SIGNATURE[i]) {
console.warn('[extractMetadataFromPNG] Not a valid PNG');
return metadata; // Not a PNG
}
}
const chunks = parseChunks(data);
console.log('[extractMetadataFromPNG] Found', chunks.length, 'chunks');
let textChunksFound = 0;
for (const chunk of chunks) {
if (chunk.type === 'tEXt') {
textChunksFound++;
const text = parseTextChunk(chunk.data);
if (text) {
console.log('[extractMetadataFromPNG] Found tEXt chunk:', text.key, '=', text.value);
if (text.key === PAARROT_COLOR_KEY) {
metadata.color = text.value;
} else if (text.key === PAARROT_BANNER_KEY) {
@@ -241,6 +255,8 @@ export function extractMetadataFromPNG(imageData: ArrayBuffer | Uint8Array): Ima
}
}
console.log('[extractMetadataFromPNG] Final metadata extracted:', metadata, 'found', textChunksFound, 'tEXt chunks');
return metadata;
}

3
src/ext.d.ts vendored
View File

@@ -53,6 +53,9 @@ interface PluginAPI {
interface ElectronAPI {
platform?: string;
arch?: string;
clipboard?: {
writeText: (text: string) => void;
};
plugins?: PluginAPI;
}

View File

@@ -94,6 +94,11 @@ body {
font-variant-ligatures: no-contextual;
}
/* Prevent zalgo text and combining diacritics from overflowing line boundaries */
p, span, div, a, button, li, td, th, h1, h2, h3, h4, h5, h6 {
line-height: 1.5;
}
/* Theme-specific body backgrounds for safe area padding */
body.light-theme {
background-color: #F0F0F0;

View File

@@ -4,6 +4,9 @@ import { MsgType } from 'matrix-js-sdk';
export const MATRIX_BLUR_HASH_PROPERTY_NAME = 'xyz.amorgan.blurhash';
export const MATRIX_SPOILER_PROPERTY_NAME = 'page.codeberg.everypizza.msc4193.spoiler';
export const MATRIX_SPOILER_REASON_PROPERTY_NAME = 'page.codeberg.everypizza.msc4193.spoiler.reason';
export const PAARROT_CAROUSEL_UUID_PROPERTY_NAME = 'com.paarrot.carousel_uuid';
export const PAARROT_CAROUSEL_INDEX_PROPERTY_NAME = 'com.paarrot.carousel_index';
export const PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME = 'com.paarrot.carousel_total';
export type IImageInfo = {
w?: number;
@@ -51,6 +54,9 @@ export type IImageContent = {
file?: IEncryptedFile;
[MATRIX_SPOILER_PROPERTY_NAME]?: boolean;
[MATRIX_SPOILER_REASON_PROPERTY_NAME]?: string;
[PAARROT_CAROUSEL_UUID_PROPERTY_NAME]?: string;
[PAARROT_CAROUSEL_INDEX_PROPERTY_NAME]?: number;
[PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME]?: number;
};
export type IVideoContent = {

View File

@@ -1,5 +1,69 @@
// https://github.com/cloudrac3r/cadencegq/blob/master/pug/mxid.pug
/**
* Convert HSL to hex color
* @param {number} h - Hue (0-360)
* @param {number} s - Saturation (0-100)
* @param {number} l - Lightness (0-100)
* @returns {string} Hex color string
*/
function hslToHex(h, s, l) {
const sNorm = s / 100;
const lNorm = l / 100;
const c = (1 - Math.abs(2 * lNorm - 1)) * sNorm;
const x = c * (1 - Math.abs((h / 60) % 2 - 1));
const m = lNorm - c / 2;
let r = 0;
let g = 0;
let b = 0;
if (h >= 0 && h < 60) {
r = c; g = x; b = 0;
} else if (h >= 60 && h < 120) {
r = x; g = c; b = 0;
} else if (h >= 120 && h < 180) {
r = 0; g = c; b = x;
} else if (h >= 180 && h < 240) {
r = 0; g = x; b = c;
} else if (h >= 240 && h < 300) {
r = x; g = 0; b = c;
} else if (h >= 300 && h < 360) {
r = c; g = 0; b = x;
}
const rHex = Math.round((r + m) * 255).toString(16).padStart(2, '0');
const gHex = Math.round((g + m) * 255).toString(16).padStart(2, '0');
const bHex = Math.round((b + m) * 255).toString(16).padStart(2, '0');
return `#${rHex}${gHex}${bHex}`.toUpperCase();
}
// Light theme colors: hsl(h, 100%, l)
const LIGHT_COLORS = [
{ h: 208, s: 100, l: 45 }, // --mx-uc-1
{ h: 302, s: 100, l: 30 }, // --mx-uc-2
{ h: 163, s: 100, l: 30 }, // --mx-uc-3
{ h: 343, s: 100, l: 45 }, // --mx-uc-4
{ h: 24, s: 100, l: 45 }, // --mx-uc-5
{ h: 181, s: 100, l: 30 }, // --mx-uc-6
{ h: 242, s: 100, l: 45 }, // --mx-uc-7
{ h: 94, s: 100, l: 35 }, // --mx-uc-8
];
// Dark theme colors: hsl(h, 100%, l)
const DARK_COLORS = [
{ h: 208, s: 100, l: 75 }, // --mx-uc-1
{ h: 301, s: 100, l: 80 }, // --mx-uc-2
{ h: 163, s: 100, l: 70 }, // --mx-uc-3
{ h: 343, s: 100, l: 75 }, // --mx-uc-4
{ h: 24, s: 100, l: 70 }, // --mx-uc-5
{ h: 181, s: 100, l: 60 }, // --mx-uc-6
{ h: 243, s: 100, l: 80 }, // --mx-uc-7
{ h: 94, s: 100, l: 80 }, // --mx-uc-8
];
function hashCode(str) {
let hash = 0;
let i;
@@ -22,6 +86,33 @@ export function cssColorMXID(userId) {
return `--mx-uc-${colorNumber + 1}`;
}
/**
* Get the actual hex color value for a user ID
* @param {string} userId - Matrix user ID
* @param {boolean} isDark - Whether to use dark theme colors (default: auto-detect)
* @returns {string} Hex color string
*/
export function getColorMXIDValue(userId, isDark) {
const colorNumber = hashCode(userId) % 8;
// Auto-detect theme if not specified
let useDark = isDark;
if (typeof isDark === 'undefined') {
const bodyClass = document.body.className;
useDark = bodyClass.includes('dark-theme') ||
bodyClass.includes('butter-theme') ||
bodyClass.includes('discord-theme') ||
bodyClass.includes('discord-darker-theme') ||
bodyClass.includes('twilight-theme') ||
bodyClass.includes('mocha-theme');
}
const colorSet = useDark ? DARK_COLORS : LIGHT_COLORS;
const color = colorSet[colorNumber];
return hslToHex(color.h, color.s, color.l);
}
export default function colorMXID(userId) {
return `var(${cssColorMXID(userId)})`;
}

View File

@@ -220,6 +220,7 @@ export default defineConfig({
server: {
port: 38347,
host: '0.0.0.0',
open: false,
cors: true, // Enable CORS for dev server
fs: {
// Allow serving files from one level up to the project root