Live video on the AT Protocol

feat(frontend): Add livestream title management to delegated moderation

Enable delegated moderators with livestream.manage permission to update
stream titles via mod view.

- Add useUpdateLivestreamRecord hook with owner/delegated paths
- Implement update stream title dialog in mod view
- Add livestream management permission checks
- Export block module from streamplace-store

Note: block.tsx now contains multiple moderation actions.

Related to #718

authored by xx-c.tngl.sh and committed by makeworld 6d549ffe 42a68382

+503 -127
+421 -127
js/components/src/components/chat/mod-view.tsx
··· 5 5 import { 6 6 useCreateBlockRecord, 7 7 useCreateHideChatRecord, 8 + useUpdateLivestreamRecord, 8 9 } from "../../streamplace-store/block"; 9 - import { useCanModerate } from "../../streamplace-store/moderation"; 10 + import { 11 + ModerationPermissions, 12 + useCanModerate, 13 + } from "../../streamplace-store/moderation"; 10 14 import { usePDSAgent } from "../../streamplace-store/xrpc"; 11 15 12 16 import { Linking } from "react-native"; 13 17 import { ChatMessageViewHydrated } from "streamplace"; 14 18 import { 15 19 useDeleteChatMessage, 20 + useLivestream, 16 21 useLivestreamStore, 17 22 } from "../../livestream-store"; 18 23 import { useStreamplaceStore } from "../../streamplace-store"; 19 24 import { formatHandle, formatHandleWithAt } from "../../utils/format-handle"; 20 25 import { 21 26 atoms, 27 + Button, 28 + DialogFooter, 22 29 DropdownMenu, 23 30 DropdownMenuGroup, 24 31 DropdownMenuItem, 25 32 DropdownMenuTrigger, 26 33 layout, 34 + ResponsiveDialog, 27 35 ResponsiveDropdownMenuContent, 28 36 Text, 37 + Textarea, 29 38 useToast, 30 39 View, 31 40 } from "../ui"; ··· 46 55 export const ModView = forwardRef<ModViewRef, ModViewProps>(() => { 47 56 const triggerRef = useRef<TriggerRef>(null); 48 57 const message = usePlayerStore((state) => state.modMessage); 58 + const toast = useToast(); 49 59 50 60 let agent = usePDSAgent(); 51 61 let [messageRemoved, setMessageRemoved] = useState(false); 52 62 let { createBlock, isLoading: isBlockLoading } = useCreateBlockRecord(); 53 63 let { createHideChat, isLoading: isHideLoading } = useCreateHideChatRecord(); 64 + let { updateLivestream, isLoading: isUpdateTitleLoading } = 65 + useUpdateLivestreamRecord(); 66 + const livestream = useLivestream(); 67 + const [showUpdateTitleDialog, setShowUpdateTitleDialog] = useState(false); 54 68 55 69 const setReportModalOpen = usePlayerStore((x) => x.setReportModalOpen); 56 70 const setReportSubject = usePlayerStore((x) => x.setReportSubject); ··· 67 81 // get the logged in user's identity 68 82 const handle = useStreamplaceStore((state) => state.handle); 69 83 70 - if (!agent?.did) { 71 - <View style={[layout.flex.row, layout.flex.alignCenter, gap.all[2]]}> 72 - <Text>Log in to submit mod actions</Text> 73 - </View>; 74 - } 75 - 76 84 const cleanup = () => { 77 85 setModMessage(null); 78 86 }; 79 87 88 + // Effect must be called unconditionally (before any early returns) 80 89 useEffect(() => { 81 90 if (message) { 82 91 setMessageRemoved(false); ··· 86 95 } 87 96 }, [message]); 88 97 89 - // Can show moderation actions if user can hide or ban 90 - const canModerate = modPermissions.canHide || modPermissions.canBan; 98 + // Early return AFTER all hooks have been called 99 + if (!agent?.did) { 100 + return ( 101 + <View style={[layout.flex.row, layout.flex.alignCenter, gap.all[2]]}> 102 + <Text>Log in to submit mod actions</Text> 103 + </View> 104 + ); 105 + } 106 + 107 + // Can show moderation actions if user can hide, ban, or manage livestream 108 + const canModerate = 109 + modPermissions.canHide || 110 + modPermissions.canBan || 111 + modPermissions.canManageLivestream; 112 + 113 + // Check if any moderation actions are actually available for this message 114 + // This must match the individual action checks inside the DropdownMenuGroup 115 + const hasAvailableActions = !!( 116 + message && 117 + agent?.did && 118 + ((modPermissions.canHide && message.author.did !== streamerDID) || 119 + (modPermissions.canBan && 120 + message.author.did !== agent.did && 121 + message.author.did !== streamerDID)) 122 + ); 91 123 92 124 return ( 93 - <DropdownMenu 94 - style={[layout.flex.row, layout.flex.alignCenter, gap.all[2], w[80]]} 95 - onOpenChange={(isOpen) => { 96 - if (!isOpen) { 97 - cleanup(); 98 - } 99 - }} 100 - > 101 - <DropdownMenuTrigger ref={triggerRef}> 102 - {/* Hidden trigger */} 103 - <View /> 104 - </DropdownMenuTrigger> 105 - <ResponsiveDropdownMenuContent> 106 - {message && ( 107 - <> 108 - <DropdownMenuGroup> 109 - <DropdownMenuItem> 110 - <View 111 - style={[ 112 - layout.flex.column, 113 - mr[5], 114 - { gap: 6, maxWidth: "100%" }, 115 - ]} 116 - > 117 - <Text 118 - style={{ 119 - fontVariant: ["tabular-nums"], 120 - color: atoms.colors.gray[300], 121 - }} 122 - > 123 - {new Date(message.record.createdAt).toLocaleTimeString([], { 124 - hour: "2-digit", 125 - minute: "2-digit", 126 - hour12: false, 127 - })}{" "} 128 - {formatHandleWithAt(message.author)}: {message.record.text} 129 - </Text> 130 - </View> 131 - </DropdownMenuItem> 132 - </DropdownMenuGroup> 125 + <> 126 + <DropdownMenu 127 + style={[layout.flex.row, layout.flex.alignCenter, gap.all[2], w[80]]} 128 + onOpenChange={(isOpen) => { 129 + if (!isOpen) { 130 + cleanup(); 131 + } 132 + }} 133 + > 134 + <DropdownMenuTrigger ref={triggerRef}> 135 + {/* Hidden trigger */} 136 + <View /> 137 + </DropdownMenuTrigger> 138 + <ResponsiveDropdownMenuContent> 139 + {message && ( 140 + <ModViewContent 141 + message={message} 142 + modPermissions={modPermissions} 143 + agent={agent} 144 + streamerDID={streamerDID} 145 + hasAvailableActions={hasAvailableActions} 146 + isHideLoading={isHideLoading} 147 + isBlockLoading={isBlockLoading} 148 + messageRemoved={messageRemoved} 149 + setMessageRemoved={setMessageRemoved} 150 + createHideChat={createHideChat} 151 + createBlock={createBlock} 152 + toast={toast} 153 + setShowUpdateTitleDialog={setShowUpdateTitleDialog} 154 + isUpdateTitleLoading={isUpdateTitleLoading} 155 + livestream={livestream} 156 + setReportModalOpen={setReportModalOpen} 157 + setReportSubject={setReportSubject} 158 + deleteChatMessage={deleteChatMessage} 159 + /> 160 + )} 161 + </ResponsiveDropdownMenuContent> 162 + </DropdownMenu> 133 163 134 - {canModerate && ( 135 - <DropdownMenuGroup title={`Moderation actions`}> 136 - {modPermissions.canHide && ( 137 - <DropdownMenuItem 138 - disabled={isHideLoading || messageRemoved} 139 - onPress={() => { 140 - if (isHideLoading || messageRemoved) return; 141 - createHideChat(message.uri, streamerDID ?? undefined) 142 - .then((r) => setMessageRemoved(true)) 143 - .catch((e) => console.error(e)); 144 - }} 145 - > 146 - <Text 147 - color={ 148 - isHideLoading || messageRemoved 149 - ? "muted" 150 - : "destructive" 151 - } 152 - > 153 - {isHideLoading 154 - ? "Removing..." 155 - : messageRemoved 156 - ? "Message removed" 157 - : "Remove this message"} 158 - </Text> 159 - </DropdownMenuItem> 160 - )} 161 - {modPermissions.canBan && ( 162 - <DropdownMenuItem 163 - disabled={ 164 - message.author.did === agent?.did || isBlockLoading 165 - } 166 - onPress={() => { 167 - createBlock(message.author.did, streamerDID ?? undefined) 168 - .then((r) => console.log(r)) 169 - .catch((e) => console.error(e)); 170 - }} 171 - > 172 - {message.author.did === agent?.did ? ( 173 - <Text color="muted"> 174 - Block yourself (you can't block yourself) 175 - </Text> 176 - ) : ( 177 - <Text color="destructive"> 178 - {isBlockLoading 179 - ? "Blocking..." 180 - : `Block user ${formatHandleWithAt(message.author)} from this channel`} 181 - </Text> 182 - )} 183 - </DropdownMenuItem> 184 - )} 185 - </DropdownMenuGroup> 186 - )} 164 + {/* Update Stream Title Dialog - rendered outside dropdown */} 165 + {showUpdateTitleDialog && ( 166 + <UpdateStreamTitleDialog 167 + livestream={livestream} 168 + streamerDID={streamerDID} 169 + updateLivestream={updateLivestream} 170 + isLoading={isUpdateTitleLoading} 171 + onClose={() => setShowUpdateTitleDialog(false)} 172 + /> 173 + )} 174 + </> 175 + ); 176 + }); 177 + 178 + interface ModViewContentProps { 179 + message: ChatMessageViewHydrated; 180 + modPermissions: ModerationPermissions; 181 + agent: ReturnType<typeof usePDSAgent>; 182 + streamerDID?: string; 183 + hasAvailableActions: boolean; 184 + isHideLoading: boolean; 185 + isBlockLoading: boolean; 186 + messageRemoved: boolean; 187 + setMessageRemoved: (removed: boolean) => void; 188 + createHideChat: (uri: string, streamerDID?: string) => Promise<any>; 189 + createBlock: (did: string, streamerDID?: string) => Promise<any>; 190 + toast: ReturnType<typeof useToast>; 191 + setShowUpdateTitleDialog: (show: boolean) => void; 192 + isUpdateTitleLoading: boolean; 193 + livestream: any; 194 + setReportModalOpen: (open: boolean) => void; 195 + setReportSubject: (subject: any) => void; 196 + deleteChatMessage: (uri: string) => Promise<any>; 197 + } 198 + 199 + function ModViewContent({ 200 + message, 201 + modPermissions, 202 + agent, 203 + streamerDID, 204 + hasAvailableActions, 205 + isHideLoading, 206 + isBlockLoading, 207 + messageRemoved, 208 + setMessageRemoved, 209 + createHideChat, 210 + createBlock, 211 + toast, 212 + setShowUpdateTitleDialog, 213 + isUpdateTitleLoading, 214 + livestream, 215 + setReportModalOpen, 216 + setReportSubject, 217 + deleteChatMessage, 218 + }: ModViewContentProps) { 219 + const { onOpenChange } = useRootContext(); 220 + 221 + return ( 222 + <> 223 + <DropdownMenuGroup key="message-display"> 224 + <DropdownMenuItem> 225 + <View 226 + style={[layout.flex.column, mr[5], { gap: 6, maxWidth: "100%" }]} 227 + > 228 + <Text 229 + style={{ 230 + fontVariant: ["tabular-nums"], 231 + color: atoms.colors.gray[300], 232 + }} 233 + > 234 + {new Date(message.record.createdAt).toLocaleTimeString([], { 235 + hour: "2-digit", 236 + minute: "2-digit", 237 + hour12: false, 238 + })}{" "} 239 + {formatHandleWithAt(message.author)}: {message.record.text} 240 + </Text> 241 + </View> 242 + </DropdownMenuItem> 243 + </DropdownMenuGroup> 187 244 188 - <DropdownMenuGroup title={`User actions`}> 245 + {hasAvailableActions && ( 246 + <DropdownMenuGroup 247 + key="moderation-actions" 248 + title={`Moderation actions`} 249 + > 250 + {modPermissions.canHide && message.author.did !== streamerDID && ( 251 + <DropdownMenuItem 252 + disabled={isHideLoading || messageRemoved} 253 + onPress={() => { 254 + if (isHideLoading || messageRemoved) return; 255 + createHideChat(message.uri, streamerDID ?? undefined) 256 + .then((r) => setMessageRemoved(true)) 257 + .catch((e) => console.error(e)); 258 + }} 259 + > 260 + <Text 261 + color={isHideLoading || messageRemoved ? "muted" : "warning"} 262 + > 263 + {isHideLoading 264 + ? "Hiding..." 265 + : messageRemoved 266 + ? "Message hidden" 267 + : "Hide this message"} 268 + </Text> 269 + </DropdownMenuItem> 270 + )} 271 + {modPermissions.canBan && 272 + agent?.did && 273 + message.author.did !== agent.did && 274 + message.author.did !== streamerDID && ( 189 275 <DropdownMenuItem 276 + disabled={isBlockLoading} 190 277 onPress={() => { 191 - Linking.openURL( 192 - `https://${BSKY_FRONTEND_DOMAIN}/profile/${formatHandle(message.author)}`, 193 - ); 278 + if (isBlockLoading) return; 279 + createBlock(message.author.did, streamerDID ?? undefined) 280 + .then((r) => { 281 + toast.show( 282 + "User blocked", 283 + `${formatHandleWithAt(message.author)} has been blocked from this channel.`, 284 + { duration: 3 }, 285 + ); 286 + onOpenChange?.(false); 287 + }) 288 + .catch((e) => { 289 + console.error(e); 290 + toast.show( 291 + "Error blocking user", 292 + e instanceof Error ? e.message : "Failed to block user", 293 + { duration: 5 }, 294 + ); 295 + }); 194 296 }} 195 297 > 196 - <Text color="primary">View user on {BSKY_FRONTEND_DOMAIN}</Text> 298 + <Text color="destructive"> 299 + {isBlockLoading 300 + ? "Blocking..." 301 + : `Block user ${formatHandleWithAt(message.author)} from this channel`} 302 + </Text> 197 303 </DropdownMenuItem> 198 - {message.author.did === agent?.did && ( 199 - <DeleteButton 200 - message={message} 201 - deleteChatMessage={deleteChatMessage} 202 - /> 203 - )} 204 - {message.author.did !== agent?.did && ( 205 - <ReportButton 206 - message={message} 207 - setReportModalOpen={setReportModalOpen} 208 - setReportSubject={setReportSubject} 209 - /> 210 - )} 211 - </DropdownMenuGroup> 212 - </> 304 + )} 305 + </DropdownMenuGroup> 306 + )} 307 + 308 + {modPermissions.canManageLivestream && ( 309 + <DropdownMenuGroup key="stream-actions" title={`Stream actions`}> 310 + <DropdownMenuItem 311 + onPress={() => { 312 + setShowUpdateTitleDialog(true); 313 + }} 314 + disabled={isUpdateTitleLoading || !livestream} 315 + > 316 + <Text 317 + color={isUpdateTitleLoading || !livestream ? "muted" : "primary"} 318 + > 319 + {isUpdateTitleLoading ? "Updating..." : "Update stream title"} 320 + </Text> 321 + </DropdownMenuItem> 322 + </DropdownMenuGroup> 323 + )} 324 + 325 + <DropdownMenuGroup key="user-actions" title={`User actions`}> 326 + <DropdownMenuItem 327 + onPress={() => { 328 + Linking.openURL( 329 + `https://${BSKY_FRONTEND_DOMAIN}/profile/${formatHandle(message.author)}`, 330 + ); 331 + }} 332 + > 333 + <Text color="primary">View user on {BSKY_FRONTEND_DOMAIN}</Text> 334 + </DropdownMenuItem> 335 + {message.author.did === agent?.did && ( 336 + <DeleteButton 337 + message={message} 338 + deleteChatMessage={deleteChatMessage} 339 + onOpenChange={onOpenChange} 340 + /> 213 341 )} 214 - </ResponsiveDropdownMenuContent> 215 - </DropdownMenu> 342 + {message.author.did !== agent?.did && ( 343 + <ReportButton 344 + message={message} 345 + setReportModalOpen={setReportModalOpen} 346 + setReportSubject={setReportSubject} 347 + onOpenChange={onOpenChange} 348 + /> 349 + )} 350 + </DropdownMenuGroup> 351 + </> 216 352 ); 217 - }); 353 + } 218 354 219 355 enum DeleteState { 220 356 None, ··· 225 361 export function DeleteButton({ 226 362 message, 227 363 deleteChatMessage, 364 + onOpenChange, 228 365 }: { 229 366 message: ChatMessageViewHydrated; 230 367 deleteChatMessage: (uri: string) => Promise<any>; 368 + onOpenChange?: (open: boolean) => void; 231 369 }) { 232 370 const [confirming, setConfirming] = useState<DeleteState>(DeleteState.None); 233 - const { onOpenChange } = useRootContext(); 234 371 const toast = useToast(); 235 372 return ( 236 373 <DropdownMenuItem ··· 271 408 message, 272 409 setReportModalOpen, 273 410 setReportSubject, 411 + onOpenChange, 274 412 }: { 275 413 message: ChatMessageViewHydrated; 276 414 setReportModalOpen: (open: boolean) => void; 277 415 setReportSubject: (subject: any) => void; 416 + onOpenChange?: (open: boolean) => void; 278 417 }) { 279 - const { onOpenChange } = useRootContext(); 280 418 return ( 281 419 <DropdownMenuItem 282 420 onPress={() => { ··· 295 433 </DropdownMenuItem> 296 434 ); 297 435 } 436 + 437 + interface UpdateStreamTitleDialogProps { 438 + livestream: any; 439 + streamerDID?: string; 440 + updateLivestream: ( 441 + livestreamUri: string, 442 + title: string, 443 + streamerDID?: string, 444 + ) => Promise<any>; 445 + isLoading: boolean; 446 + onClose: () => void; 447 + } 448 + 449 + function UpdateStreamTitleDialog({ 450 + livestream, 451 + streamerDID, 452 + updateLivestream, 453 + isLoading, 454 + onClose, 455 + }: UpdateStreamTitleDialogProps) { 456 + const [title, setTitle] = useState(livestream?.record?.title || ""); 457 + const [error, setError] = useState<string | null>(null); 458 + const toast = useToast(); 459 + 460 + useEffect(() => { 461 + if (livestream?.record?.title) { 462 + setTitle(livestream.record.title); 463 + } 464 + }, [livestream?.record?.title]); 465 + 466 + const handleUpdate = async () => { 467 + setError(null); 468 + 469 + if (!title.trim()) { 470 + setError("Please enter a stream title"); 471 + return; 472 + } 473 + 474 + if (!livestream?.uri) { 475 + setError("No livestream found"); 476 + return; 477 + } 478 + 479 + try { 480 + await updateLivestream(livestream.uri, title.trim(), streamerDID); 481 + toast.show( 482 + "Stream title updated", 483 + "The stream title has been successfully updated.", 484 + { duration: 3 }, 485 + ); 486 + onClose(); 487 + } catch (err) { 488 + setError( 489 + err instanceof Error ? err.message : "Failed to update stream title", 490 + ); 491 + } 492 + }; 493 + 494 + return ( 495 + <ResponsiveDialog 496 + open={true} 497 + onOpenChange={(open) => { 498 + if (!open) { 499 + onClose(); 500 + setError(null); 501 + setTitle(livestream?.record?.title || ""); 502 + } 503 + }} 504 + title="Update Stream Title" 505 + description="Update the title of the livestream." 506 + size="md" 507 + dismissible={false} 508 + > 509 + <View style={[{ padding: 16, paddingBottom: 0 }]}> 510 + <View style={[{ marginBottom: 16 }]}> 511 + <Text 512 + style={[ 513 + { color: atoms.colors.gray[300], fontSize: 13, marginBottom: 8 }, 514 + ]} 515 + > 516 + Stream Title 517 + </Text> 518 + <Textarea 519 + value={title} 520 + onChangeText={(text) => { 521 + setTitle(text); 522 + setError(null); 523 + }} 524 + placeholder="Enter stream title..." 525 + maxLength={140} 526 + multiline 527 + style={[ 528 + { 529 + padding: 12, 530 + borderRadius: 8, 531 + backgroundColor: atoms.colors.neutral[800], 532 + color: atoms.colors.white, 533 + borderWidth: 1, 534 + borderColor: atoms.colors.neutral[600], 535 + minHeight: 100, 536 + fontSize: 16, 537 + }, 538 + ]} 539 + /> 540 + <Text 541 + style={[ 542 + { color: atoms.colors.gray[400], fontSize: 12, marginTop: 4 }, 543 + ]} 544 + > 545 + {title.length}/140 characters 546 + </Text> 547 + </View> 548 + 549 + {error && ( 550 + <View 551 + style={[ 552 + { 553 + backgroundColor: atoms.colors.red[900], 554 + padding: 12, 555 + borderRadius: 8, 556 + borderWidth: 1, 557 + borderColor: atoms.colors.red[700], 558 + marginBottom: 16, 559 + }, 560 + ]} 561 + > 562 + <Text style={[{ color: atoms.colors.red[400], fontSize: 13 }]}> 563 + {error} 564 + </Text> 565 + </View> 566 + )} 567 + </View> 568 + 569 + <DialogFooter> 570 + <Button 571 + variant="secondary" 572 + onPress={() => { 573 + onClose(); 574 + setError(null); 575 + setTitle(livestream?.record?.title || ""); 576 + }} 577 + disabled={isLoading} 578 + > 579 + <Text>Cancel</Text> 580 + </Button> 581 + <Button 582 + variant="primary" 583 + onPress={handleUpdate} 584 + disabled={isLoading || !title.trim()} 585 + > 586 + <Text>{isLoading ? "Updating..." : "Update Title"}</Text> 587 + </Button> 588 + </DialogFooter> 589 + </ResponsiveDialog> 590 + ); 591 + }
+81
js/components/src/streamplace-store/block.tsx
··· 112 112 113 113 return { createHideChat, isLoading }; 114 114 } 115 + 116 + /** 117 + * Hook to update a livestream record (update stream title). 118 + * 119 + * When the caller is the stream owner (agent.did === streamerDID), updates the 120 + * livestream record directly via ATProto writes to their repo. 121 + * 122 + * When the caller is a delegated moderator, uses the place.stream.moderation.updateLivestream 123 + * XRPC endpoint which validates permissions and updates the record using the 124 + * streamer's OAuth session. 125 + */ 126 + export function useUpdateLivestreamRecord() { 127 + let agent = usePDSAgent(); 128 + const [isLoading, setIsLoading] = useState(false); 129 + 130 + const updateLivestream = async ( 131 + livestreamUri: string, 132 + title: string, 133 + streamerDID?: string, 134 + ) => { 135 + if (!agent) { 136 + throw new Error("No PDS agent found"); 137 + } 138 + 139 + if (!agent.did) { 140 + throw new Error("No user DID found, assuming not logged in"); 141 + } 142 + 143 + setIsLoading(true); 144 + try { 145 + // If no streamerDID provided or caller is the streamer, use direct ATProto write 146 + if (!streamerDID || agent.did === streamerDID) { 147 + // Extract rkey from URI 148 + const rkey = livestreamUri.split("/").pop(); 149 + if (!rkey) { 150 + throw new Error("Invalid livestream URI"); 151 + } 152 + 153 + // Get existing record first 154 + const getResult = await agent.com.atproto.repo.getRecord({ 155 + repo: agent.did, 156 + collection: "place.stream.livestream", 157 + rkey, 158 + }); 159 + 160 + const oldRecord = getResult.data.value as any; 161 + 162 + // Update the record 163 + const record = { 164 + $type: "place.stream.livestream", 165 + title: title, 166 + url: oldRecord.url, 167 + createdAt: oldRecord.createdAt, 168 + post: oldRecord.post, 169 + thumb: oldRecord.thumb, 170 + }; 171 + 172 + const result = await agent.com.atproto.repo.putRecord({ 173 + repo: agent.did, 174 + collection: "place.stream.livestream", 175 + rkey, 176 + record, 177 + swapRecord: getResult.data.cid, 178 + }); 179 + return result; 180 + } 181 + 182 + // Otherwise, use delegated moderation endpoint 183 + const result = await agent.place.stream.moderation.updateLivestream({ 184 + streamer: streamerDID, 185 + livestreamUri: livestreamUri, 186 + title: title, 187 + }); 188 + return result; 189 + } finally { 190 + setIsLoading(false); 191 + } 192 + }; 193 + 194 + return { updateLivestream, isLoading }; 195 + }
+1
js/components/src/streamplace-store/index.tsx
··· 1 + export * from "./block"; 1 2 export * from "./moderation"; 2 3 export * from "./moderator-management"; 3 4 export * from "./stream";