Live video on the AT Protocol

Merge pull request #638 from streamplace/eli/chat-mod-fixes

chat: fix & unify detection of canModerate

authored by Eli Mallon and committed by GitHub d11deb5d 6a1f375e

+108 -115
-2
js/app/components/live-dashboard/bento-grid.tsx
··· 69 70 // Calculate derived values 71 const isConnected = ingestConnectionState === "connected"; 72 - const canModerate = isLive && isConnected; 73 74 // Calculate uptime 75 const getUptime = useCallback((): string => { ··· 179 isLive={isLive} 180 isConnected={isConnected} 181 messagesPerMinute={messagesPerMinute} 182 - canModerate={canModerate} 183 /> 184 </View> 185 <View
··· 69 70 // Calculate derived values 71 const isConnected = ingestConnectionState === "connected"; 72 73 // Calculate uptime 74 const getUptime = useCallback((): string => { ··· 178 isLive={isLive} 179 isConnected={isConnected} 180 messagesPerMinute={messagesPerMinute} 181 /> 182 </View> 183 <View
+1 -2
js/app/components/mobile/chat.tsx
··· 105 let agent = usePDSAgent(); 106 107 const navigation = useNavigation(); 108 - let canModerate = profile?.handle === handle; 109 110 return ( 111 <View ··· 117 ]} 118 > 119 <View style={[flex.values[1]]}> 120 - <Chat canModerate={canModerate} /> 121 </View> 122 <View style={[layout.flex.column, gap.all[2]]}> 123 {agent?.did ? (
··· 105 let agent = usePDSAgent(); 106 107 const navigation = useNavigation(); 108 109 return ( 110 <View ··· 116 ]} 117 > 118 <View style={[flex.values[1]]}> 119 + <Chat /> 120 </View> 121 <View style={[layout.flex.column, gap.all[2]]}> 122 {agent?.did ? (
+1 -1
js/app/src/screens/chat-popout.native.tsx
··· 171 </View> 172 </View> 173 <View style={[zero.flex.values[1], zero.p[4]]}> 174 - <Chat canModerate={profile?.handle === user} /> 175 {profile && <ChatBox emojiData={emojiData} isPopout={true} />} 176 </View> 177 </View>
··· 171 </View> 172 </View> 173 <View style={[zero.flex.values[1], zero.p[4]]}> 174 + <Chat /> 175 {profile && <ChatBox emojiData={emojiData} isPopout={true} />} 176 </View> 177 </View>
+2 -1
js/app/src/screens/chat-popout.tsx
··· 33 useEffect(() => { 34 setSrc(user); 35 }, [user]); 36 return ( 37 <View style={[{ position: "relative" }, zero.flex.values[1], zero.m[2]]}> 38 <View ··· 41 { position: "absolute", width: "100%", minHeight: "100%", bottom: 0 }, 42 ]} 43 > 44 - <Chat canModerate={profile?.handle === user} /> 45 {profile && <ChatBox emojiData={emojiData} isPopout={true} />} 46 </View> 47 </View>
··· 33 useEffect(() => { 34 setSrc(user); 35 }, [user]); 36 + 37 return ( 38 <View style={[{ position: "relative" }, zero.flex.values[1], zero.m[2]]}> 39 <View ··· 42 { position: "absolute", width: "100%", minHeight: "100%", bottom: 0 }, 43 ]} 44 > 45 + <Chat /> 46 {profile && <ChatBox emojiData={emojiData} isPopout={true} />} 47 </View> 48 </View>
+90 -104
js/components/src/components/chat/chat.tsx
··· 136 }, 137 ); 138 139 - const ChatLine = memo( 140 - ({ 141 - item, 142 - canModerate, 143 - }: { 144 - item: ChatMessageViewHydrated; 145 - canModerate: boolean; 146 - }) => { 147 - const setReply = useSetReplyToMessage(); 148 - const setModMsg = usePlayerStore((state) => state.setModMessage); 149 - const swipeableRef = useRef<SwipeableMethods | null>(null); 150 - const [isHovered, setIsHovered] = useState(false); 151 - const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null); 152 153 - const handleHoverIn = () => { 154 if (hoverTimeoutRef.current) { 155 clearTimeout(hoverTimeoutRef.current); 156 - hoverTimeoutRef.current = null; 157 } 158 - setIsHovered(true); 159 }; 160 161 - const handleHoverOut = () => { 162 - hoverTimeoutRef.current = setTimeout(() => { 163 - setIsHovered(false); 164 - }, 50); 165 - }; 166 - 167 - useEffect(() => { 168 - return () => { 169 - if (hoverTimeoutRef.current) { 170 - clearTimeout(hoverTimeoutRef.current); 171 - } 172 - }; 173 - }, []); 174 - 175 - if (item.author.did === "did:sys:system") { 176 - return ( 177 - <SystemMessage 178 - timestamp={new Date(item.record.createdAt)} 179 - title={item.record.text} 180 - /> 181 - ); 182 - } 183 - 184 - if (Platform.OS === "web") { 185 - return ( 186 - <View 187 - style={[ 188 - py[1], 189 - px[2], 190 - { 191 - position: "relative", 192 - borderRadius: 8, 193 - minWidth: 0, 194 - maxWidth: "100%", 195 - }, 196 - isHovered && bg.gray[950], 197 - ]} 198 - onPointerEnter={handleHoverIn} 199 - onPointerLeave={handleHoverOut} 200 - > 201 - <Pressable style={[{ minWidth: 0, maxWidth: "100%" }]}> 202 - <RenderChatMessage item={item} /> 203 - </Pressable> 204 - <ActionsBar 205 - item={item} 206 - visible={isHovered} 207 - hoverTimeoutRef={hoverTimeoutRef} 208 - /> 209 - </View> 210 - ); 211 - } 212 213 return ( 214 - <> 215 - <Swipeable 216 - containerStyle={[py[1]]} 217 - friction={2} 218 - enableTrackpadTwoFingerGesture 219 - rightThreshold={40} 220 - leftThreshold={40} 221 - renderRightActions={ 222 - Platform.OS === "android" ? undefined : RightAction 223 - } 224 - renderLeftActions={Platform.OS === "android" ? undefined : LeftAction} 225 - overshootFriction={9} 226 - ref={swipeableRef} 227 - onSwipeableOpen={(r) => { 228 - if (r === (Platform.OS === "android" ? "right" : "left")) { 229 - setReply(item); 230 - } 231 - if (r === (Platform.OS === "android" ? "left" : "right")) { 232 - setModMsg(item); 233 - } 234 - // close this swipeable 235 - const swipeable = swipeableRef.current; 236 - if (swipeable) { 237 - swipeable.close(); 238 - } 239 - }} 240 - > 241 <RenderChatMessage item={item} /> 242 - </Swipeable> 243 - </> 244 ); 245 - }, 246 - ); 247 248 export function Chat({ 249 shownMessages = SHOWN_MSGS, 250 style: propsStyle, 251 - canModerate = false, 252 ...props 253 }: ComponentProps<typeof View> & { 254 shownMessages?: number; 255 style?: ComponentProps<typeof View>["style"]; 256 - canModerate?: boolean; 257 }) { 258 const chat = useChat(); 259 const [isScrolledUp, setIsScrolledUp] = useState(false); ··· 276 if (!chat) 277 return ( 278 <View style={[flex.shrink[1], { minWidth: 0, maxWidth: "100%" }]}> 279 - <Text>Loading chaat...</Text> 280 </View> 281 ); 282 ··· 295 data={chat.slice(0, shownMessages)} 296 inverted={true} 297 keyExtractor={keyExtractor} 298 - renderItem={({ item, index }) => ( 299 - <ChatLine item={item} canModerate={canModerate} /> 300 - )} 301 removeClippedSubviews={true} 302 maxToRenderPerBatch={10} 303 initialNumToRender={10}
··· 136 }, 137 ); 138 139 + const ChatLine = memo(({ item }: { item: ChatMessageViewHydrated }) => { 140 + const setReply = useSetReplyToMessage(); 141 + const setModMsg = usePlayerStore((state) => state.setModMessage); 142 + const swipeableRef = useRef<SwipeableMethods | null>(null); 143 + const [isHovered, setIsHovered] = useState(false); 144 + const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null); 145 + 146 + const handleHoverIn = () => { 147 + if (hoverTimeoutRef.current) { 148 + clearTimeout(hoverTimeoutRef.current); 149 + hoverTimeoutRef.current = null; 150 + } 151 + setIsHovered(true); 152 + }; 153 + 154 + const handleHoverOut = () => { 155 + hoverTimeoutRef.current = setTimeout(() => { 156 + setIsHovered(false); 157 + }, 50); 158 + }; 159 160 + useEffect(() => { 161 + return () => { 162 if (hoverTimeoutRef.current) { 163 clearTimeout(hoverTimeoutRef.current); 164 } 165 }; 166 + }, []); 167 168 + if (item.author.did === "did:sys:system") { 169 + return ( 170 + <SystemMessage 171 + timestamp={new Date(item.record.createdAt)} 172 + title={item.record.text} 173 + /> 174 + ); 175 + } 176 177 + if (Platform.OS === "web") { 178 return ( 179 + <View 180 + style={[ 181 + py[1], 182 + px[2], 183 + { 184 + position: "relative", 185 + borderRadius: 8, 186 + minWidth: 0, 187 + maxWidth: "100%", 188 + }, 189 + isHovered && bg.gray[950], 190 + ]} 191 + onPointerEnter={handleHoverIn} 192 + onPointerLeave={handleHoverOut} 193 + > 194 + <Pressable style={[{ minWidth: 0, maxWidth: "100%" }]}> 195 <RenderChatMessage item={item} /> 196 + </Pressable> 197 + <ActionsBar 198 + item={item} 199 + visible={isHovered} 200 + hoverTimeoutRef={hoverTimeoutRef} 201 + /> 202 + </View> 203 ); 204 + } 205 + 206 + return ( 207 + <> 208 + <Swipeable 209 + containerStyle={[py[1]]} 210 + friction={2} 211 + enableTrackpadTwoFingerGesture 212 + rightThreshold={40} 213 + leftThreshold={40} 214 + renderRightActions={Platform.OS === "android" ? undefined : RightAction} 215 + renderLeftActions={Platform.OS === "android" ? undefined : LeftAction} 216 + overshootFriction={9} 217 + ref={swipeableRef} 218 + onSwipeableOpen={(r) => { 219 + if (r === (Platform.OS === "android" ? "right" : "left")) { 220 + setReply(item); 221 + } 222 + if (r === (Platform.OS === "android" ? "left" : "right")) { 223 + setModMsg(item); 224 + } 225 + // close this swipeable 226 + const swipeable = swipeableRef.current; 227 + if (swipeable) { 228 + swipeable.close(); 229 + } 230 + }} 231 + > 232 + <RenderChatMessage item={item} /> 233 + </Swipeable> 234 + </> 235 + ); 236 + }); 237 238 export function Chat({ 239 shownMessages = SHOWN_MSGS, 240 style: propsStyle, 241 ...props 242 }: ComponentProps<typeof View> & { 243 shownMessages?: number; 244 style?: ComponentProps<typeof View>["style"]; 245 }) { 246 const chat = useChat(); 247 const [isScrolledUp, setIsScrolledUp] = useState(false); ··· 264 if (!chat) 265 return ( 266 <View style={[flex.shrink[1], { minWidth: 0, maxWidth: "100%" }]}> 267 + <Text>Loading chat...</Text> 268 </View> 269 ); 270 ··· 283 data={chat.slice(0, shownMessages)} 284 inverted={true} 285 keyExtractor={keyExtractor} 286 + renderItem={({ item, index }) => <ChatLine item={item} />} 287 removeClippedSubviews={true} 288 maxToRenderPerBatch={10} 289 initialNumToRender={10}
+3 -2
js/components/src/components/chat/mod-view.tsx
··· 1 import { TriggerRef, useRootContext } from "@rn-primitives/dropdown-menu"; 2 import { forwardRef, useEffect, useRef, useState } from "react"; 3 import { gap, mr, w } from "../../lib/theme/atoms"; 4 - import { usePlayerStore } from "../../player-store"; 5 import { 6 useCreateBlockRecord, 7 useCreateHideChatRecord, ··· 51 const setReportSubject = usePlayerStore((x) => x.setReportSubject); 52 const setModMessage = usePlayerStore((x) => x.setModMessage); 53 const deleteChatMessage = useDeleteChatMessage(); 54 55 // get the channel did 56 const channelId = usePlayerStore((state) => state.src); ··· 119 </DropdownMenuGroup> 120 121 {/* TODO: Checking for non-owner moderators */} 122 - {channelId === handle && ( 123 <DropdownMenuGroup title={`Moderation actions`}> 124 <DropdownMenuItem 125 disabled={isHideLoading || messageRemoved}
··· 1 import { TriggerRef, useRootContext } from "@rn-primitives/dropdown-menu"; 2 import { forwardRef, useEffect, useRef, useState } from "react"; 3 import { gap, mr, w } from "../../lib/theme/atoms"; 4 + import { useIsMyStream, usePlayerStore } from "../../player-store"; 5 import { 6 useCreateBlockRecord, 7 useCreateHideChatRecord, ··· 51 const setReportSubject = usePlayerStore((x) => x.setReportSubject); 52 const setModMessage = usePlayerStore((x) => x.setModMessage); 53 const deleteChatMessage = useDeleteChatMessage(); 54 + const isMyStream = useIsMyStream(); 55 56 // get the channel did 57 const channelId = usePlayerStore((state) => state.src); ··· 120 </DropdownMenuGroup> 121 122 {/* TODO: Checking for non-owner moderators */} 123 + {isMyStream() && ( 124 <DropdownMenuGroup title={`Moderation actions`}> 125 <DropdownMenuItem 126 disabled={isHideLoading || messageRemoved}
+1 -3
js/components/src/components/dashboard/chat-panel.tsx
··· 10 isLive: boolean; 11 isConnected: boolean; 12 messagesPerMinute?: number; 13 - canModerate?: boolean; 14 shownMessages?: number; 15 } 16 ··· 18 isLive, 19 isConnected, 20 messagesPerMinute = 0, 21 - canModerate = false, 22 shownMessages = 50, 23 }: ChatPanelProps) { 24 return ( ··· 59 </View> 60 <View style={[flex.values[1], px[2], { minHeight: 0 }]}> 61 <View style={[flex.values[1], { minHeight: 0 }]}> 62 - <Chat canModerate={canModerate} shownMessages={shownMessages} /> 63 </View> 64 <View style={[{ flexShrink: 0 }]}> 65 <ChatBox
··· 10 isLive: boolean; 11 isConnected: boolean; 12 messagesPerMinute?: number; 13 shownMessages?: number; 14 } 15 ··· 17 isLive, 18 isConnected, 19 messagesPerMinute = 0, 20 shownMessages = 50, 21 }: ChatPanelProps) { 22 return ( ··· 57 </View> 58 <View style={[flex.values[1], px[2], { minHeight: 0 }]}> 59 <View style={[flex.values[1], { minHeight: 0 }]}> 60 + <Chat shownMessages={shownMessages} /> 61 </View> 62 <View style={[{ flexShrink: 0 }]}> 63 <ChatBox
+10
js/components/src/player-store/player-store.tsx
··· 3 import { ChatMessageViewHydrated } from "streamplace"; 4 import { createStore, StoreApi, useStore } from "zustand"; 5 import { useLivestreamStore } from "../livestream-store"; 6 import { PlayerContext } from "./context"; 7 import { 8 IngestMediaSource, ··· 271 } 272 return now - Date.parse(segment.startTime) > 10000; 273 };
··· 3 import { ChatMessageViewHydrated } from "streamplace"; 4 import { createStore, StoreApi, useStore } from "zustand"; 5 import { useLivestreamStore } from "../livestream-store"; 6 + import { useStreamplaceStore } from "../streamplace-store"; 7 import { PlayerContext } from "./context"; 8 import { 9 IngestMediaSource, ··· 272 } 273 return now - Date.parse(segment.startTime) > 10000; 274 }; 275 + 276 + export const useIsMyStream = () => { 277 + const myHandle = useStreamplaceStore((state) => state.handle); 278 + const myDid = useStreamplaceStore((state) => state.oauthSession?.did); 279 + const channelId = usePlayerStore((state) => state.src); 280 + return () => { 281 + return myHandle === channelId || myDid === channelId; 282 + }; 283 + };