Live video on the AT Protocol

Rework chat rendering

+580 -149
+5 -5
js/app/components/mobile/chat.tsx
··· 1 - import { View, atoms } from "@streamplace/components"; 2 - import Chat from "components/chat/chat"; 1 + import { atoms, Chat, View } from "@streamplace/components"; 3 2 import ChatBox from "components/chat/chat-box"; 4 3 const { borderRadius, bottom, gap, h, layout, w, zIndex } = atoms; 5 4 ··· 24 23 w.percent[100], 25 24 { transform: [{ translateY: slideKeyboard }] }, 26 25 { 27 - backgroundColor: "rgba(0, 0, 0, 0.4)", 26 + backgroundColor: "rgba(0, 0, 0, 0.5)", 28 27 borderRadius: borderRadius["2xl"], 28 + padding: 10, 29 29 }, 30 30 ]} 31 31 > 32 - <Chat isChatVisible={true} setIsChatVisible={() => true} /> 33 - <View style={[layout.flex.column, gap.all[2], { padding: 10 }]}> 32 + <Chat /> 33 + <View style={[layout.flex.column, gap.all[2]]}> 34 34 <ChatBox 35 35 isChatVisible={true} 36 36 chatBoxStyle={{ borderRadius: borderRadius.xl }}
+1 -1
js/app/components/mobile/ui.tsx
··· 154 154 slideKeyboard={slideKeyboard} 155 155 /> 156 156 )} 157 + 157 158 <PlayerUI.CountdownOverlay 158 159 visible={showCountdown} 159 160 width={width} 160 161 height={height} 161 - startFrom={3} 162 162 onDone={() => { 163 163 setShowCountdown(false); 164 164 }}
+142
js/components/src/components/chat/chat-message.tsx
··· 1 + import { $Typed } from "@atproto/api"; 2 + import { 3 + Link, 4 + Mention, 5 + } from "@atproto/api/dist/client/types/app/bsky/richtext/facet"; 6 + import { memo, useCallback } from "react"; 7 + import { Linking, View } from "react-native"; 8 + import { ChatMessageViewHydrated } from "streamplace"; 9 + import { RichtextSegment, segmentize } from "../../lib/facet"; 10 + import { flex, gap, w } from "../../lib/theme/atoms"; 11 + import { atoms, layout } from "../ui"; 12 + 13 + interface Facet { 14 + index: { 15 + byteStart: number; 16 + byteEnd: number; 17 + }; 18 + features: Array<{ 19 + $type: string; 20 + uri?: string; 21 + did?: string; 22 + }>; 23 + } 24 + 25 + import { Text } from "../ui/text"; 26 + 27 + const getRgbColor = (color?: { red: number; green: number; blue: number }) => 28 + color ? `rgb(${color.red}, ${color.green}, ${color.blue})` : "$accentColor"; 29 + 30 + const segmentedObject = ( 31 + obj: RichtextSegment, 32 + index: number, 33 + userCache?: Map<string, ChatMessageViewHydrated["chatProfile"]>, 34 + ) => { 35 + if (obj.features && obj.features.length > 0) { 36 + let ftr = obj.features[0]; 37 + // afaik there shouldn't be a case where facets overlap, at least currently 38 + if (ftr.$type === "app.bsky.richtext.facet#link") { 39 + let linkftr = ftr as $Typed<Link>; 40 + return ( 41 + <Text 42 + key={`mention-${index}`} 43 + style={[{ color: atoms.colors.ios.systemBlue, cursor: "pointer" }]} 44 + onPress={() => Linking.openURL(linkftr.uri || "")} 45 + > 46 + {obj.text} 47 + </Text> 48 + ); 49 + } else if (ftr.$type === "app.bsky.richtext.facet#mention") { 50 + let mtnftr = ftr as $Typed<Mention>; 51 + const profile = userCache?.get(mtnftr.did); 52 + return ( 53 + <Text 54 + key={`mention-${index}`} 55 + style={[ 56 + { 57 + cursor: "pointer", 58 + color: getRgbColor(profile?.color), 59 + }, 60 + ]} 61 + onPress={() => 62 + Linking.openURL(`https://bsky.app/profile/${mtnftr.did || ""}`) 63 + } 64 + > 65 + {obj.text} 66 + </Text> 67 + ); 68 + } 69 + } else { 70 + return <Text key={`text-${index}`}>{obj.text}</Text>; 71 + } 72 + }; 73 + 74 + const RichTextMessage = ({ 75 + text, 76 + facets, 77 + userCache, 78 + }: { 79 + text: string; 80 + facets: ChatMessageViewHydrated["record"]["facets"]; 81 + userCache?: Map<string, ChatMessageViewHydrated["chatProfile"]>; 82 + }) => { 83 + if (!facets?.length) return <Text>{text}</Text>; 84 + 85 + let segs = segmentize(text, facets as Facet[]); 86 + 87 + return segs.map((seg, i) => segmentedObject(seg, i, userCache)); 88 + }; 89 + export const RenderChatMessage = memo( 90 + function RenderChatMessage({ 91 + item, 92 + userCache, 93 + }: { 94 + item: ChatMessageViewHydrated; 95 + userCache?: Map<string, ChatMessageViewHydrated["chatProfile"]>; 96 + }) { 97 + const formatTime = useCallback((dateString: string) => { 98 + return new Date(dateString).toLocaleString(undefined, { 99 + hour: "2-digit", 100 + minute: "2-digit", 101 + hour12: false, 102 + }); 103 + }, []); 104 + 105 + return ( 106 + <View style={[gap.all[2], layout.flex.row, w.percent[100]]}> 107 + <Text 108 + style={{ 109 + fontVariant: ["tabular-nums"], 110 + color: atoms.colors.gray[300], 111 + }} 112 + > 113 + {formatTime(item.record.createdAt)} 114 + </Text> 115 + <Text weight="bold" color="default" style={[flex.shrink[1]]}> 116 + <Text 117 + style={[ 118 + { 119 + cursor: "pointer", 120 + color: getRgbColor(item.chatProfile?.color), 121 + }, 122 + ]} 123 + > 124 + @{item.author.handle} 125 + </Text> 126 + :{" "} 127 + <RichTextMessage 128 + text={item.record.text} 129 + facets={item.record.facets || []} 130 + userCache={userCache} 131 + /> 132 + </Text> 133 + </View> 134 + ); 135 + }, 136 + (prevProps, nextProps) => { 137 + return ( 138 + prevProps.item.author.handle === nextProps.item.author.handle && 139 + prevProps.item.record.text === nextProps.item.record.text 140 + ); 141 + }, 142 + );
+66
js/components/src/components/chat/chat.tsx
··· 1 + import { Text, useChat, View } from "@streamplace/components"; 2 + import { useCallback, useEffect, useMemo, useState } from "react"; 3 + import { 4 + FlatList, 5 + ListRenderItemInfo, 6 + useWindowDimensions, 7 + } from "react-native"; 8 + import { ChatMessageViewHydrated } from "streamplace"; 9 + import { flex, w } from "../../lib/theme/atoms"; 10 + import { RenderChatMessage } from "./chat-message"; 11 + 12 + export function Chat() { 13 + const chat = useChat(); 14 + const [authors, setAuthors] = useState< 15 + Map<string, ChatMessageViewHydrated["chatProfile"]> 16 + >(new Map()); 17 + const { width } = useWindowDimensions(); 18 + const showTime = width > 800; 19 + 20 + const reversedChat = useMemo(() => { 21 + return chat ? [...chat].reverse() : []; 22 + }, [chat]); 23 + 24 + useEffect(() => { 25 + if (!chat || chat.length === 0) return; 26 + 27 + const uniqueAuthors = chat.reduce((acc, msg) => { 28 + acc.set(msg.author.did, msg.chatProfile); 29 + return acc; 30 + }, new Map<string, ChatMessageViewHydrated["chatProfile"]>()); 31 + 32 + setAuthors(uniqueAuthors); 33 + }, [chat]); 34 + 35 + const keyExtractor = useCallback( 36 + (item: ChatMessageViewHydrated, index: number) => { 37 + return `${item.author.handle}-${item.record.text}-${index}`; 38 + }, 39 + [], 40 + ); 41 + 42 + const renderItem = useCallback( 43 + ({ item }: ListRenderItemInfo<ChatMessageViewHydrated>) => { 44 + return <RenderChatMessage item={item} />; 45 + }, 46 + [showTime], 47 + ); 48 + 49 + if (!chat) return <Text>Loading chat...</Text>; 50 + 51 + return ( 52 + <View style={[flex.shrink[1]]}> 53 + <FlatList 54 + style={[flex.grow[1], flex.shrink[1], w.percent[100]]} 55 + data={reversedChat} 56 + keyExtractor={keyExtractor} 57 + inverted 58 + renderItem={renderItem} 59 + removeClippedSubviews={true} 60 + maxToRenderPerBatch={10} 61 + initialNumToRender={20} 62 + updateCellsBatchingPeriod={50} 63 + /> 64 + </View> 65 + ); 66 + }
+38 -22
js/components/src/components/mobile-player/ui/countdown.tsx
··· 1 - import { useEffect, useRef, useState } from "react"; 1 + import { useEffect, useState } from "react"; 2 2 import Animated, { 3 + runOnJS, 3 4 useAnimatedStyle, 5 + useFrameCallback, 4 6 useSharedValue, 5 7 withTiming, 6 8 } from "react-native-reanimated"; ··· 21 23 onDone, 22 24 }: CountdownOverlayProps) { 23 25 const [countdown, setCountdown] = useState(startFrom); 24 - const intervalRef = useRef<NodeJS.Timeout | null>(null); 26 + 27 + const startTimestamp = useSharedValue<number | null>(null); 28 + const done = useSharedValue(false); 25 29 26 30 // Animation values 27 31 const scale = useSharedValue(1); 28 32 const opacity = useSharedValue(1); 29 33 30 - // Animate and handle countdown 34 + const updateCountdown = (value: number) => { 35 + setCountdown(value); 36 + }; 37 + 38 + const handleDone = () => { 39 + if (onDone) onDone(); 40 + }; 41 + 42 + // Accurate countdown using useFrameCallback 43 + useFrameCallback(({ timestamp }) => { 44 + if (!visible) return; 45 + 46 + // Set start timestamp on first frame 47 + if (startTimestamp.value === null) { 48 + startTimestamp.value = timestamp; 49 + return; 50 + } 51 + 52 + const elapsed = (timestamp - startTimestamp.value) / 1000; // Convert to seconds 53 + const remaining = Math.max(0, startFrom - Math.floor(elapsed)); 54 + 55 + // Use runOnJS to call JavaScript functions from worklet 56 + runOnJS(updateCountdown)(remaining); 57 + 58 + if (remaining === 0 && !done.value) { 59 + done.value = true; 60 + runOnJS(handleDone)(); 61 + } 62 + }); 63 + 31 64 useEffect(() => { 32 65 if (visible) { 66 + startTimestamp.value = null; // Will be set on first frame 33 67 setCountdown(startFrom); 34 - console.log("Countdown started from:", startFrom); 35 - 36 - // Start countdown interval 37 - intervalRef.current = setInterval(() => { 38 - console.log("Setting countdown"); 39 - setCountdown((prev) => { 40 - if (prev <= 1) { 41 - if (intervalRef.current) clearInterval(intervalRef.current); 42 - console.log("Probably done"); 43 - if (onDone) onDone(); 44 - return 0; 45 - } 46 - return prev - 1; 47 - }); 48 - }, 1000); 68 + done.value = false; 49 69 } else { 50 70 setCountdown(startFrom); 51 - if (intervalRef.current) clearInterval(intervalRef.current); 52 71 } 53 - return () => { 54 - if (intervalRef.current) clearInterval(intervalRef.current); 55 - }; 56 72 }, [visible, startFrom]); 57 73 58 74 // Animate scale and opacity on countdown change
+2
js/components/src/index.tsx
··· 22 22 export * as atoms from "./lib/theme/atoms"; 23 23 24 24 export * from "./hooks"; 25 + 26 + export * from "./components/chat/chat";
+131
js/components/src/lib/facet.ts
··· 1 + // Taken from ATcute's richtext-segmenter 2 + // https://github.com/mary-ext/atcute/blob/trunk/packages/bluesky/richtext-segmenter/lib/index.ts 3 + // repoed b/c we need to import types from @atproto/api not @atcute/bsky 4 + import { Main } from "@atproto/api/dist/client/types/app/bsky/richtext/facet"; 5 + 6 + type UnwrapArray<T> = T extends (infer V)[] ? V : never; 7 + 8 + export type Facet = Main; 9 + export type FacetFeature = UnwrapArray<Facet["features"]>; 10 + 11 + export interface RichtextSegment { 12 + text: string; 13 + features: FacetFeature[] | undefined; 14 + } 15 + 16 + const segment = ( 17 + text: string, 18 + features: FacetFeature[] | undefined, 19 + ): RichtextSegment => { 20 + return { text, features: text.length > 0 ? features : undefined }; 21 + }; 22 + 23 + export const segmentize = ( 24 + text: string, 25 + facets: Facet[] | undefined, 26 + ): RichtextSegment[] => { 27 + if (facets === undefined || facets.length === 0) { 28 + return [segment(text, undefined)]; 29 + } 30 + 31 + const segments: RichtextSegment[] = []; 32 + const utf16Length = text.length; 33 + let utf16Cursor = 0; 34 + let utf8Cursor = 0; 35 + 36 + const advanceCursor = (startUtf16: number, endUtf8: number): number => { 37 + let curs = startUtf16; 38 + 39 + // Fast-path for entirely ASCII text 40 + const isLikelyAsciiText = text.charCodeAt(curs) < 0x80; 41 + if (isLikelyAsciiText) { 42 + curs += 1; 43 + utf8Cursor += 1; 44 + 45 + // SIMD-like batch processing 46 + while (utf8Cursor + 8 <= endUtf8 && curs + 8 <= utf16Length) { 47 + const char1 = text.charCodeAt(curs); 48 + const char2 = text.charCodeAt(curs + 1); 49 + const char3 = text.charCodeAt(curs + 2); 50 + const char4 = text.charCodeAt(curs + 3); 51 + const char5 = text.charCodeAt(curs + 4); 52 + const char6 = text.charCodeAt(curs + 5); 53 + const char7 = text.charCodeAt(curs + 6); 54 + const char8 = text.charCodeAt(curs + 7); 55 + 56 + if ( 57 + (char1 | char2 | char3 | char4 | char5 | char6 | char7 | char8) < 58 + 0x80 59 + ) { 60 + curs += 8; 61 + utf8Cursor += 8; 62 + continue; 63 + } 64 + 65 + break; 66 + } 67 + } 68 + 69 + // Process remaining characters individually 70 + while (utf8Cursor < endUtf8 && curs < utf16Length) { 71 + const code = text.charCodeAt(curs); 72 + 73 + if (code < 0x80) { 74 + curs += 1; 75 + utf8Cursor += 1; 76 + } else if (code < 0x800) { 77 + curs += 1; 78 + utf8Cursor += 2; 79 + } else if (code < 0xd800 || code > 0xdbff) { 80 + curs += 1; 81 + utf8Cursor += 3; 82 + } else { 83 + curs += 2; 84 + utf8Cursor += 4; 85 + } 86 + } 87 + 88 + return curs; 89 + }; 90 + 91 + // Process facets 92 + for (let idx = 0, len = facets.length; idx < len; idx++) { 93 + const facet = facets[idx]; 94 + 95 + const { byteStart, byteEnd } = facet.index; 96 + const features = facet.features; 97 + 98 + if (byteStart > byteEnd || features.length === 0) { 99 + continue; 100 + } 101 + 102 + if (utf8Cursor < byteStart) { 103 + const nextUtf16Cursor = advanceCursor(utf16Cursor, byteStart); 104 + if (nextUtf16Cursor > utf16Cursor) { 105 + segments.push( 106 + segment(text.slice(utf16Cursor, nextUtf16Cursor), undefined), 107 + ); 108 + } 109 + 110 + utf16Cursor = nextUtf16Cursor; 111 + } 112 + 113 + { 114 + const nextUtf16Cursor = advanceCursor(utf16Cursor, byteEnd); 115 + if (nextUtf16Cursor > utf16Cursor) { 116 + segments.push( 117 + segment(text.slice(utf16Cursor, nextUtf16Cursor), features), 118 + ); 119 + } 120 + 121 + utf16Cursor = nextUtf16Cursor; 122 + } 123 + } 124 + 125 + // Handle remaining text 126 + if (utf16Cursor < utf16Length) { 127 + segments.push(segment(text.slice(utf16Cursor), undefined)); 128 + } 129 + 130 + return segments; 131 + };
+195 -121
js/components/src/livestream-store/chat.tsx
··· 3 3 isLink, 4 4 isMention, 5 5 } from "@atproto/api/dist/client/types/app/bsky/richtext/facet"; 6 + import { useCallback } from "react"; 6 7 import { 7 8 ChatMessageViewHydrated, 8 9 PlaceStreamChatMessage, ··· 18 19 19 20 export const useSetReplyToMessage = () => { 20 21 const store = getStoreFromContext(); 21 - return (message: ChatMessageViewHydrated | null) => { 22 - store.setState({ replyToMessage: message }); 23 - }; 22 + return useCallback( 23 + (message: ChatMessageViewHydrated | null) => { 24 + store.setState({ replyToMessage: message }); 25 + }, 26 + [store], 27 + ); 24 28 }; 25 29 26 30 export type NewChatMessage = { ··· 38 42 const userHandle = useHandle(); 39 43 const chatProfile = useChatProfile(); 40 44 41 - return async (msg: NewChatMessage) => { 42 - if (!pdsAgent || !userDID) { 43 - throw new Error("No PDS agent or user DID found"); 44 - } 45 + return useCallback( 46 + async (msg: NewChatMessage) => { 47 + if (!pdsAgent || !userDID) { 48 + throw new Error("No PDS agent or user DID found"); 49 + } 45 50 46 - let state = store.getState(); 51 + let state = store.getState(); 47 52 48 - const streamerProfile = state.profile; 53 + const streamerProfile = state.profile; 49 54 50 - if (!streamerProfile) { 51 - throw new Error("Profile not found"); 52 - } 55 + if (!streamerProfile) { 56 + throw new Error("Profile not found"); 57 + } 53 58 54 - const rt = new RichText({ text: msg.text }); 55 - rt.detectFacetsWithoutResolution(); 59 + const rt = new RichText({ text: msg.text }); 60 + rt.detectFacetsWithoutResolution(); 56 61 57 - const record: PlaceStreamChatMessage.Record = { 58 - text: msg.text, 59 - createdAt: new Date().toISOString(), 60 - streamer: streamerProfile.did, 61 - ...(msg.reply 62 - ? { 63 - reply: { 64 - root: { 65 - cid: msg.reply.cid, 66 - uri: msg.reply.uri, 62 + const record: PlaceStreamChatMessage.Record = { 63 + text: msg.text, 64 + createdAt: new Date().toISOString(), 65 + streamer: streamerProfile.did, 66 + ...(msg.reply 67 + ? { 68 + reply: { 69 + root: { 70 + cid: msg.reply.cid, 71 + uri: msg.reply.uri, 72 + }, 73 + parent: { 74 + cid: msg.reply.cid, 75 + uri: msg.reply.uri, 76 + }, 67 77 }, 68 - parent: { 69 - cid: msg.reply.cid, 70 - uri: msg.reply.uri, 71 - }, 72 - }, 73 - } 74 - : {}), 75 - ...(rt.facets && rt.facets.length > 0 76 - ? { 77 - facets: rt.facets.map((facet) => ({ 78 - index: facet.index, 79 - features: facet.features 80 - .filter( 81 - (feature) => 82 - feature.$type === "app.bsky.richtext.facet#link" || 83 - feature.$type === "app.bsky.richtext.facet#mention", 84 - ) 85 - .map((feature) => { 86 - if (isLink(feature)) { 87 - return { 88 - $type: "app.bsky.richtext.facet#link", 89 - uri: feature.uri, 90 - }; 91 - } else if (isMention(feature)) { 92 - return { 93 - $type: "app.bsky.richtext.facet#mention", 94 - did: feature.did, 95 - }; 96 - } else { 97 - throw new Error("invalid code path"); 98 - } 99 - }), 100 - })), 101 - } 102 - : {}), 103 - }; 78 + } 79 + : {}), 80 + ...(rt.facets && rt.facets.length > 0 81 + ? { 82 + facets: rt.facets.map((facet) => ({ 83 + index: facet.index, 84 + features: facet.features 85 + .filter( 86 + (feature) => 87 + feature.$type === "app.bsky.richtext.facet#link" || 88 + feature.$type === "app.bsky.richtext.facet#mention", 89 + ) 90 + .map((feature) => { 91 + if (isLink(feature)) { 92 + return { 93 + $type: "app.bsky.richtext.facet#link", 94 + uri: feature.uri, 95 + }; 96 + } else if (isMention(feature)) { 97 + return { 98 + $type: "app.bsky.richtext.facet#mention", 99 + did: feature.did, 100 + }; 101 + } else { 102 + throw new Error("invalid code path"); 103 + } 104 + }), 105 + })), 106 + } 107 + : {}), 108 + }; 104 109 105 - const localChat: ChatMessageViewHydrated = { 106 - uri: `local-${Date.now()}`, 107 - cid: "", 108 - author: { 109 - did: userDID, 110 - handle: userHandle || userDID, 111 - }, 112 - record: record, 113 - indexedAt: new Date().toISOString(), 114 - chatProfile: chatProfile || undefined, 115 - }; 110 + const localChat: ChatMessageViewHydrated = { 111 + uri: `local-${Date.now()}-${userDID.slice(-8)}`, 112 + cid: "", 113 + author: { 114 + did: userDID, 115 + handle: userHandle || userDID, 116 + }, 117 + record: record, 118 + indexedAt: new Date().toISOString(), 119 + chatProfile: chatProfile || undefined, 120 + }; 116 121 117 - state = reduceChat(state, [localChat], []); 118 - store.setState(state); 122 + // Optimistic update - use incremental approach 123 + state = reduceChatIncremental(state, [localChat], []); 124 + store.setState(state); 119 125 120 - await pdsAgent.com.atproto.repo.createRecord({ 121 - repo: userDID, 122 - collection: "place.stream.chat.message", 123 - record, 124 - }); 125 - }; 126 + try { 127 + await pdsAgent.com.atproto.repo.createRecord({ 128 + repo: userDID, 129 + collection: "place.stream.chat.message", 130 + record, 131 + }); 132 + } catch (error) { 133 + // On error, we could implement a retry mechanism or remove the optimistic message 134 + console.error("Failed to send chat message:", error); 135 + throw error; 136 + } 137 + }, 138 + [pdsAgent, store, userDID, userHandle, chatProfile], 139 + ); 126 140 }; 127 141 128 - const CHAT_LIMIT = 20; 142 + const buildSortedChatList = ( 143 + chatIndex: { [key: string]: ChatMessageViewHydrated }, 144 + existingChatList: ChatMessageViewHydrated[], 145 + newMessages: { key: string; message: ChatMessageViewHydrated }[], 146 + removedKeys: Set<string>, 147 + ): ChatMessageViewHydrated[] => { 148 + // if the update is large, just rebuild as it'll probably be faster 149 + if (newMessages.length > 10 || removedKeys.size > 0) { 150 + const sortedKeys = Object.keys(chatIndex).sort((a, b) => 151 + a.localeCompare(b), 152 + ); 153 + return sortedKeys.map((key) => chatIndex[key]); 154 + } 155 + 156 + // otherwise, we can do an incremental update 157 + let newChatList = [...existingChatList]; 158 + 159 + // i never thought i'd be writing binary search again 160 + for (const { key, message } of newMessages) { 161 + const timestamp = parseInt(key.split("-")[0]); 162 + let insertIndex = newChatList.length; 163 + 164 + for (let i = newChatList.length - 1; i >= 0; i--) { 165 + const existingMessage = newChatList[i]; 166 + const existingTimestamp = parseInt( 167 + new Date(existingMessage.record.createdAt).getTime().toString(), 168 + ); 129 169 130 - export const reduceChat = ( 170 + if (existingTimestamp <= timestamp) { 171 + insertIndex = i + 1; 172 + break; 173 + } 174 + } 175 + 176 + newChatList.splice(insertIndex, 0, message); 177 + } 178 + 179 + return newChatList; 180 + }; 181 + 182 + export const reduceChatIncremental = ( 131 183 state: LivestreamState, 132 - messages: ChatMessageViewHydrated[], 184 + newMessages: ChatMessageViewHydrated[], 133 185 blocks: PlaceStreamDefs.BlockView[], 134 186 ): LivestreamState => { 135 - state = { ...state } as LivestreamState; 136 - let newChat: { [key: string]: ChatMessageViewHydrated } = { 137 - ...state.chatIndex, 138 - }; 187 + if (newMessages.length === 0 && blocks.length === 0) { 188 + return state; 189 + } 139 190 140 - // Add new messages 141 - for (let message of messages) { 191 + const newChatIndex = { ...state.chatIndex }; 192 + let hasChanges = false; 193 + const removedKeys = new Set<string>(); 194 + 195 + // handle blocks 196 + if (blocks.length > 0) { 197 + const blockedDIDs = new Set(blocks.map((block) => block.record.subject)); 198 + for (const [key, message] of Object.entries(newChatIndex)) { 199 + if (blockedDIDs.has(message.author.did)) { 200 + delete newChatIndex[key]; 201 + removedKeys.add(key); 202 + hasChanges = true; 203 + } 204 + } 205 + } 206 + 207 + const messagesToAdd: { key: string; message: ChatMessageViewHydrated }[] = []; 208 + 209 + for (const message of newMessages) { 142 210 const date = new Date(message.record.createdAt); 143 211 const key = `${date.getTime()}-${message.uri}`; 144 212 145 - // Remove existing local message matching the server one 213 + // skip messages we already have 214 + if (newChatIndex[key] && newChatIndex[key].uri === message.uri) { 215 + continue; 216 + } 217 + 218 + // if we have a local message, replace it with the new one 146 219 if (!message.uri.startsWith("local-")) { 147 - const existingLocalMessageKey = Object.keys(newChat).find((k) => { 148 - const msg = newChat[k]; 220 + const existingLocalKey = Object.keys(newChatIndex).find((k) => { 221 + const msg = newChatIndex[k]; 149 222 return ( 150 223 msg.uri.startsWith("local-") && 151 224 msg.record.text === message.record.text && 152 - msg.author.did === message.author.did 225 + msg.author.did === message.author.did && 226 + Math.abs(new Date(msg.record.createdAt).getTime() - date.getTime()) < 227 + 10000 // Within 10 seconds 153 228 ); 154 229 }); 155 230 156 - if (existingLocalMessageKey) { 157 - delete newChat[existingLocalMessageKey]; 231 + if (existingLocalKey) { 232 + delete newChatIndex[existingLocalKey]; 233 + removedKeys.add(existingLocalKey); 234 + hasChanges = true; 158 235 } 159 236 } 160 237 161 - // Handle reply information for local-first messages 238 + // add reply info 239 + let processedMessage = message; 162 240 if (message.record.reply) { 163 241 const reply = message.record.reply as { 164 242 parent?: { uri: string; cid: string }; ··· 166 244 }; 167 245 168 246 const parentUri = reply?.parent?.uri || reply?.root?.uri; 169 - 170 247 if (parentUri) { 171 - // First try to find the parent message in our chat 172 - const parentMsgKey = Object.keys(newChat).find( 173 - (k) => newChat[k].uri === parentUri, 248 + const parentMsgKey = Object.keys(newChatIndex).find( 249 + (k) => newChatIndex[k].uri === parentUri, 174 250 ); 175 251 176 252 if (parentMsgKey) { 177 - // Found the parent message, add its info to our message 178 - const parentMsg = newChat[parentMsgKey]; 179 - message = { 253 + const parentMsg = newChatIndex[parentMsgKey]; 254 + processedMessage = { 180 255 ...message, 181 256 replyTo: { 182 257 cid: parentMsg.cid, ··· 191 266 } 192 267 } 193 268 194 - newChat[key] = message; 269 + messagesToAdd.push({ key, message: processedMessage }); 270 + hasChanges = true; 195 271 } 196 272 197 - for (const block of blocks) { 198 - for (const [k, v] of Object.entries(newChat)) { 199 - if (v.author.did === block.record.subject) { 200 - delete newChat[k]; 201 - } 202 - } 273 + // Add new messages to index 274 + for (const { key, message } of messagesToAdd) { 275 + newChatIndex[key] = message; 203 276 } 204 277 205 - let newChatList = Object.values(newChat).sort((a, b) => 206 - new Date(a.record.createdAt) > new Date(b.record.createdAt) ? 1 : -1, 207 - ); 278 + // only rebuild if we have changes 279 + if (!hasChanges) { 280 + return state; 281 + } 208 282 209 - newChatList = newChatList.slice(-CHAT_LIMIT); 210 - 211 - newChat = newChatList.reduce( 212 - (acc, msg) => { 213 - acc[msg.uri] = msg; 214 - return acc; 215 - }, 216 - {} as { [key: string]: ChatMessageViewHydrated }, 283 + // Build the new sorted chat list efficiently 284 + const newChatList = buildSortedChatList( 285 + newChatIndex, 286 + state.chat, 287 + messagesToAdd, 288 + removedKeys, 217 289 ); 218 290 219 291 return { 220 292 ...state, 221 - chatIndex: newChat, 293 + chatIndex: newChatIndex, 222 294 chat: newChatList, 223 295 }; 224 296 }; 297 + 298 + export const reduceChat = reduceChatIncremental;