Live video on the AT Protocol
1import {
2 useChat,
3 useProfile,
4 useReplyToMessage,
5 useSetReplyToMessage,
6} from "@streamplace/components";
7import { Reply, ReplyAll, Settings, X } from "@tamagui/lucide-icons";
8import {
9 createBlockRecord,
10 selectUserProfile,
11} from "features/bluesky/blueskySlice";
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 ReanimatedSwipeable, {
24 SwipeableMethods,
25} from "react-native-gesture-handler/ReanimatedSwipeable";
26import Animated, {
27 SharedValue,
28 useAnimatedStyle,
29} from "react-native-reanimated";
30import { ChatMessageViewHydrated } from "streamplace";
31import { Button, ScrollView, Sheet, Text, useMedia, View } from "tamagui";
32import { RichtextSegment, segmentize } from "../../utils/facet";
33
34export default function Chat({
35 isChatVisible,
36 setIsChatVisible: _setIsChatVisible,
37}: {
38 isChatVisible: boolean;
39 setIsChatVisible: (visible: boolean) => void;
40}) {
41 const [open, setOpen] = useState(false);
42 const [modMessage, setMessage] = useState<ChatMessageViewHydrated | null>(
43 null,
44 );
45 const [isAtBottom, setIsAtBottom] = useState(true);
46 const chat = useChat();
47 const scrollRef = useRef<ScrollView>(null);
48 const streamerProfile = useProfile();
49 const userProfile = useAppSelector(selectUserProfile);
50 const myStream = !!(
51 userProfile &&
52 userProfile.did &&
53 userProfile.did === streamerProfile?.did
54 );
55
56 const handleScroll = (event: any) => {
57 const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent;
58 const paddingToBottom = 100;
59 const isAtBottom =
60 layoutMeasurement.height + contentOffset.y >=
61 contentSize.height - paddingToBottom;
62 setIsAtBottom(isAtBottom);
63 };
64
65 useEffect(() => {
66 if (chat && isAtBottom) {
67 scrollRef.current?.scrollToEnd({ animated: true });
68 }
69 }, [chat, isAtBottom]);
70
71 if (!chat) {
72 return <></>;
73 }
74
75 const m = useMedia();
76 const dispatch = useAppDispatch();
77
78 return (
79 <View f={1} position="relative">
80 {isChatVisible && (
81 <>
82 <Sheet
83 // forceRemoveScrollEnabled={open}
84 open={open}
85 modal={!m.gtXs}
86 onOpenChange={setOpen}
87 // snapPoints={snapPoints}
88 // snapPointsMode={snapPointsMode}
89 dismissOnSnapToBottom
90 // position={position}
91 // onPositionChange={setPosition}
92 zIndex={100_000}
93 animation="medium"
94 >
95 <Sheet.Overlay
96 animation="lazy"
97 backgroundColor="$shadow6"
98 enterStyle={{ opacity: 0 }}
99 exitStyle={{ opacity: 0 }}
100 />
101 <Sheet.Frame>
102 <View
103 f={1}
104 alignItems="center"
105 justifyContent="center"
106 padding="$4"
107 gap="$5"
108 backgroundColor="$accentBackground"
109 >
110 <Button
111 position="absolute"
112 top="$0"
113 right="$0"
114 onPress={(e) => {
115 e.stopPropagation();
116 setOpen(false);
117 }}
118 marginRight={-15}
119 marginTop={-5}
120 backgroundColor="transparent"
121 >
122 <X />
123 </Button>
124 {modMessage && (
125 <>
126 <ChatMessageText message={modMessage} chat={chat || []} />
127 {modMessage.author.did !== userProfile?.did && (
128 <Button
129 width="100%"
130 onPress={() => {
131 setOpen(false);
132 dispatch(
133 createBlockRecord({
134 subjectDID: modMessage.author.did,
135 }),
136 );
137 }}
138 >
139 <Text>Block @{modMessage.author.handle}</Text>
140 </Button>
141 )}
142 {modMessage.author.did === userProfile?.did && (
143 <>
144 <Button width="100%" disabled={true}>
145 <Text>(You can't block yourself!)</Text>
146 </Button>
147 </>
148 )}
149 </>
150 )}
151 </View>
152 </Sheet.Frame>
153 </Sheet>
154 <ScrollView
155 marginHorizontal="$2"
156 invertStickyHeaders={true}
157 ref={scrollRef}
158 onContentSizeChange={() => {
159 if (isAtBottom) {
160 scrollRef.current?.scrollToEnd({ animated: true });
161 }
162 }}
163 onLayout={() => {
164 if (isAtBottom) {
165 scrollRef.current?.scrollToEnd({ animated: true });
166 }
167 }}
168 onScroll={handleScroll}
169 scrollEventThrottle={16}
170 >
171 {chat.map((message, index) => (
172 <ChatMessageRow
173 key={message.cid + index}
174 message={message}
175 setOpen={setOpen}
176 setMessage={setMessage}
177 myStream={myStream}
178 replyHandle={undefined}
179 chat={chat}
180 />
181 ))}
182 </ScrollView>
183 </>
184 )}
185 </View>
186 );
187}
188
189const RightAction = (
190 _progress: SharedValue<number>,
191 drag: SharedValue<number>,
192) => {
193 const styleAnimation = useAnimatedStyle(() => {
194 return {
195 transform: [
196 {
197 translateX: drag.value + 50,
198 },
199 ],
200 };
201 });
202
203 return (
204 <Animated.View style={[styleAnimation, { height: "auto" }]}>
205 <Text
206 width="$4"
207 backgroundColor="rgba(255,255,255,0.5)"
208 borderRadius="$2"
209 pt={"$1"}
210 px={"$1"}
211 >
212 <ReplyAll />
213 </Text>
214 </Animated.View>
215 );
216};
217
218function ChatMessageRow({
219 message,
220 setMessage,
221 setOpen,
222 myStream,
223 replyHandle: _replyHandle,
224 chat,
225}: {
226 message: ChatMessageViewHydrated;
227 setOpen: (open: boolean) => void;
228 setMessage: (message: ChatMessageViewHydrated) => void;
229 myStream: boolean;
230 replyHandle?: string;
231 chat: ChatMessageViewHydrated[];
232}): JSX.Element {
233 const [hover, setHover] = useState(false);
234 const setReplyToMessage = useSetReplyToMessage();
235 const { isWeb } = usePlatform();
236
237 const swipeableRef = useRef<SwipeableMethods>(null);
238 const close = () => {
239 let current: any = swipeableRef.current;
240 if (current) {
241 current.close();
242 }
243 };
244
245 const currentReplyTo = useReplyToMessage();
246
247 const moderateMessage = () => {
248 if (!myStream) {
249 return;
250 }
251 setOpen(true);
252 setMessage(message);
253 };
254
255 const handleReply = () => {
256 setReplyToMessage(message);
257 };
258
259 const replyTo = message.replyTo as ChatMessageViewHydrated | undefined;
260 const hasReply = !!replyTo;
261 const replyToHandle = replyTo?.author?.handle;
262 const replyToText = replyTo?.record?.text;
263 const replyToColor = replyTo?.chatProfile?.color
264 ? `rgb(${replyTo.chatProfile.color.red}, ${replyTo.chatProfile.color.green}, ${replyTo.chatProfile.color.blue})`
265 : "$accentColor";
266
267 return (
268 <View
269 onMouseEnter={() => setHover(true)}
270 onMouseLeave={() => setHover(false)}
271 onPress={() => {
272 if (!isWeb) {
273 moderateMessage();
274 }
275 }}
276 >
277 {isWeb && (
278 <View
279 position="absolute"
280 flexDirection="row"
281 right={0}
282 top="$-3"
283 alignItems="center"
284 justifyContent="center"
285 gap="$2"
286 pl="$2"
287 pr="$1"
288 backgroundColor="rgba(64,64,64,1)"
289 borderRadius="$2"
290 style={{
291 display: hover ? "flex" : "none",
292 alignItems: "center",
293 justifyContent: "center",
294 }}
295 zi={32}
296 >
297 <Text fontSize="$2">{timeAgo(message.record.createdAt)}</Text>
298 <TouchableOpacity onPress={handleReply}>
299 <Reply size={16} />
300 </TouchableOpacity>
301 {isWeb && myStream && (
302 <TouchableOpacity
303 style={{
304 display: hover ? "flex" : "none",
305 alignItems: "center",
306 justifyContent: "center",
307 padding: 4,
308 }}
309 onPress={moderateMessage}
310 >
311 <Settings size={16} />
312 </TouchableOpacity>
313 )}
314 </View>
315 )}
316 <ReanimatedSwipeable
317 ref={swipeableRef}
318 renderRightActions={RightAction}
319 overshootRight={false}
320 friction={2}
321 enableTrackpadTwoFingerGesture
322 rightThreshold={40}
323 onSwipeableOpen={(r) => {
324 if (r === "right") {
325 handleReply();
326 }
327 close();
328 }}
329 >
330 <View
331 flexDirection="row"
332 display="block"
333 paddingVertical={isWeb ? 6 : 4} // Adjust padding for web
334 paddingHorizontal={isWeb ? 6 : 4} // Adjust padding for web
335 position="relative"
336 hoverStyle={{ backgroundColor: "rgba(255,255,255,0.1)" }}
337 backgroundColor={
338 currentReplyTo?.cid === message.cid
339 ? "rgba(180,180,255,0.1)"
340 : "transparent"
341 }
342 borderRadius={isWeb ? 4 : 4}
343 onPress={() => {
344 if (!isWeb) {
345 moderateMessage();
346 }
347 }}
348 overflow="visible"
349 >
350 {hasReply && (
351 <View
352 position="absolute"
353 left={6}
354 top={-8}
355 width={2}
356 height={16}
357 opacity={0.7}
358 />
359 )}
360 <View flexDirection="column" gap="$1" flex={1} overflow="visible">
361 {/* Reply section */}
362 {hasReply && (
363 <View
364 flexDirection="column"
365 marginBottom="$2"
366 paddingLeft="$3"
367 position="relative"
368 >
369 {/* Vertical reply line */}
370 <View
371 position="absolute"
372 left={6}
373 top={0}
374 bottom={0}
375 width={2}
376 borderRadius={2}
377 backgroundColor="$accentColor"
378 opacity={0.5}
379 />
380 {/* Reply preview */}
381 <View
382 flexDirection="row"
383 alignItems="center"
384 gap="$1"
385 paddingVertical="$1"
386 paddingHorizontal="$2"
387 borderRadius="$2"
388 marginLeft="-$1"
389 >
390 <Text fontSize={12} color={replyToColor} fontWeight="bold">
391 {replyToHandle ? `@${replyToHandle}` : ""}
392 </Text>
393 <Text
394 fontSize={12}
395 color="$color"
396 opacity={0.7}
397 numberOfLines={1}
398 flex={1}
399 >
400 {replyToText || ""}
401 </Text>
402 </View>
403 </View>
404 )}
405 </View>
406 </View>
407 </ReanimatedSwipeable>
408 </View>
409 );
410}
411
412function byteOffsetToCharIndex(text: string, byteOffset: number): number {
413 const encoder = new TextEncoder();
414 let bytesCount = 0;
415 for (let i = 0; i < text.length; i++) {
416 const charBytes = encoder.encode(text[i]);
417 if (bytesCount + charBytes.length > byteOffset) {
418 return i;
419 }
420 bytesCount += charBytes.length;
421 }
422 return text.length;
423}
424
425const ChatMessageText = ({
426 message,
427 chat = [],
428}: {
429 message: ChatMessageViewHydrated;
430 chat?: ChatMessageViewHydrated[];
431}) => {
432 const rt = new RichText({ text: message.record.text });
433 rt.detectFacetsWithoutResolution();
434
435 // Process facets to add DID information for mentions
436 rt.facets?.forEach((facet) => {
437 facet.features.forEach((feature) => {
438 if (isMention(feature)) {
439 const startCharIndex = byteOffsetToCharIndex(
440 message.record.text,
441 facet.index.byteStart,
442 );
443 const endCharIndex = byteOffsetToCharIndex(
444 message.record.text,
445 facet.index.byteEnd,
446 );
447 const mentionText = message.record.text.slice(
448 startCharIndex,
449 endCharIndex,
450 );
451 // Find the mentioned user by their handle (removing the @ symbol)
452 const mentionedUser = chat.find(
453 (msg) => msg.author.handle === mentionText.slice(1),
454 );
455 if (mentionedUser) {
456 feature.did = mentionedUser.author.did;
457 }
458 }
459 });
460 });
461
462 return (
463 <Text fontSize={13}>
464 <Text
465 color={getRgbColor(message.chatProfile?.color)}
466 cursor="pointer"
467 onPress={() =>
468 Linking.openURL(`https://bsky.app/profile/${message.author.did}`)
469 }
470 >
471 {message.author.handle
472 ? `@${message.author.handle}`
473 : message.author.did}
474 :
475 </Text>
476 <Text> </Text>
477 <RichTextMessage
478 text={message.record.text}
479 facets={rt.facets as Facet[]}
480 chat={chat}
481 />
482 </Text>
483 );
484};
485
486interface Facet {
487 index: {
488 byteStart: number;
489 byteEnd: number;
490 };
491 features: Array<{
492 $type: string;
493 uri?: string;
494 did?: string;
495 }>;
496}
497
498const getRgbColor = (color?: { red: number; green: number; blue: number }) =>
499 color ? `rgb(${color.red}, ${color.green}, ${color.blue})` : "$accentColor";
500
501const segmentedObject = (
502 obj: RichtextSegment,
503 chat: ChatMessageViewHydrated[],
504 index: number,
505) => {
506 if (obj.features && obj.features.length > 0) {
507 let ftr = obj.features[0];
508 // afaik there shouldn't be a case where facets overlap, at least currently
509 if (ftr.$type === "app.bsky.richtext.facet#link") {
510 let linkftr = ftr as $Typed<Link>;
511 return (
512 <Text
513 key={`mention-${index}`}
514 color="#9090f0"
515 cursor="pointer"
516 onPress={() => Linking.openURL(linkftr.uri || "")}
517 >
518 {obj.text}
519 </Text>
520 );
521 } else if (ftr.$type === "app.bsky.richtext.facet#mention") {
522 let mtnftr = ftr as $Typed<Mention>;
523 const mentionedUserMessage = chat.find(
524 (msg) => msg.author.did === mtnftr.did,
525 );
526 return (
527 <Text
528 key={`mention-${index}`}
529 color={getRgbColor(mentionedUserMessage?.chatProfile?.color)}
530 cursor="pointer"
531 onPress={() =>
532 Linking.openURL(`https://bsky.app/profile/${mtnftr.did || ""}`)
533 }
534 >
535 {obj.text}
536 </Text>
537 );
538 }
539 } else {
540 return <Text key={`text-${index}`}>{obj.text}</Text>;
541 }
542};
543
544const RichTextMessage = ({
545 text,
546 facets,
547 chat = [],
548}: {
549 text: string;
550 facets: Facet[];
551 chat?: ChatMessageViewHydrated[];
552}) => {
553 if (!facets?.length) return <Text>{text}</Text>;
554
555 let segs = segmentize(text, facets);
556
557 return segs.map((seg, i) =>
558 segmentedObject(seg, chat as ChatMessageViewHydrated[], i),
559 );
560};
561
562export function timeAgo(time: Date | number | string): string {
563 let timestamp: number;
564
565 if (typeof time === "number") {
566 timestamp = time;
567 } else if (typeof time === "string") {
568 timestamp = new Date(time).getTime();
569 } else if (time instanceof Date) {
570 timestamp = time.getTime();
571 } else {
572 timestamp = Date.now();
573 }
574
575 const now = Date.now();
576 let seconds = (now - timestamp) / 1000;
577 let token = "ago";
578 let listChoice = 1;
579
580 if (seconds === 0) {
581 return "Just now";
582 }
583
584 if (seconds < 0) {
585 seconds = Math.abs(seconds);
586 token = "from now";
587 listChoice = 2;
588 }
589
590 // Show time for > 1 hour difference
591 if (seconds > 3600) {
592 const date = new Date(timestamp);
593 // More than 1 day ago/from now: show shortened date + time
594 if (seconds > 86400) {
595 // Example format: "Apr 27, 14:35"
596 return date.toLocaleString(undefined, {
597 month: "short",
598 day: "numeric",
599 hour: "2-digit",
600 minute: "2-digit",
601 });
602 }
603 // Otherwise show only time for > 1 hour
604 return date.toLocaleTimeString(undefined, {
605 hour: "2-digit",
606 minute: "2-digit",
607 });
608 }
609
610 const timeFormats: [number, string, number | string][] = [
611 [60, "seconds", 1], // 60
612 [120, "1 minute ago", "1 minute from now"], // 60*2
613 [3600, "minutes", 60], // 60*60, 60
614 [7200, "1 hour ago", "1 hour from now"], // 60*60*2
615 [86400, "hours", 3600], // 60*60*24, 60*60
616 [172800, "Yesterday", "Tomorrow"], // 60*60*24*2
617 [604800, "days", 86400], // 60*60*24*7, 60*60*24
618 [1209600, "Last week", "Next week"], // 60*60*24*7*2
619 [2419200, "weeks", 604800], // 60*60*24*7*4, 60*60*24*7
620 [4838400, "Last month", "Next month"], // 60*60*24*7*4*2
621 [29030400, "months", 2419200], // 60*60*24*7*4*12, 60*60*24*7*4
622 [58060800, "Last year", "Next year"], // 60*60*24*7*4*12*2
623 [2903040000, "years", 29030400], // 60*60*24*7*4*12*100, 60*60*24*7*4*12
624 [5806080000, "Last century", "Next century"], // 60*60*24*7*4*12*100*2
625 [58060800000, "centuries", 2903040000], // 60*60*24*7*4*12*100*20, 60*60*24*7*4*12*100
626 ];
627
628 for (const format of timeFormats) {
629 if (seconds < format[0]) {
630 if (typeof format[2] === "string") {
631 return format[listChoice] as string;
632 } else {
633 return (
634 Math.floor(seconds / (format[2] as number)) +
635 " " +
636 format[1] +
637 " " +
638 token
639 );
640 }
641 }
642 }
643
644 return new Date(timestamp).toString();
645}