perf(room): memoize timeline/composer handlers and emoji-pack room lookups
- RoomTimeline: wrap jump-to-latest/unread + mark-as-read handlers in useCallback (the handlers passed to memoized message children were already memoized). - RoomInput: wrap file/upload/emoji/sticker/location callbacks in useCallback so the editor and toolbar don't re-render needlessly. - EmojiBoard: hoist repeated mx.getRoom() pack-label lookups into a useMemo'd map in the emoji and sticker sidebars (previously called per-render in map loops). Behavior unchanged. (RoomTimeline/RoomInput already have ErrorBoundary wrappers in RoomView, so no boundary added.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -178,6 +178,16 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP
|
|||||||
const labels = useEmojiGroupLabels();
|
const labels = useEmojiGroupLabels();
|
||||||
const icons = useEmojiGroupIcons();
|
const icons = useEmojiGroupIcons();
|
||||||
|
|
||||||
|
const packLabels = useMemo(() => {
|
||||||
|
const map = new Map<string, string | undefined>();
|
||||||
|
packs.forEach((pack) => {
|
||||||
|
let label = pack.meta.name;
|
||||||
|
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
|
||||||
|
map.set(pack.id, label);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [mx, packs]);
|
||||||
|
|
||||||
const handleScrollToGroup = (groupId: string) => {
|
const handleScrollToGroup = (groupId: string) => {
|
||||||
setActiveGroupId(groupId);
|
setActiveGroupId(groupId);
|
||||||
onScrollToGroup(groupId);
|
onScrollToGroup(groupId);
|
||||||
@@ -198,8 +208,7 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP
|
|||||||
<SidebarStack>
|
<SidebarStack>
|
||||||
<SidebarDivider />
|
<SidebarDivider />
|
||||||
{packs.map((pack) => {
|
{packs.map((pack) => {
|
||||||
let label = pack.meta.name;
|
const label = packLabels.get(pack.id);
|
||||||
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
|
|
||||||
|
|
||||||
const url =
|
const url =
|
||||||
mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ?? undefined;
|
mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ?? undefined;
|
||||||
@@ -252,6 +261,16 @@ function StickerSidebar({ activeGroupAtom, packs, onScrollToGroup }: StickerSide
|
|||||||
const [activeGroupId, setActiveGroupId] = useAtom(activeGroupAtom);
|
const [activeGroupId, setActiveGroupId] = useAtom(activeGroupAtom);
|
||||||
const usage = ImageUsage.Sticker;
|
const usage = ImageUsage.Sticker;
|
||||||
|
|
||||||
|
const packLabels = useMemo(() => {
|
||||||
|
const map = new Map<string, string | undefined>();
|
||||||
|
packs.forEach((pack) => {
|
||||||
|
let label = pack.meta.name;
|
||||||
|
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
|
||||||
|
map.set(pack.id, label);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [mx, packs]);
|
||||||
|
|
||||||
const handleScrollToGroup = (groupId: string) => {
|
const handleScrollToGroup = (groupId: string) => {
|
||||||
setActiveGroupId(groupId);
|
setActiveGroupId(groupId);
|
||||||
onScrollToGroup(groupId);
|
onScrollToGroup(groupId);
|
||||||
@@ -261,8 +280,7 @@ function StickerSidebar({ activeGroupAtom, packs, onScrollToGroup }: StickerSide
|
|||||||
<Sidebar>
|
<Sidebar>
|
||||||
<SidebarStack>
|
<SidebarStack>
|
||||||
{packs.map((pack) => {
|
{packs.map((pack) => {
|
||||||
let label = pack.meta.name;
|
const label = packLabels.get(pack.id);
|
||||||
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
|
|
||||||
|
|
||||||
const url =
|
const url =
|
||||||
mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ?? undefined;
|
mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ?? undefined;
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
const showSchedule = composerToolbarButtons?.showSchedule ?? true;
|
const showSchedule = composerToolbarButtons?.showSchedule ?? true;
|
||||||
const [locating, setLocating] = React.useState(false);
|
const [locating, setLocating] = React.useState(false);
|
||||||
const [locationError, setLocationError] = React.useState<string | null>(null);
|
const [locationError, setLocationError] = React.useState<string | null>(null);
|
||||||
const handleShareLocation = () => {
|
const handleShareLocation = useCallback(() => {
|
||||||
if (!navigator.geolocation) {
|
if (!navigator.geolocation) {
|
||||||
setLocationError('Geolocation not supported.');
|
setLocationError('Geolocation not supported.');
|
||||||
setTimeout(() => setLocationError(null), 4000);
|
setTimeout(() => setLocationError(null), 4000);
|
||||||
@@ -252,7 +252,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
},
|
},
|
||||||
{ timeout: 10000 },
|
{ timeout: 10000 },
|
||||||
);
|
);
|
||||||
};
|
}, [mx, roomId]);
|
||||||
|
|
||||||
const handleVoiceSend = useCallback(
|
const handleVoiceSend = useCallback(
|
||||||
async (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => {
|
async (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => {
|
||||||
@@ -405,71 +405,77 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
[setSelectedFiles, selectedFiles],
|
[setSelectedFiles, selectedFiles],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCancelUpload = (uploads: Upload[]) => {
|
const handleCancelUpload = useCallback(
|
||||||
uploads.forEach((upload) => {
|
(uploads: Upload[]) => {
|
||||||
if (upload.status === UploadStatus.Loading) {
|
uploads.forEach((upload) => {
|
||||||
mx.cancelUpload(upload.promise);
|
if (upload.status === UploadStatus.Loading) {
|
||||||
}
|
mx.cancelUpload(upload.promise);
|
||||||
});
|
|
||||||
handleRemoveUpload(uploads.map((upload) => upload.file));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSendUpload = async (uploads: UploadSuccess[]) => {
|
|
||||||
const contentsPromises = uploads.map(async (upload) => {
|
|
||||||
const fileItem = selectedFiles.find((f) => f.file === upload.file);
|
|
||||||
if (!fileItem) throw new Error('Broken upload');
|
|
||||||
|
|
||||||
// Resolve the MXC URL to use — may be overridden if compression is enabled
|
|
||||||
let mxc = upload.mxc;
|
|
||||||
|
|
||||||
if (fileItem.metadata.compressImage && isCompressible(fileItem.originalFile)) {
|
|
||||||
// Use the cached compression result if available, otherwise compute it now
|
|
||||||
let compressionResult = fileItem.metadata.compressionResult;
|
|
||||||
if (compressionResult === undefined) {
|
|
||||||
compressionResult = await compressImage(fileItem.originalFile);
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
handleRemoveUpload(uploads.map((upload) => upload.file));
|
||||||
|
},
|
||||||
|
[mx, handleRemoveUpload],
|
||||||
|
);
|
||||||
|
|
||||||
if (compressionResult) {
|
const handleSendUpload = useCallback(
|
||||||
const originalFile = fileItem.originalFile as File;
|
async (uploads: UploadSuccess[]) => {
|
||||||
const compressedFile = new File([compressionResult.blob], originalFile.name, {
|
const contentsPromises = uploads.map(async (upload) => {
|
||||||
type: 'image/jpeg',
|
const fileItem = selectedFiles.find((f) => f.file === upload.file);
|
||||||
});
|
if (!fileItem) throw new Error('Broken upload');
|
||||||
const uploadRes = await mx.uploadContent(compressedFile, {
|
|
||||||
name: originalFile.name,
|
// Resolve the MXC URL to use — may be overridden if compression is enabled
|
||||||
type: 'image/jpeg',
|
let mxc = upload.mxc;
|
||||||
});
|
|
||||||
const compressedMxc = (uploadRes as { content_uri: string }).content_uri;
|
if (fileItem.metadata.compressImage && isCompressible(fileItem.originalFile)) {
|
||||||
if (compressedMxc) {
|
// Use the cached compression result if available, otherwise compute it now
|
||||||
// Delete the pre-uploaded original so only one copy lives on the server.
|
let compressionResult = fileItem.metadata.compressionResult;
|
||||||
tryDeleteMxcContent(mx, upload.mxc);
|
if (compressionResult === undefined) {
|
||||||
mxc = compressedMxc;
|
compressionResult = await compressImage(fileItem.originalFile);
|
||||||
// Build a synthetic fileItem that refers to the compressed file so
|
}
|
||||||
// getImageMsgContent picks up the correct dimensions and type.
|
|
||||||
const compressedItem = {
|
if (compressionResult) {
|
||||||
...fileItem,
|
const originalFile = fileItem.originalFile as File;
|
||||||
file: compressedFile,
|
const compressedFile = new File([compressionResult.blob], originalFile.name, {
|
||||||
originalFile: compressedFile,
|
type: 'image/jpeg',
|
||||||
};
|
});
|
||||||
return getImageMsgContent(mx, compressedItem, mxc);
|
const uploadRes = await mx.uploadContent(compressedFile, {
|
||||||
|
name: originalFile.name,
|
||||||
|
type: 'image/jpeg',
|
||||||
|
});
|
||||||
|
const compressedMxc = (uploadRes as { content_uri: string }).content_uri;
|
||||||
|
if (compressedMxc) {
|
||||||
|
// Delete the pre-uploaded original so only one copy lives on the server.
|
||||||
|
tryDeleteMxcContent(mx, upload.mxc);
|
||||||
|
mxc = compressedMxc;
|
||||||
|
// Build a synthetic fileItem that refers to the compressed file so
|
||||||
|
// getImageMsgContent picks up the correct dimensions and type.
|
||||||
|
const compressedItem = {
|
||||||
|
...fileItem,
|
||||||
|
file: compressedFile,
|
||||||
|
originalFile: compressedFile,
|
||||||
|
};
|
||||||
|
return getImageMsgContent(mx, compressedItem, mxc);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (fileItem.file.type.startsWith('image')) {
|
if (fileItem.file.type.startsWith('image')) {
|
||||||
return getImageMsgContent(mx, fileItem, mxc);
|
return getImageMsgContent(mx, fileItem, mxc);
|
||||||
}
|
}
|
||||||
if (fileItem.file.type.startsWith('video')) {
|
if (fileItem.file.type.startsWith('video')) {
|
||||||
return getVideoMsgContent(mx, fileItem, mxc);
|
return getVideoMsgContent(mx, fileItem, mxc);
|
||||||
}
|
}
|
||||||
if (fileItem.file.type.startsWith('audio')) {
|
if (fileItem.file.type.startsWith('audio')) {
|
||||||
return getAudioMsgContent(fileItem, mxc);
|
return getAudioMsgContent(fileItem, mxc);
|
||||||
}
|
}
|
||||||
return getFileMsgContent(fileItem, mxc);
|
return getFileMsgContent(fileItem, mxc);
|
||||||
});
|
});
|
||||||
handleCancelUpload(uploads);
|
handleCancelUpload(uploads);
|
||||||
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
|
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
|
||||||
contents.forEach((content) => mx.sendMessage(roomId, content as any));
|
contents.forEach((content) => mx.sendMessage(roomId, content as any));
|
||||||
};
|
},
|
||||||
|
[mx, roomId, selectedFiles, handleCancelUpload],
|
||||||
|
);
|
||||||
|
|
||||||
const submit = useCallback(() => {
|
const submit = useCallback(() => {
|
||||||
uploadBoardHandlers.current?.handleSend();
|
uploadBoardHandlers.current?.handleSend();
|
||||||
@@ -675,10 +681,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
ReactEditor.focus(editor);
|
ReactEditor.focus(editor);
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const handleEmoticonSelect = (key: string, shortcode: string) => {
|
const handleEmoticonSelect = useCallback(
|
||||||
editor.insertNode(createEmoticonElement(key, shortcode));
|
(key: string, shortcode: string) => {
|
||||||
moveCursor(editor);
|
editor.insertNode(createEmoticonElement(key, shortcode));
|
||||||
};
|
moveCursor(editor);
|
||||||
|
},
|
||||||
|
[editor],
|
||||||
|
);
|
||||||
|
|
||||||
const handleGifSelect = useCallback(
|
const handleGifSelect = useCallback(
|
||||||
async (gifUrl: string, w: number, h: number) => {
|
async (gifUrl: string, w: number, h: number) => {
|
||||||
@@ -736,21 +745,24 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
[mx, roomId, alive],
|
[mx, roomId, alive],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleStickerSelect = async (mxc: string, shortcode: string, label: string) => {
|
const handleStickerSelect = useCallback(
|
||||||
const stickerUrl = mxcUrlToHttp(mx, mxc, useAuthentication);
|
async (mxc: string, shortcode: string, label: string) => {
|
||||||
if (!stickerUrl) return;
|
const stickerUrl = mxcUrlToHttp(mx, mxc, useAuthentication);
|
||||||
|
if (!stickerUrl) return;
|
||||||
|
|
||||||
const info = await getImageInfo(
|
const info = await getImageInfo(
|
||||||
await loadImageElement(stickerUrl),
|
await loadImageElement(stickerUrl),
|
||||||
await getImageUrlBlob(stickerUrl),
|
await getImageUrlBlob(stickerUrl),
|
||||||
);
|
);
|
||||||
|
|
||||||
mx.sendEvent(roomId, EventType.Sticker, {
|
mx.sendEvent(roomId, EventType.Sticker, {
|
||||||
body: label,
|
body: label,
|
||||||
url: mxc,
|
url: mxc,
|
||||||
info,
|
info,
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
[mx, roomId, useAuthentication],
|
||||||
|
);
|
||||||
|
|
||||||
if (room.getType() === 'm.server_notice') {
|
if (room.getType() === 'm.server_notice') {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -906,25 +906,25 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
}
|
}
|
||||||
}, [scrollToElement, editId]);
|
}, [scrollToElement, editId]);
|
||||||
|
|
||||||
const handleJumpToLatest = () => {
|
const handleJumpToLatest = useCallback(() => {
|
||||||
if (eventId) {
|
if (eventId) {
|
||||||
navigateRoom(room.roomId, undefined, { replace: true });
|
navigateRoom(room.roomId, undefined, { replace: true });
|
||||||
}
|
}
|
||||||
setTimeline(getInitialTimeline(room));
|
setTimeline(getInitialTimeline(room));
|
||||||
scrollToBottomRef.current.count += 1;
|
scrollToBottomRef.current.count += 1;
|
||||||
scrollToBottomRef.current.smooth = false;
|
scrollToBottomRef.current.smooth = false;
|
||||||
};
|
}, [eventId, navigateRoom, room]);
|
||||||
|
|
||||||
const handleJumpToUnread = () => {
|
const handleJumpToUnread = useCallback(() => {
|
||||||
if (unreadInfo?.readUptoEventId) {
|
if (unreadInfo?.readUptoEventId) {
|
||||||
setTimeline(getEmptyTimeline());
|
setTimeline(getEmptyTimeline());
|
||||||
loadEventTimeline(unreadInfo.readUptoEventId);
|
loadEventTimeline(unreadInfo.readUptoEventId);
|
||||||
}
|
}
|
||||||
};
|
}, [unreadInfo, loadEventTimeline]);
|
||||||
|
|
||||||
const handleMarkAsRead = () => {
|
const handleMarkAsRead = useCallback(() => {
|
||||||
markAsRead(mx, room.roomId, hideActivity);
|
markAsRead(mx, room.roomId, hideActivity);
|
||||||
};
|
}, [mx, room, hideActivity]);
|
||||||
|
|
||||||
const handleOpenReply: MouseEventHandler = useCallback(
|
const handleOpenReply: MouseEventHandler = useCallback(
|
||||||
async (evt) => {
|
async (evt) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user