Live video on the AT Protocol
1import {
2 useChat,
3 useProfile,
4 useSetReplyToMessage,
5} from "@streamplace/components";
6import { Reply, Settings, X } from "@tamagui/lucide-icons";
7import {
8 createBlockRecord,
9 selectUserProfile,
10} from "features/bluesky/blueskySlice";
11import { MessageViewHydrated } from "features/player/playerSlice";
12import usePlatform from "hooks/usePlatform";
13import { useEffect, useRef, useState } from "react";
14import { Linking, TouchableOpacity } from "react-native";
15import { useAppDispatch, useAppSelector } from "store/hooks";
16
17import { $Typed, RichText } from "@atproto/api";
18import {
19 isMention,
20 Link,
21 Mention,
22} from "@atproto/api/dist/client/types/app/bsky/richtext/facet";
23import { ChatMessageViewHydrated } from "streamplace";
24import { Button, ScrollView, Sheet, Text, useMedia, View } from "tamagui";
25import { RichtextSegment, segmentize } from "../../utils/facet";
26
27export default function Chat({
28 isChatVisible,
29 setIsChatVisible: _setIsChatVisible,
30}: {
31 isChatVisible: boolean;
32 setIsChatVisible: (visible: boolean) => void;
33}) {
34 const [open, setOpen] = useState(false);
35 const [modMessage, setMessage] = useState<ChatMessageViewHydrated | null>(
36 null,
37 );
38 const [isAtBottom, setIsAtBottom] = useState(true);
39 const chat = useChat();
40 const scrollRef = useRef<ScrollView>(null);
41 const streamerProfile = useProfile();
42 const userProfile = useAppSelector(selectUserProfile);
43 const myStream = !!(
44 userProfile &&
45 userProfile.did &&
46 userProfile.did === streamerProfile?.did
47 );
48
49 const handleScroll = (event: any) => {
50 const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent;
51 const paddingToBottom = 100;
52 const isAtBottom =
53 layoutMeasurement.height + contentOffset.y >=
54 contentSize.height - paddingToBottom;
55 setIsAtBottom(isAtBottom);
56 };
57
58 useEffect(() => {
59 if (chat && isAtBottom) {
60 scrollRef.current?.scrollToEnd({ animated: true });
61 }
62 }, [chat, isAtBottom]);
63
64 if (!chat) {
65 return <></>;
66 }
67
68 const m = useMedia();
69 const dispatch = useAppDispatch();
70
71 return (
72 <View f={1} position="relative">
73 {isChatVisible && (
74 <>
75 <Sheet
76 // forceRemoveScrollEnabled={open}
77 open={open}
78 modal={!m.gtXs}
79 onOpenChange={setOpen}
80 // snapPoints={snapPoints}
81 // snapPointsMode={snapPointsMode}
82 dismissOnSnapToBottom
83 // position={position}
84 // onPositionChange={setPosition}
85 zIndex={100_000}
86 animation="medium"
87 >
88 <Sheet.Overlay
89 animation="lazy"
90 backgroundColor="$shadow6"
91 enterStyle={{ opacity: 0 }}
92 exitStyle={{ opacity: 0 }}
93 />
94 <Sheet.Frame>
95 <View
96 f={1}
97 alignItems="center"
98 justifyContent="center"
99 padding="$4"
100 gap="$5"
101 backgroundColor="$accentBackground"
102 >
103 <Button
104 position="absolute"
105 top="$0"
106 right="$0"
107 onPress={(e) => {
108 e.stopPropagation();
109 setOpen(false);
110 }}
111 marginRight={-15}
112 marginTop={-5}
113 backgroundColor="transparent"
114 >
115 <X />
116 </Button>
117 {modMessage && (
118 <>
119 <ChatMessageText message={modMessage} chat={chat || []} />
120 {modMessage.author.did !== userProfile?.did && (
121 <Button
122 width="100%"
123 onPress={() => {
124 setOpen(false);
125 dispatch(
126 createBlockRecord({
127 subjectDID: modMessage.author.did,
128 }),
129 );
130 }}
131 >
132 <Text>Block @{modMessage.author.handle}</Text>
133 </Button>
134 )}
135 {modMessage.author.did === userProfile?.did && (
136 <>
137 <Button width="100%" disabled={true}>
138 <Text>(You can't block yourself!)</Text>
139 </Button>
140 </>
141 )}
142 </>
143 )}
144 </View>
145 </Sheet.Frame>
146 </Sheet>
147 <ScrollView
148 paddingHorizontal="$4"
149 invertStickyHeaders={true}
150 ref={scrollRef}
151 onContentSizeChange={() => {
152 if (isAtBottom) {
153 scrollRef.current?.scrollToEnd({ animated: true });
154 }
155 }}
156 onLayout={() => {
157 if (isAtBottom) {
158 scrollRef.current?.scrollToEnd({ animated: true });
159 }
160 }}
161 onScroll={handleScroll}
162 scrollEventThrottle={16}
163 >
164 {chat.map((message) => (
165 <ChatMessageRow
166 key={message.cid}
167 message={message}
168 setOpen={setOpen}
169 setMessage={setMessage}
170 myStream={myStream}
171 replyHandle={undefined}
172 chat={chat}
173 />
174 ))}
175 </ScrollView>
176 </>
177 )}
178 </View>
179 );
180}
181
182function ChatMessageRow({
183 message,
184 setMessage,
185 setOpen,
186 myStream,
187 replyHandle: _replyHandle,
188 chat,
189}: {
190 message: ChatMessageViewHydrated;
191 setOpen: (open: boolean) => void;
192 setMessage: (message: ChatMessageViewHydrated) => void;
193 myStream: boolean;
194 replyHandle?: string;
195 chat: ChatMessageViewHydrated[];
196}): JSX.Element {
197 const [hover, setHover] = useState(false);
198 const setReplyToMessage = useSetReplyToMessage();
199 const { isWeb } = usePlatform();
200
201 const moderateMessage = () => {
202 if (!myStream) {
203 return;
204 }
205 setOpen(true);
206 setMessage(message);
207 };
208
209 const handleReply = () => {
210 setReplyToMessage(message);
211 };
212
213 const replyTo = message.replyTo as ChatMessageViewHydrated | undefined;
214 const hasReply = !!replyTo;
215 const replyToHandle = replyTo?.author?.handle;
216 const replyToText = replyTo?.record?.text;
217 const replyToColor = replyTo?.chatProfile?.color
218 ? `rgb(${replyTo.chatProfile.color.red}, ${replyTo.chatProfile.color.green}, ${replyTo.chatProfile.color.blue})`
219 : "$accentColor";
220
221 return (
222 <View
223 flexDirection="row"
224 display="block"
225 paddingVertical={isWeb ? 6 : 4} // Adjust padding for web
226 position="relative"
227 onMouseEnter={() => setHover(true)}
228 onMouseLeave={() => setHover(false)}
229 hoverStyle={{ backgroundColor: "rgba(255,255,255,0.1)" }}
230 onPress={() => {
231 if (!isWeb) {
232 moderateMessage();
233 }
234 }}
235 onLongPress={handleReply}
236 >
237 {hasReply && (
238 <View
239 position="absolute"
240 left={6}
241 top={-8}
242 width={2}
243 height={16}
244 opacity={0.7}
245 />
246 )}
247 <View flexDirection="column" gap="$1" flex={1}>
248 {/* Reply section */}
249 {hasReply && (
250 <View
251 flexDirection="column"
252 marginBottom="$2"
253 paddingLeft="$3"
254 position="relative"
255 >
256 {/* Vertical reply line */}
257 <View
258 position="absolute"
259 left={6}
260 top={0}
261 bottom={0}
262 width={2}
263 borderRadius={2}
264 backgroundColor="$accentColor"
265 opacity={0.5}
266 />
267 {/* Reply preview */}
268 <View
269 flexDirection="row"
270 alignItems="center"
271 gap="$1"
272 paddingVertical="$1"
273 paddingHorizontal="$2"
274 borderRadius="$2"
275 marginLeft="-$1"
276 >
277 <Text fontSize={12} color={replyToColor} fontWeight="bold">
278 {replyToHandle ? `@${replyToHandle}` : ""}
279 </Text>
280 <Text
281 fontSize={12}
282 color="$color"
283 opacity={0.7}
284 numberOfLines={1}
285 flex={1}
286 >
287 {replyToText || ""}
288 </Text>
289 </View>
290 </View>
291 )}
292
293 {/* Message content */}
294 <View flexDirection="row" alignItems="flex-start" gap="$2">
295 <ChatMessageText message={message} chat={chat} />
296 </View>
297 </View>
298 <View
299 position="absolute"
300 flexDirection="row"
301 right={0}
302 top={0}
303 bottom={0}
304 alignItems="stretch"
305 justifyContent="flex-end"
306 gap="$2"
307 >
308 {isWeb && (
309 <TouchableOpacity
310 style={{
311 display: hover ? "flex" : "none",
312 alignItems: "center",
313 justifyContent: "center",
314 padding: 4,
315 }}
316 onPress={handleReply}
317 >
318 <Reply size={16} />
319 </TouchableOpacity>
320 )}
321 {isWeb && myStream && (
322 <TouchableOpacity
323 style={{
324 display: hover ? "flex" : "none",
325 alignItems: "center",
326 justifyContent: "center",
327 padding: 4,
328 }}
329 onPress={moderateMessage}
330 >
331 <Settings size={16} />
332 </TouchableOpacity>
333 )}
334 </View>
335 </View>
336 );
337}
338
339function byteOffsetToCharIndex(text: string, byteOffset: number): number {
340 const encoder = new TextEncoder();
341 let bytesCount = 0;
342 for (let i = 0; i < text.length; i++) {
343 const charBytes = encoder.encode(text[i]);
344 if (bytesCount + charBytes.length > byteOffset) {
345 return i;
346 }
347 bytesCount += charBytes.length;
348 }
349 return text.length;
350}
351
352const ChatMessageText = ({
353 message,
354 chat = [],
355}: {
356 message: ChatMessageViewHydrated;
357 chat?: ChatMessageViewHydrated[];
358}) => {
359 const rt = new RichText({ text: message.record.text });
360 rt.detectFacetsWithoutResolution();
361
362 // Process facets to add DID information for mentions
363 rt.facets?.forEach((facet) => {
364 facet.features.forEach((feature) => {
365 if (isMention(feature)) {
366 const startCharIndex = byteOffsetToCharIndex(
367 message.record.text,
368 facet.index.byteStart,
369 );
370 const endCharIndex = byteOffsetToCharIndex(
371 message.record.text,
372 facet.index.byteEnd,
373 );
374 const mentionText = message.record.text.slice(
375 startCharIndex,
376 endCharIndex,
377 );
378 // Find the mentioned user by their handle (removing the @ symbol)
379 const mentionedUser = chat.find(
380 (msg) => msg.author.handle === mentionText.slice(1),
381 );
382 if (mentionedUser) {
383 feature.did = mentionedUser.author.did;
384 }
385 }
386 });
387 });
388
389 return (
390 <Text fontSize={13}>
391 <Text
392 color={getRgbColor(message.chatProfile?.color)}
393 cursor="pointer"
394 onPress={() =>
395 Linking.openURL(`https://bsky.app/profile/${message.author.did}`)
396 }
397 >
398 {message.author.handle
399 ? `@${message.author.handle}`
400 : message.author.did}
401 :
402 </Text>
403 <Text> </Text>
404 <RichTextMessage
405 text={message.record.text}
406 facets={rt.facets as Facet[]}
407 chat={chat}
408 />
409 </Text>
410 );
411};
412
413interface Facet {
414 index: {
415 byteStart: number;
416 byteEnd: number;
417 };
418 features: Array<{
419 $type: string;
420 uri?: string;
421 did?: string;
422 }>;
423}
424
425const getRgbColor = (color?: { red: number; green: number; blue: number }) =>
426 color ? `rgb(${color.red}, ${color.green}, ${color.blue})` : "$accentColor";
427
428const segmentedObject = (
429 obj: RichtextSegment,
430 chat: MessageViewHydrated[],
431 index: number,
432) => {
433 if (obj.features && obj.features.length > 0) {
434 let ftr = obj.features[0];
435 // afaik there shouldn't be a case where facets overlap, at least currently
436 if (ftr.$type === "app.bsky.richtext.facet#link") {
437 let linkftr = ftr as $Typed<Link>;
438 return (
439 <Text
440 key={`mention-${index}`}
441 color="#9090f0"
442 cursor="pointer"
443 onPress={() => Linking.openURL(linkftr.uri || "")}
444 >
445 {obj.text}
446 </Text>
447 );
448 } else if (ftr.$type === "app.bsky.richtext.facet#mention") {
449 let mtnftr = ftr as $Typed<Mention>;
450 const mentionedUserMessage = chat.find(
451 (msg) => msg.author.did === mtnftr.did,
452 );
453 return (
454 <Text
455 key={`mention-${index}`}
456 color={getRgbColor(mentionedUserMessage?.chatProfile?.color)}
457 cursor="pointer"
458 onPress={() =>
459 Linking.openURL(`https://bsky.app/profile/${mtnftr.did || ""}`)
460 }
461 >
462 {obj.text}
463 </Text>
464 );
465 }
466 } else {
467 return <Text key={`text-${index}`}>{obj.text}</Text>;
468 }
469};
470
471const RichTextMessage = ({
472 text,
473 facets,
474 chat = [],
475}: {
476 text: string;
477 facets: Facet[];
478 chat?: ChatMessageViewHydrated[];
479}) => {
480 if (!facets?.length) return <Text>{text}</Text>;
481
482 let segs = segmentize(text, facets);
483
484 return segs.map((seg, i) =>
485 segmentedObject(seg, chat as MessageViewHydrated[], i),
486 );
487};