Live video on the AT Protocol

Add chat message hide moderation action and refactor mod view

+215 -39
+130 -3
js/components/src/components/chat/chat.tsx
··· 1 - import { Reply, ShieldEllipsis } from "lucide-react-native"; 2 - import { ComponentProps, memo, useRef } from "react"; 1 + import { Ellipsis, Reply, ShieldEllipsis } from "lucide-react-native"; 2 + import { ComponentProps, memo, useEffect, useRef, useState } from "react"; 3 3 import { FlatList, Platform, Pressable } from "react-native"; 4 4 import Swipeable, { 5 5 SwipeableMethods, ··· 55 55 return `${item.uri}`; 56 56 }; 57 57 58 + // Actions bar for larger screens 59 + const ActionsBar = memo( 60 + ({ 61 + item, 62 + visible, 63 + hoverTimeoutRef, 64 + }: { 65 + item: ChatMessageViewHydrated; 66 + visible: boolean; 67 + hoverTimeoutRef: React.MutableRefObject<NodeJS.Timeout | null>; 68 + }) => { 69 + const setReply = useSetReplyToMessage(); 70 + const setModMsg = usePlayerStore((state) => state.setModMessage); 71 + 72 + if (!visible) return null; 73 + 74 + return ( 75 + <View 76 + style={[ 77 + { 78 + position: "absolute", 79 + top: -14, 80 + right: 8, 81 + flexDirection: "row", 82 + backgroundColor: "rgba(180,180,180, 0.5)", 83 + borderRadius: 6, 84 + borderWidth: 1, 85 + padding: 1, 86 + gap: 4, 87 + zIndex: 10, 88 + }, 89 + ]} 90 + > 91 + <Pressable 92 + onPress={() => setReply(item)} 93 + style={[ 94 + { 95 + padding: 6, 96 + borderRadius: 4, 97 + backgroundColor: "rgba(255, 255, 255, 0.1)", 98 + }, 99 + ]} 100 + onHoverIn={() => { 101 + // Keep the actions bar visible when hovering over it 102 + if (hoverTimeoutRef.current) { 103 + clearTimeout(hoverTimeoutRef.current); 104 + hoverTimeoutRef.current = null; 105 + } 106 + }} 107 + > 108 + <Reply color="white" size={16} /> 109 + </Pressable> 110 + <Pressable 111 + onPress={() => setModMsg(item)} 112 + style={[ 113 + { 114 + padding: 6, 115 + borderRadius: 4, 116 + backgroundColor: "rgba(255, 255, 255, 0.1)", 117 + }, 118 + ]} 119 + onHoverIn={() => { 120 + // Keep the actions bar visible when hovering over it 121 + if (hoverTimeoutRef.current) { 122 + clearTimeout(hoverTimeoutRef.current); 123 + hoverTimeoutRef.current = null; 124 + } 125 + }} 126 + > 127 + <Ellipsis color="white" size={16} /> 128 + </Pressable> 129 + </View> 130 + ); 131 + }, 132 + ); 133 + 58 134 const ChatLine = memo(({ item }: { item: ChatMessageViewHydrated }) => { 59 135 const setReply = useSetReplyToMessage(); 60 136 const setModMsg = usePlayerStore((state) => state.setModMessage); 61 137 const swipeableRef = useRef<SwipeableMethods | null>(null); 138 + const { isMouseDriven } = usePointerDevice(); 139 + const [isHovered, setIsHovered] = useState(false); 140 + const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null); 141 + 142 + const handleHoverIn = () => { 143 + if (hoverTimeoutRef.current) { 144 + clearTimeout(hoverTimeoutRef.current); 145 + hoverTimeoutRef.current = null; 146 + } 147 + setIsHovered(true); 148 + }; 149 + 150 + const handleHoverOut = () => { 151 + hoverTimeoutRef.current = setTimeout(() => { 152 + setIsHovered(false); 153 + }, 50); 154 + }; 155 + 156 + useEffect(() => { 157 + return () => { 158 + if (hoverTimeoutRef.current) { 159 + clearTimeout(hoverTimeoutRef.current); 160 + } 161 + }; 162 + }, []); 163 + 164 + if (isMouseDriven) { 165 + return ( 166 + <View 167 + style={[ 168 + py[1], 169 + px[2], 170 + { position: "relative", borderRadius: 8 }, 171 + isHovered && bg.gray[950], 172 + ]} 173 + onPointerEnter={handleHoverIn} 174 + onPointerLeave={handleHoverOut} 175 + > 176 + <Pressable> 177 + <RenderChatMessage item={item} /> 178 + </Pressable> 179 + <ActionsBar 180 + item={item} 181 + visible={isHovered} 182 + hoverTimeoutRef={hoverTimeoutRef} 183 + /> 184 + </View> 185 + ); 186 + } 187 + 62 188 return ( 63 - <Pressable onLongPress={() => setModMsg(item)}> 189 + <Pressable> 64 190 <Swipeable 65 191 containerStyle={[py[1]]} 66 192 friction={2} ··· 116 242 data={chat} 117 243 inverted={true} 118 244 keyExtractor={keyExtractor} 245 + renderItem={({ item, index }) => <ChatLine item={item} />} 119 246 renderItem={({ item, index }) => <ChatLine item={item} />} 120 247 removeClippedSubviews={true} 121 248 maxToRenderPerBatch={10}
+60 -36
js/components/src/components/chat/mod-view.tsx
··· 1 1 import { TriggerRef } from "@rn-primitives/dropdown-menu"; 2 2 import { forwardRef, useEffect, useRef } from "react"; 3 - import { gap, mr } from "../../lib/theme/atoms"; 3 + import { gap, mr, w } from "../../lib/theme/atoms"; 4 4 import { usePlayerStore } from "../../player-store"; 5 - import { useCreateBlockRecord } from "../../streamplace-store/block"; 5 + import { 6 + useCreateBlockRecord, 7 + useCreateHideChatRecord, 8 + } from "../../streamplace-store/block"; 6 9 import { usePDSAgent } from "../../streamplace-store/xrpc"; 7 10 11 + import { Linking } from "react-native"; 12 + import { useStreamplaceStore } from "../../streamplace-store"; 8 13 import { 9 14 DropdownMenu, 10 15 DropdownMenuGroup, ··· 17 22 } from "../ui"; 18 23 import { RenderChatMessage } from "./chat-message"; 19 24 25 + const BSKY_FRONTEND_DOMAIN = "bsky.app"; 26 + 20 27 type ModViewProps = { 21 28 onClose?: () => void; 22 29 // onDeleteMessage?: (msg: ChatMessageViewHydrated) => void; ··· 34 41 35 42 let agent = usePDSAgent(); 36 43 let createBlockRecord = useCreateBlockRecord(); 44 + let createHideChatRecord = useCreateHideChatRecord(); 45 + 46 + // get the channel did 47 + const channelId = usePlayerStore((state) => state.src); 48 + // get the logged in user's identity 49 + const handle = useStreamplaceStore((state) => state.handle); 37 50 38 51 if (!agent?.did) { 39 52 <View style={[layout.flex.row, layout.flex.alignCenter, gap.all[2]]}> ··· 52 65 }, [message]); 53 66 54 67 return ( 55 - <DropdownMenu> 68 + <DropdownMenu 69 + style={[layout.flex.row, layout.flex.alignCenter, gap.all[2], w[80]]} 70 + > 56 71 <DropdownMenuTrigger ref={triggerRef}> 57 72 {/* Hidden trigger */} 58 73 <View /> ··· 62 77 <> 63 78 <DropdownMenuGroup> 64 79 <DropdownMenuItem> 65 - <View style={[layout.flex.column, mr[5], { gap: 6 }]}> 80 + <View 81 + style={[ 82 + layout.flex.column, 83 + mr[5], 84 + { gap: 6, maxWidth: "100%" }, 85 + ]} 86 + > 66 87 <RenderChatMessage item={message} /> 67 88 </View> 68 89 </DropdownMenuItem> 69 90 </DropdownMenuGroup> 70 91 71 - <DropdownMenuGroup title={`Moderation actions`}> 72 - {/* <DropdownMenuItem 73 - onPress={ 74 - onDeleteMessage 75 - ? () => onDeleteMessage(modMessage) 76 - : undefined 77 - } 92 + {/* TODO: Checking for non-owner moderators */} 93 + {channelId === handle && ( 94 + <DropdownMenuGroup title={`Moderation actions`}> 95 + <DropdownMenuItem 96 + disabled={message.author.did === agent?.did} 97 + onPress={() => { 98 + createHideChatRecord(message.uri) 99 + .then((r) => console.log(r)) 100 + .catch((e) => console.error(e)); 101 + }} 78 102 > 79 - <Text customColor={colors.ios.systemTeal}> 80 - Delete message 81 - </Text> 103 + <Text color="destructive">Remove this message</Text> 82 104 </DropdownMenuItem> 83 - <DropdownMenuSeparator /> 84 105 <DropdownMenuItem 85 - onPress={ 86 - onBanUser 87 - ? () => onBanUser(modMessage.author.handle) 88 - : undefined 89 - } 106 + disabled={message.author.did === agent?.did} 107 + onPress={() => { 108 + createBlockRecord(message.author.did) 109 + .then((r) => console.log(r)) 110 + .catch((e) => console.error(e)); 111 + }} 90 112 > 91 - <Text color="destructive"> 92 - Ban user @{modMessage.author.handle} 93 - </Text> 94 - </DropdownMenuItem> */} 113 + {message.author.did === agent?.did ? ( 114 + <Text color="muted"> 115 + Block yourself (you can't block yourself) 116 + </Text> 117 + ) : ( 118 + <Text color="destructive"> 119 + Block user @{message.author.handle} from this channel 120 + </Text> 121 + )} 122 + </DropdownMenuItem> 123 + </DropdownMenuGroup> 124 + )} 125 + 126 + <DropdownMenuGroup title={`User actions`}> 95 127 <DropdownMenuItem 96 - disabled={message.author.did === agent?.did} 97 128 onPress={() => { 98 - console.log("Creating block record"); 99 - createBlockRecord(message.author.did) 100 - .then((r) => console.log(r)) 101 - .catch((e) => console.error(e)); 129 + Linking.openURL( 130 + `https://${BSKY_FRONTEND_DOMAIN}/profile/${channelId}`, 131 + ); 102 132 }} 103 133 > 104 - <Text color="destructive"> 105 - {message.author.did === agent?.did ? ( 106 - <>Block yourself (you can't block yourself)</> 107 - ) : ( 108 - <>Block user @{message.author.handle} from this channel</> 109 - )} 110 - </Text> 134 + <Text color="primary">View user on {BSKY_FRONTEND_DOMAIN}</Text> 111 135 </DropdownMenuItem> 112 136 </DropdownMenuGroup> 113 137 </>
+25
js/components/src/streamplace-store/block.tsx
··· 27 27 return record; 28 28 }; 29 29 } 30 + 31 + export function useCreateHideChatRecord() { 32 + let agent = usePDSAgent(); 33 + 34 + return async (chatMessageUri: string) => { 35 + if (!agent) { 36 + throw new Error("No PDS agent found"); 37 + } 38 + 39 + if (!agent.did) { 40 + throw new Error("No user DID found, assuming not logged in"); 41 + } 42 + 43 + const record = { 44 + $type: "place.stream.chat.hide", 45 + hiddenMessage: chatMessageUri, 46 + }; 47 + 48 + return await agent.com.atproto.repo.createRecord({ 49 + repo: agent.did, 50 + collection: "place.stream.chat.hide", 51 + record, 52 + }); 53 + }; 54 + }