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 { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
24import Animated, {
25 SharedValue,
26 useAnimatedStyle,
27} from "react-native-reanimated";
28import { ChatMessageViewHydrated } from "streamplace";
29import { Button, ScrollView, Sheet, Text, useMedia, View } from "tamagui";
30import { RichtextSegment, segmentize } from "../../utils/facet";
31
32export default function Chat({
33 isChatVisible,
34 setIsChatVisible: _setIsChatVisible,
35}: {
36 isChatVisible: boolean;
37 setIsChatVisible: (visible: boolean) => void;
38}) {
39 const [open, setOpen] = useState(false);
40 const [modMessage, setMessage] = useState<ChatMessageViewHydrated | null>(
41 null,
42 );
43 const [isAtBottom, setIsAtBottom] = useState(true);
44 let chat = useChat();
45 const scrollRef = useRef<ScrollView>(null);
46 const streamerProfile = useProfile();
47 const userProfile = useAppSelector(selectUserProfile);
48 const myStream = !!(
49 userProfile &&
50 userProfile.did &&
51 userProfile.did === streamerProfile?.did
52 );
53
54 const handleScroll = (event: any) => {
55 const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent;
56 const paddingToBottom = 100;
57 const isAtBottom =
58 layoutMeasurement.height + contentOffset.y >=
59 contentSize.height - paddingToBottom;
60 setIsAtBottom(isAtBottom);
61 };
62
63 useEffect(() => {
64 if (chat && isAtBottom) {
65 scrollRef.current?.scrollToEnd({ animated: true });
66 }
67 }, [chat, isAtBottom]);
68
69 if (!chat) {
70 return <></>;
71 }
72
73 chat = [...chat].reverse();
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 <View
278 position="absolute"
279 flexDirection="row"
280 right={0}
281 top="$-3"
282 alignItems="stretch"
283 justifyContent="flex-end"
284 gap="$1"
285 px="$1"
286 backgroundColor="rgba(255,255,255,0.5)"
287 borderRadius="$2"
288 >
289 {isWeb && (
290 <TouchableOpacity
291 style={{
292 display: hover ? "flex" : "none",
293 alignItems: "center",
294 justifyContent: "center",
295 padding: 4,
296 }}
297 onPress={handleReply}
298 >
299 <Reply size={16} />
300 </TouchableOpacity>
301 )}
302 {isWeb && myStream && (
303 <TouchableOpacity
304 style={{
305 display: hover ? "flex" : "none",
306 alignItems: "center",
307 justifyContent: "center",
308 padding: 4,
309 }}
310 onPress={moderateMessage}
311 >
312 <Settings size={16} />
313 </TouchableOpacity>
314 )}
315 </View>
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 onLongPress={() => {
344 if (!isWeb) {
345 handleReply();
346 }
347 }}
348 onPress={() => {
349 if (!isWeb) {
350 moderateMessage();
351 }
352 }}
353 overflow="visible"
354 >
355 {hasReply && (
356 <View
357 position="absolute"
358 left={6}
359 top={-8}
360 width={2}
361 height={16}
362 opacity={0.7}
363 />
364 )}
365 <View flexDirection="column" gap="$1" flex={1} overflow="visible">
366 {/* Reply section */}
367 {hasReply && (
368 <View
369 flexDirection="column"
370 marginBottom="$2"
371 paddingLeft="$3"
372 position="relative"
373 >
374 {/* Vertical reply line */}
375 <View
376 position="absolute"
377 left={6}
378 top={0}
379 bottom={0}
380 width={2}
381 borderRadius={2}
382 backgroundColor="$accentColor"
383 opacity={0.5}
384 />
385 {/* Reply preview */}
386 <View
387 flexDirection="row"
388 alignItems="center"
389 gap="$1"
390 paddingVertical="$1"
391 paddingHorizontal="$2"
392 borderRadius="$2"
393 marginLeft="-$1"
394 >
395 <Text fontSize={12} color={replyToColor} fontWeight="bold">
396 {replyToHandle ? `@${replyToHandle}` : ""}
397 </Text>
398 <Text
399 fontSize={12}
400 color="$color"
401 opacity={0.7}
402 numberOfLines={1}
403 flex={1}
404 >
405 {replyToText || ""}
406 </Text>
407 </View>
408 </View>
409 )}
410 {/* Message content */}
411 <View
412 flexDirection="row"
413 alignItems="flex-start"
414 justifyContent="flex-start"
415 gap="$2"
416 >
417 <Text
418 color="$gray10"
419 fontSize="$2"
420 mt="$0.5"
421 style={{ fontVariantNumeric: "tabular-nums" }}
422 >
423 {new Date(message.record.createdAt).toLocaleString(undefined, {
424 hour: "2-digit",
425 minute: "2-digit",
426 hour12: false,
427 })}
428 </Text>
429 <ChatMessageText message={message} chat={chat} />
430 </View>
431 </View>
432 </View>
433 {/* </ReanimatedSwipeable> */}
434 </View>
435 );
436}
437
438function byteOffsetToCharIndex(text: string, byteOffset: number): number {
439 const encoder = new TextEncoder();
440 let bytesCount = 0;
441 for (let i = 0; i < text.length; i++) {
442 const charBytes = encoder.encode(text[i]);
443 if (bytesCount + charBytes.length > byteOffset) {
444 return i;
445 }
446 bytesCount += charBytes.length;
447 }
448 return text.length;
449}
450
451const ChatMessageText = ({
452 message,
453 chat = [],
454}: {
455 message: ChatMessageViewHydrated;
456 chat?: ChatMessageViewHydrated[];
457}) => {
458 const rt = new RichText({ text: message.record.text });
459 rt.detectFacetsWithoutResolution();
460
461 // Process facets to add DID information for mentions
462 rt.facets?.forEach((facet) => {
463 facet.features.forEach((feature) => {
464 if (isMention(feature)) {
465 const startCharIndex = byteOffsetToCharIndex(
466 message.record.text,
467 facet.index.byteStart,
468 );
469 const endCharIndex = byteOffsetToCharIndex(
470 message.record.text,
471 facet.index.byteEnd,
472 );
473 const mentionText = message.record.text.slice(
474 startCharIndex,
475 endCharIndex,
476 );
477 // Find the mentioned user by their handle (removing the @ symbol)
478 const mentionedUser = chat.find(
479 (msg) => msg.author.handle === mentionText.slice(1),
480 );
481 if (mentionedUser) {
482 feature.did = mentionedUser.author.did;
483 }
484 }
485 });
486 });
487
488 return (
489 <Text fontSize={13}>
490 <Text
491 color={getRgbColor(message.chatProfile?.color)}
492 cursor="pointer"
493 onPress={() =>
494 Linking.openURL(`https://bsky.app/profile/${message.author.did}`)
495 }
496 >
497 {message.author.handle
498 ? `@${message.author.handle}`
499 : message.author.did}
500 :
501 </Text>
502 <Text> </Text>
503 <RichTextMessage
504 text={message.record.text}
505 facets={rt.facets as Facet[]}
506 chat={chat}
507 />
508 </Text>
509 );
510};
511
512interface Facet {
513 index: {
514 byteStart: number;
515 byteEnd: number;
516 };
517 features: Array<{
518 $type: string;
519 uri?: string;
520 did?: string;
521 }>;
522}
523
524const getRgbColor = (color?: { red: number; green: number; blue: number }) =>
525 color ? `rgb(${color.red}, ${color.green}, ${color.blue})` : "$accentColor";
526
527const segmentedObject = (
528 obj: RichtextSegment,
529 chat: ChatMessageViewHydrated[],
530 index: number,
531) => {
532 if (obj.features && obj.features.length > 0) {
533 let ftr = obj.features[0];
534 // afaik there shouldn't be a case where facets overlap, at least currently
535 if (ftr.$type === "app.bsky.richtext.facet#link") {
536 let linkftr = ftr as $Typed<Link>;
537 return (
538 <Text
539 key={`mention-${index}`}
540 color="#9090f0"
541 cursor="pointer"
542 onPress={() => Linking.openURL(linkftr.uri || "")}
543 >
544 {obj.text}
545 </Text>
546 );
547 } else if (ftr.$type === "app.bsky.richtext.facet#mention") {
548 let mtnftr = ftr as $Typed<Mention>;
549 const mentionedUserMessage = chat.find(
550 (msg) => msg.author.did === mtnftr.did,
551 );
552 return (
553 <Text
554 key={`mention-${index}`}
555 color={getRgbColor(mentionedUserMessage?.chatProfile?.color)}
556 cursor="pointer"
557 onPress={() =>
558 Linking.openURL(`https://bsky.app/profile/${mtnftr.did || ""}`)
559 }
560 >
561 {obj.text}
562 </Text>
563 );
564 }
565 } else {
566 return <Text key={`text-${index}`}>{obj.text}</Text>;
567 }
568};
569
570const RichTextMessage = ({
571 text,
572 facets,
573 chat = [],
574}: {
575 text: string;
576 facets: Facet[];
577 chat?: ChatMessageViewHydrated[];
578}) => {
579 if (!facets?.length) return <Text>{text}</Text>;
580
581 let segs = segmentize(text, facets);
582
583 return segs.map((seg, i) => segmentedObject(seg, chat, i));
584};
585
586export function timeAgo(time: Date | number | string): string {
587 let timestamp: number;
588
589 if (typeof time === "number") {
590 timestamp = time;
591 } else if (typeof time === "string") {
592 timestamp = new Date(time).getTime();
593 } else if (time instanceof Date) {
594 timestamp = time.getTime();
595 } else {
596 timestamp = Date.now();
597 }
598
599 const now = Date.now();
600 let seconds = (now - timestamp) / 1000;
601 let token = "ago";
602 let listChoice = 1;
603
604 if (seconds === 0) {
605 return "Just now";
606 }
607
608 if (seconds < 0) {
609 seconds = Math.abs(seconds);
610 token = "from now";
611 listChoice = 2;
612 }
613
614 // Show time for > 1 hour difference
615 if (seconds > 3600) {
616 const date = new Date(timestamp);
617 // More than 1 day ago/from now: show shortened date + time
618 if (seconds > 86400) {
619 // Example format: "Apr 27, 14:35"
620 return date.toLocaleString(undefined, {
621 month: "short",
622 day: "numeric",
623 hour: "2-digit",
624 minute: "2-digit",
625 });
626 }
627 // Otherwise show only time for > 1 hour
628 return date.toLocaleTimeString(undefined, {
629 hour: "2-digit",
630 minute: "2-digit",
631 });
632 }
633
634 const timeFormats: [number, string, number | string][] = [
635 [60, "seconds", 1], // 60
636 [120, "1 minute ago", "1 minute from now"], // 60*2
637 [3600, "minutes", 60], // 60*60, 60
638 [7200, "1 hour ago", "1 hour from now"], // 60*60*2
639 [86400, "hours", 3600], // 60*60*24, 60*60
640 [172800, "Yesterday", "Tomorrow"], // 60*60*24*2
641 [604800, "days", 86400], // 60*60*24*7, 60*60*24
642 [1209600, "Last week", "Next week"], // 60*60*24*7*2
643 [2419200, "weeks", 604800], // 60*60*24*7*4, 60*60*24*7
644 [4838400, "Last month", "Next month"], // 60*60*24*7*4*2
645 [29030400, "months", 2419200], // 60*60*24*7*4*12, 60*60*24*7*4
646 [58060800, "Last year", "Next year"], // 60*60*24*7*4*12*2
647 [2903040000, "years", 29030400], // 60*60*24*7*4*12*100, 60*60*24*7*4*12
648 [5806080000, "Last century", "Next century"], // 60*60*24*7*4*12*100*2
649 [58060800000, "centuries", 2903040000], // 60*60*24*7*4*12*100*20, 60*60*24*7*4*12*100
650 ];
651
652 for (const format of timeFormats) {
653 if (seconds < format[0]) {
654 if (typeof format[2] === "string") {
655 return format[listChoice] as string;
656 } else {
657 return (
658 Math.floor(seconds / (format[2] as number)) +
659 " " +
660 format[1] +
661 " " +
662 token
663 );
664 }
665 }
666 }
667
668 return new Date(timestamp).toString();
669}