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