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