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
-
import { View, atoms } from "@streamplace/components";
2
-
import Chat from "components/chat/chat";
3
import ChatBox from "components/chat/chat-box";
4
const { borderRadius, bottom, gap, h, layout, w, zIndex } = atoms;
5
···
24
w.percent[100],
25
{ transform: [{ translateY: slideKeyboard }] },
26
{
27
-
backgroundColor: "rgba(0, 0, 0, 0.4)",
28
borderRadius: borderRadius["2xl"],
0
29
},
30
]}
31
>
32
-
<Chat isChatVisible={true} setIsChatVisible={() => true} />
33
-
<View style={[layout.flex.column, gap.all[2], { padding: 10 }]}>
34
<ChatBox
35
isChatVisible={true}
36
chatBoxStyle={{ borderRadius: borderRadius.xl }}
···
1
+
import { atoms, Chat, View } from "@streamplace/components";
0
2
import ChatBox from "components/chat/chat-box";
3
const { borderRadius, bottom, gap, h, layout, w, zIndex } = atoms;
4
···
23
w.percent[100],
24
{ transform: [{ translateY: slideKeyboard }] },
25
{
26
+
backgroundColor: "rgba(0, 0, 0, 0.5)",
27
borderRadius: borderRadius["2xl"],
28
+
padding: 10,
29
},
30
]}
31
>
32
+
<Chat />
33
+
<View style={[layout.flex.column, gap.all[2]]}>
34
<ChatBox
35
isChatVisible={true}
36
chatBoxStyle={{ borderRadius: borderRadius.xl }}
+1
-1
js/app/components/mobile/ui.tsx
···
154
slideKeyboard={slideKeyboard}
155
/>
156
)}
0
157
<PlayerUI.CountdownOverlay
158
visible={showCountdown}
159
width={width}
160
height={height}
161
-
startFrom={3}
162
onDone={() => {
163
setShowCountdown(false);
164
}}
···
154
slideKeyboard={slideKeyboard}
155
/>
156
)}
157
+
158
<PlayerUI.CountdownOverlay
159
visible={showCountdown}
160
width={width}
161
height={height}
0
162
onDone={() => {
163
setShowCountdown(false);
164
}}
+142
js/components/src/components/chat/chat-message.tsx
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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";
2
import Animated, {
0
3
useAnimatedStyle,
0
4
useSharedValue,
5
withTiming,
6
} from "react-native-reanimated";
···
21
onDone,
22
}: CountdownOverlayProps) {
23
const [countdown, setCountdown] = useState(startFrom);
24
-
const intervalRef = useRef<NodeJS.Timeout | null>(null);
0
0
25
26
// Animation values
27
const scale = useSharedValue(1);
28
const opacity = useSharedValue(1);
29
30
-
// Animate and handle countdown
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
31
useEffect(() => {
32
if (visible) {
0
33
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);
49
} else {
50
setCountdown(startFrom);
51
-
if (intervalRef.current) clearInterval(intervalRef.current);
52
}
53
-
return () => {
54
-
if (intervalRef.current) clearInterval(intervalRef.current);
55
-
};
56
}, [visible, startFrom]);
57
58
// Animate scale and opacity on countdown change
···
1
+
import { useEffect, useState } from "react";
2
import Animated, {
3
+
runOnJS,
4
useAnimatedStyle,
5
+
useFrameCallback,
6
useSharedValue,
7
withTiming,
8
} from "react-native-reanimated";
···
23
onDone,
24
}: CountdownOverlayProps) {
25
const [countdown, setCountdown] = useState(startFrom);
26
+
27
+
const startTimestamp = useSharedValue<number | null>(null);
28
+
const done = useSharedValue(false);
29
30
// Animation values
31
const scale = useSharedValue(1);
32
const opacity = useSharedValue(1);
33
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
+
64
useEffect(() => {
65
if (visible) {
66
+
startTimestamp.value = null; // Will be set on first frame
67
setCountdown(startFrom);
68
+
done.value = false;
0
0
0
0
0
0
0
0
0
0
0
0
0
0
69
} else {
70
setCountdown(startFrom);
0
71
}
0
0
0
72
}, [visible, startFrom]);
73
74
// Animate scale and opacity on countdown change
+2
js/components/src/index.tsx
···
22
export * as atoms from "./lib/theme/atoms";
23
24
export * from "./hooks";
0
0
···
22
export * as atoms from "./lib/theme/atoms";
23
24
export * from "./hooks";
25
+
26
+
export * from "./components/chat/chat";
+131
js/components/src/lib/facet.ts
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
isLink,
4
isMention,
5
} from "@atproto/api/dist/client/types/app/bsky/richtext/facet";
0
6
import {
7
ChatMessageViewHydrated,
8
PlaceStreamChatMessage,
···
18
19
export const useSetReplyToMessage = () => {
20
const store = getStoreFromContext();
21
-
return (message: ChatMessageViewHydrated | null) => {
22
-
store.setState({ replyToMessage: message });
23
-
};
0
0
0
24
};
25
26
export type NewChatMessage = {
···
38
const userHandle = useHandle();
39
const chatProfile = useChatProfile();
40
41
-
return async (msg: NewChatMessage) => {
42
-
if (!pdsAgent || !userDID) {
43
-
throw new Error("No PDS agent or user DID found");
44
-
}
0
45
46
-
let state = store.getState();
47
48
-
const streamerProfile = state.profile;
49
50
-
if (!streamerProfile) {
51
-
throw new Error("Profile not found");
52
-
}
53
54
-
const rt = new RichText({ text: msg.text });
55
-
rt.detectFacetsWithoutResolution();
56
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,
0
0
0
0
0
67
},
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
-
};
104
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
-
};
116
117
-
state = reduceChat(state, [localChat], []);
118
-
store.setState(state);
0
119
120
-
await pdsAgent.com.atproto.repo.createRecord({
121
-
repo: userDID,
122
-
collection: "place.stream.chat.message",
123
-
record,
124
-
});
125
-
};
0
0
0
0
0
0
0
0
126
};
127
128
-
const CHAT_LIMIT = 20;
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
129
130
-
export const reduceChat = (
0
0
0
0
0
0
0
0
0
0
0
0
131
state: LivestreamState,
132
-
messages: ChatMessageViewHydrated[],
133
blocks: PlaceStreamDefs.BlockView[],
134
): LivestreamState => {
135
-
state = { ...state } as LivestreamState;
136
-
let newChat: { [key: string]: ChatMessageViewHydrated } = {
137
-
...state.chatIndex,
138
-
};
139
140
-
// Add new messages
141
-
for (let message of messages) {
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
142
const date = new Date(message.record.createdAt);
143
const key = `${date.getTime()}-${message.uri}`;
144
145
-
// Remove existing local message matching the server one
0
0
0
0
0
146
if (!message.uri.startsWith("local-")) {
147
-
const existingLocalMessageKey = Object.keys(newChat).find((k) => {
148
-
const msg = newChat[k];
149
return (
150
msg.uri.startsWith("local-") &&
151
msg.record.text === message.record.text &&
152
-
msg.author.did === message.author.did
0
0
153
);
154
});
155
156
-
if (existingLocalMessageKey) {
157
-
delete newChat[existingLocalMessageKey];
0
0
158
}
159
}
160
161
-
// Handle reply information for local-first messages
0
162
if (message.record.reply) {
163
const reply = message.record.reply as {
164
parent?: { uri: string; cid: string };
···
166
};
167
168
const parentUri = reply?.parent?.uri || reply?.root?.uri;
169
-
170
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,
174
);
175
176
if (parentMsgKey) {
177
-
// Found the parent message, add its info to our message
178
-
const parentMsg = newChat[parentMsgKey];
179
-
message = {
180
...message,
181
replyTo: {
182
cid: parentMsg.cid,
···
191
}
192
}
193
194
-
newChat[key] = message;
0
195
}
196
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
-
}
203
}
204
205
-
let newChatList = Object.values(newChat).sort((a, b) =>
206
-
new Date(a.record.createdAt) > new Date(b.record.createdAt) ? 1 : -1,
207
-
);
0
208
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 },
217
);
218
219
return {
220
...state,
221
-
chatIndex: newChat,
222
chat: newChatList,
223
};
224
};
0
0
···
3
isLink,
4
isMention,
5
} from "@atproto/api/dist/client/types/app/bsky/richtext/facet";
6
+
import { useCallback } from "react";
7
import {
8
ChatMessageViewHydrated,
9
PlaceStreamChatMessage,
···
19
20
export const useSetReplyToMessage = () => {
21
const store = getStoreFromContext();
22
+
return useCallback(
23
+
(message: ChatMessageViewHydrated | null) => {
24
+
store.setState({ replyToMessage: message });
25
+
},
26
+
[store],
27
+
);
28
};
29
30
export type NewChatMessage = {
···
42
const userHandle = useHandle();
43
const chatProfile = useChatProfile();
44
45
+
return useCallback(
46
+
async (msg: NewChatMessage) => {
47
+
if (!pdsAgent || !userDID) {
48
+
throw new Error("No PDS agent or user DID found");
49
+
}
50
51
+
let state = store.getState();
52
53
+
const streamerProfile = state.profile;
54
55
+
if (!streamerProfile) {
56
+
throw new Error("Profile not found");
57
+
}
58
59
+
const rt = new RichText({ text: msg.text });
60
+
rt.detectFacetsWithoutResolution();
61
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
+
},
77
},
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
+
};
0
0
0
0
0
109
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
+
};
121
122
+
// Optimistic update - use incremental approach
123
+
state = reduceChatIncremental(state, [localChat], []);
124
+
store.setState(state);
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
+
);
140
};
141
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
+
);
169
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 = (
183
state: LivestreamState,
184
+
newMessages: ChatMessageViewHydrated[],
185
blocks: PlaceStreamDefs.BlockView[],
186
): LivestreamState => {
187
+
if (newMessages.length === 0 && blocks.length === 0) {
188
+
return state;
189
+
}
0
190
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) {
210
const date = new Date(message.record.createdAt);
211
const key = `${date.getTime()}-${message.uri}`;
212
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
219
if (!message.uri.startsWith("local-")) {
220
+
const existingLocalKey = Object.keys(newChatIndex).find((k) => {
221
+
const msg = newChatIndex[k];
222
return (
223
msg.uri.startsWith("local-") &&
224
msg.record.text === message.record.text &&
225
+
msg.author.did === message.author.did &&
226
+
Math.abs(new Date(msg.record.createdAt).getTime() - date.getTime()) <
227
+
10000 // Within 10 seconds
228
);
229
});
230
231
+
if (existingLocalKey) {
232
+
delete newChatIndex[existingLocalKey];
233
+
removedKeys.add(existingLocalKey);
234
+
hasChanges = true;
235
}
236
}
237
238
+
// add reply info
239
+
let processedMessage = message;
240
if (message.record.reply) {
241
const reply = message.record.reply as {
242
parent?: { uri: string; cid: string };
···
244
};
245
246
const parentUri = reply?.parent?.uri || reply?.root?.uri;
0
247
if (parentUri) {
248
+
const parentMsgKey = Object.keys(newChatIndex).find(
249
+
(k) => newChatIndex[k].uri === parentUri,
0
250
);
251
252
if (parentMsgKey) {
253
+
const parentMsg = newChatIndex[parentMsgKey];
254
+
processedMessage = {
0
255
...message,
256
replyTo: {
257
cid: parentMsg.cid,
···
266
}
267
}
268
269
+
messagesToAdd.push({ key, message: processedMessage });
270
+
hasChanges = true;
271
}
272
273
+
// Add new messages to index
274
+
for (const { key, message } of messagesToAdd) {
275
+
newChatIndex[key] = message;
0
0
0
276
}
277
278
+
// only rebuild if we have changes
279
+
if (!hasChanges) {
280
+
return state;
281
+
}
282
283
+
// Build the new sorted chat list efficiently
284
+
const newChatList = buildSortedChatList(
285
+
newChatIndex,
286
+
state.chat,
287
+
messagesToAdd,
288
+
removedKeys,
0
0
289
);
290
291
return {
292
...state,
293
+
chatIndex: newChatIndex,
294
chat: newChatList,
295
};
296
};
297
+
298
+
export const reduceChat = reduceChatIncremental;