Live video on the AT Protocol

Merge pull request #860 from streamplace/v/fix-update-stream-title-location

fix: move stream title update button from the ModView to kebab menu

authored by Eli Mallon and committed by GitHub b24ba4a3 ef85f9f2

+256 -231
+86 -26
js/app/components/mobile/desktop-ui/kebab.tsx
··· 10 10 DropdownMenuTrigger, 11 11 ResponsiveDropdownMenuContent, 12 12 Text, 13 + UpdateStreamTitleDialog, 14 + useCanModerate, 15 + useLivestream, 13 16 useLivestreamInfo, 14 17 useLivestreamStore, 15 18 usePlayerStore, 16 19 useTheme, 20 + useUpdateLivestreamRecord, 17 21 } from "@streamplace/components"; 18 22 import { EllipsisVertical } from "lucide-react-native"; 19 23 import { useState } from "react"; ··· 35 39 export function KebabMenu({ dropdownPortalContainer }: KebabMenuProps) { 36 40 const th = useTheme(); 37 41 const [isOpen, setIsOpen] = useState(false); 42 + const [showUpdateTitleDialog, setShowUpdateTitleDialog] = useState(false); 38 43 39 - const livestream = useLivestreamStore((x) => x.livestream); 44 + const livestreamFromStore = useLivestreamStore((x) => x.livestream); 45 + const livestream = useLivestream(); 40 46 const setReportModalOpen = usePlayerStore((x) => x.setReportModalOpen); 41 47 const setReportSubject = usePlayerStore((x) => x.setReportSubject); 42 48 const { profile } = useLivestreamInfo(); 43 49 50 + // Get the streamer's DID from the profile 51 + const streamerDID = profile?.did; 52 + // Check moderation permissions for the current user on this streamer's channel 53 + const modPermissions = useCanModerate(streamerDID); 54 + const { updateLivestream, isLoading: isUpdateTitleLoading } = 55 + useUpdateLivestreamRecord(); 56 + 44 57 const iconRotate = useAnimatedStyle(() => { 45 58 return { 46 59 transform: [ ··· 55 68 }); 56 69 57 70 return ( 58 - <DropdownMenu onOpenChange={setIsOpen} key={dropdownPortalContainer}> 59 - <DropdownMenuTrigger> 60 - <Animated.View style={[iconRotate]}> 61 - <EllipsisVertical color={th.theme.colors.foreground} /> 62 - </Animated.View> 63 - </DropdownMenuTrigger> 64 - <ResponsiveDropdownMenuContent 65 - side="top" 66 - align="end" 67 - portalHost={dropdownPortalContainer} 68 - > 69 - <DropdownMenuGroup title="Report"> 70 - <ReportStreamItem 71 - livestream={livestream} 72 - setReportModalOpen={setReportModalOpen} 73 - setReportSubject={setReportSubject} 74 - /> 75 - <ReportUserItem 76 - profile={profile} 77 - setReportModalOpen={setReportModalOpen} 78 - setReportSubject={setReportSubject} 79 - /> 80 - </DropdownMenuGroup> 81 - </ResponsiveDropdownMenuContent> 82 - </DropdownMenu> 71 + <> 72 + <DropdownMenu onOpenChange={setIsOpen} key={dropdownPortalContainer}> 73 + <DropdownMenuTrigger> 74 + <Animated.View style={[iconRotate]}> 75 + <EllipsisVertical color={th.theme.colors.foreground} /> 76 + </Animated.View> 77 + </DropdownMenuTrigger> 78 + <ResponsiveDropdownMenuContent 79 + side="top" 80 + align="end" 81 + portalHost={dropdownPortalContainer} 82 + > 83 + {modPermissions.canManageLivestream && ( 84 + <DropdownMenuGroup title="Stream Settings"> 85 + <UpdateStreamTitleItem 86 + setShowUpdateTitleDialog={setShowUpdateTitleDialog} 87 + isUpdateTitleLoading={isUpdateTitleLoading} 88 + livestream={livestream} 89 + /> 90 + </DropdownMenuGroup> 91 + )} 92 + <DropdownMenuGroup title="Report"> 93 + <ReportStreamItem 94 + livestream={livestreamFromStore} 95 + setReportModalOpen={setReportModalOpen} 96 + setReportSubject={setReportSubject} 97 + /> 98 + <ReportUserItem 99 + profile={profile} 100 + setReportModalOpen={setReportModalOpen} 101 + setReportSubject={setReportSubject} 102 + /> 103 + </DropdownMenuGroup> 104 + </ResponsiveDropdownMenuContent> 105 + </DropdownMenu> 106 + 107 + {showUpdateTitleDialog && ( 108 + <UpdateStreamTitleDialog 109 + livestream={livestream} 110 + streamerDID={streamerDID} 111 + updateLivestream={updateLivestream} 112 + isLoading={isUpdateTitleLoading} 113 + onClose={() => setShowUpdateTitleDialog(false)} 114 + /> 115 + )} 116 + </> 83 117 ); 84 118 } 85 119 ··· 141 175 </DropdownMenuItem> 142 176 ); 143 177 } 178 + 179 + function UpdateStreamTitleItem({ 180 + setShowUpdateTitleDialog, 181 + isUpdateTitleLoading, 182 + livestream, 183 + }: { 184 + setShowUpdateTitleDialog: (show: boolean) => void; 185 + isUpdateTitleLoading: boolean; 186 + livestream: any; 187 + }) { 188 + const { onOpenChange } = useRootContext(); 189 + 190 + return ( 191 + <DropdownMenuItem 192 + onPress={() => { 193 + onOpenChange?.(false); 194 + setShowUpdateTitleDialog(true); 195 + }} 196 + disabled={isUpdateTitleLoading || !livestream} 197 + > 198 + <Text> 199 + {isUpdateTitleLoading ? "Updating..." : "Update stream title"} 200 + </Text> 201 + </DropdownMenuItem> 202 + ); 203 + }
-205
js/components/src/components/chat/mod-view.tsx
··· 5 5 import { 6 6 useCreateBlockRecord, 7 7 useCreateHideChatRecord, 8 - useUpdateLivestreamRecord, 9 8 } from "../../streamplace-store/block"; 10 9 import { 11 10 ModerationPermissions, ··· 17 16 import { ChatMessageViewHydrated } from "streamplace"; 18 17 import { 19 18 useDeleteChatMessage, 20 - useLivestream, 21 19 useLivestreamStore, 22 20 } from "../../livestream-store"; 23 21 import { useStreamplaceStore } from "../../streamplace-store"; 24 22 import { formatHandle, formatHandleWithAt } from "../../utils/format-handle"; 25 23 import { 26 24 atoms, 27 - Button, 28 - DialogFooter, 29 25 DropdownMenu, 30 26 DropdownMenuGroup, 31 27 DropdownMenuItem, 32 28 DropdownMenuTrigger, 33 29 layout, 34 - ResponsiveDialog, 35 30 ResponsiveDropdownMenuContent, 36 31 Text, 37 - Textarea, 38 32 useToast, 39 33 View, 40 34 } from "../ui"; ··· 61 55 let [messageRemoved, setMessageRemoved] = useState(false); 62 56 let { createBlock, isLoading: isBlockLoading } = useCreateBlockRecord(); 63 57 let { createHideChat, isLoading: isHideLoading } = useCreateHideChatRecord(); 64 - let { updateLivestream, isLoading: isUpdateTitleLoading } = 65 - useUpdateLivestreamRecord(); 66 - const livestream = useLivestream(); 67 - const [showUpdateTitleDialog, setShowUpdateTitleDialog] = useState(false); 68 58 69 59 const setReportModalOpen = usePlayerStore((x) => x.setReportModalOpen); 70 60 const setReportSubject = usePlayerStore((x) => x.setReportSubject); ··· 135 125 createHideChat={createHideChat} 136 126 createBlock={createBlock} 137 127 toast={toast} 138 - setShowUpdateTitleDialog={setShowUpdateTitleDialog} 139 - isUpdateTitleLoading={isUpdateTitleLoading} 140 - livestream={livestream} 141 128 setReportModalOpen={setReportModalOpen} 142 129 setReportSubject={setReportSubject} 143 130 deleteChatMessage={deleteChatMessage} ··· 145 132 )} 146 133 </ResponsiveDropdownMenuContent> 147 134 </DropdownMenu> 148 - 149 - {/* Update Stream Title Dialog - rendered outside dropdown */} 150 - {showUpdateTitleDialog && ( 151 - <UpdateStreamTitleDialog 152 - livestream={livestream} 153 - streamerDID={streamerDID} 154 - updateLivestream={updateLivestream} 155 - isLoading={isUpdateTitleLoading} 156 - onClose={() => setShowUpdateTitleDialog(false)} 157 - /> 158 - )} 159 135 </> 160 136 ); 161 137 }); ··· 173 149 createHideChat: (uri: string, streamerDID?: string) => Promise<any>; 174 150 createBlock: (did: string, streamerDID?: string) => Promise<any>; 175 151 toast: ReturnType<typeof useToast>; 176 - setShowUpdateTitleDialog: (show: boolean) => void; 177 - isUpdateTitleLoading: boolean; 178 - livestream: any; 179 152 setReportModalOpen: (open: boolean) => void; 180 153 setReportSubject: (subject: any) => void; 181 154 deleteChatMessage: (uri: string) => Promise<any>; ··· 194 167 createHideChat, 195 168 createBlock, 196 169 toast, 197 - setShowUpdateTitleDialog, 198 - isUpdateTitleLoading, 199 - livestream, 200 170 setReportModalOpen, 201 171 setReportSubject, 202 172 deleteChatMessage, ··· 290 260 </DropdownMenuGroup> 291 261 )} 292 262 293 - {modPermissions.canManageLivestream && ( 294 - <DropdownMenuGroup key="stream-actions" title={`Stream actions`}> 295 - <DropdownMenuItem 296 - onPress={() => { 297 - setShowUpdateTitleDialog(true); 298 - }} 299 - disabled={isUpdateTitleLoading || !livestream} 300 - > 301 - <Text 302 - color={isUpdateTitleLoading || !livestream ? "muted" : "primary"} 303 - > 304 - {isUpdateTitleLoading ? "Updating..." : "Update stream title"} 305 - </Text> 306 - </DropdownMenuItem> 307 - </DropdownMenuGroup> 308 - )} 309 - 310 263 <DropdownMenuGroup key="user-actions" title={`User actions`}> 311 264 <DropdownMenuItem 312 265 onPress={() => { ··· 418 371 </DropdownMenuItem> 419 372 ); 420 373 } 421 - 422 - interface UpdateStreamTitleDialogProps { 423 - livestream: any; 424 - streamerDID?: string; 425 - updateLivestream: ( 426 - livestreamUri: string, 427 - title: string, 428 - streamerDID?: string, 429 - ) => Promise<any>; 430 - isLoading: boolean; 431 - onClose: () => void; 432 - } 433 - 434 - function UpdateStreamTitleDialog({ 435 - livestream, 436 - streamerDID, 437 - updateLivestream, 438 - isLoading, 439 - onClose, 440 - }: UpdateStreamTitleDialogProps) { 441 - const [title, setTitle] = useState(livestream?.record?.title || ""); 442 - const [error, setError] = useState<string | null>(null); 443 - const toast = useToast(); 444 - 445 - useEffect(() => { 446 - if (livestream?.record?.title) { 447 - setTitle(livestream.record.title); 448 - } 449 - }, [livestream?.record?.title]); 450 - 451 - const handleUpdate = async () => { 452 - setError(null); 453 - 454 - if (!title.trim()) { 455 - setError("Please enter a stream title"); 456 - return; 457 - } 458 - 459 - if (!livestream?.uri) { 460 - setError("No livestream found"); 461 - return; 462 - } 463 - 464 - try { 465 - await updateLivestream(livestream.uri, title.trim(), streamerDID); 466 - toast.show( 467 - "Stream title updated", 468 - "The stream title has been successfully updated.", 469 - { duration: 3 }, 470 - ); 471 - onClose(); 472 - } catch (err) { 473 - setError( 474 - err instanceof Error ? err.message : "Failed to update stream title", 475 - ); 476 - } 477 - }; 478 - 479 - return ( 480 - <ResponsiveDialog 481 - open={true} 482 - onOpenChange={(open) => { 483 - if (!open) { 484 - onClose(); 485 - setError(null); 486 - setTitle(livestream?.record?.title || ""); 487 - } 488 - }} 489 - title="Update Stream Title" 490 - description="Update the title of the livestream." 491 - size="md" 492 - dismissible={false} 493 - > 494 - <View style={[{ padding: 16, paddingBottom: 0 }]}> 495 - <View style={[{ marginBottom: 16 }]}> 496 - <Text 497 - style={[ 498 - { color: atoms.colors.gray[300], fontSize: 13, marginBottom: 8 }, 499 - ]} 500 - > 501 - Stream Title 502 - </Text> 503 - <Textarea 504 - value={title} 505 - onChangeText={(text) => { 506 - setTitle(text); 507 - setError(null); 508 - }} 509 - placeholder="Enter stream title..." 510 - maxLength={140} 511 - multiline 512 - style={[ 513 - { 514 - padding: 12, 515 - borderRadius: 8, 516 - backgroundColor: atoms.colors.neutral[800], 517 - color: atoms.colors.white, 518 - borderWidth: 1, 519 - borderColor: atoms.colors.neutral[600], 520 - minHeight: 100, 521 - fontSize: 16, 522 - }, 523 - ]} 524 - /> 525 - <Text 526 - style={[ 527 - { color: atoms.colors.gray[400], fontSize: 12, marginTop: 4 }, 528 - ]} 529 - > 530 - {title.length}/140 characters 531 - </Text> 532 - </View> 533 - 534 - {error && ( 535 - <View 536 - style={[ 537 - { 538 - backgroundColor: atoms.colors.red[900], 539 - padding: 12, 540 - borderRadius: 8, 541 - borderWidth: 1, 542 - borderColor: atoms.colors.red[700], 543 - marginBottom: 16, 544 - }, 545 - ]} 546 - > 547 - <Text style={[{ color: atoms.colors.red[400], fontSize: 13 }]}> 548 - {error} 549 - </Text> 550 - </View> 551 - )} 552 - </View> 553 - 554 - <DialogFooter> 555 - <Button 556 - width="min" 557 - variant="secondary" 558 - onPress={() => { 559 - onClose(); 560 - setError(null); 561 - setTitle(livestream?.record?.title || ""); 562 - }} 563 - disabled={isLoading} 564 - > 565 - <Text>Cancel</Text> 566 - </Button> 567 - <Button 568 - variant="primary" 569 - width="min" 570 - onPress={handleUpdate} 571 - disabled={isLoading || !title.trim()} 572 - > 573 - <Text>{isLoading ? "Updating..." : "Update Title"}</Text> 574 - </Button> 575 - </DialogFooter> 576 - </ResponsiveDialog> 577 - ); 578 - }
+169
js/components/src/components/chat/update-stream-title-dialog.tsx
··· 1 + import { useEffect, useState } from "react"; 2 + import { 3 + atoms, 4 + Button, 5 + DialogFooter, 6 + ResponsiveDialog, 7 + Text, 8 + Textarea, 9 + useToast, 10 + View, 11 + } from "../ui"; 12 + 13 + export interface UpdateStreamTitleDialogProps { 14 + livestream: any; 15 + streamerDID?: string; 16 + updateLivestream: ( 17 + livestreamUri: string, 18 + title: string, 19 + streamerDID?: string, 20 + ) => Promise<any>; 21 + isLoading: boolean; 22 + onClose: () => void; 23 + } 24 + 25 + export function UpdateStreamTitleDialog({ 26 + livestream, 27 + streamerDID, 28 + updateLivestream, 29 + isLoading, 30 + onClose, 31 + }: UpdateStreamTitleDialogProps) { 32 + const [title, setTitle] = useState(livestream?.record?.title || ""); 33 + const [error, setError] = useState<string | null>(null); 34 + const toast = useToast(); 35 + 36 + useEffect(() => { 37 + if (livestream?.record?.title) { 38 + setTitle(livestream.record.title); 39 + } 40 + }, [livestream?.record?.title]); 41 + 42 + const handleUpdate = async () => { 43 + setError(null); 44 + 45 + if (!title.trim()) { 46 + setError("Please enter a stream title"); 47 + return; 48 + } 49 + 50 + if (!livestream?.uri) { 51 + setError("No livestream found"); 52 + return; 53 + } 54 + 55 + try { 56 + await updateLivestream(livestream.uri, title.trim(), streamerDID); 57 + toast.show( 58 + "Stream title updated", 59 + "The stream title has been successfully updated.", 60 + { duration: 3 }, 61 + ); 62 + onClose(); 63 + } catch (err) { 64 + setError( 65 + err instanceof Error ? err.message : "Failed to update stream title", 66 + ); 67 + } 68 + }; 69 + 70 + return ( 71 + <ResponsiveDialog 72 + open={true} 73 + onOpenChange={(open) => { 74 + if (!open) { 75 + onClose(); 76 + setError(null); 77 + setTitle(livestream?.record?.title || ""); 78 + } 79 + }} 80 + title="Update Stream Title" 81 + description="Update the title of the livestream." 82 + size="md" 83 + dismissible={false} 84 + > 85 + <View style={[{ padding: 16, paddingBottom: 0 }]}> 86 + <View style={[{ marginBottom: 16 }]}> 87 + <Text 88 + style={[ 89 + { color: atoms.colors.gray[300], fontSize: 13, marginBottom: 8 }, 90 + ]} 91 + > 92 + Stream Title 93 + </Text> 94 + <Textarea 95 + value={title} 96 + onChangeText={(text) => { 97 + setTitle(text); 98 + setError(null); 99 + }} 100 + placeholder="Enter stream title..." 101 + maxLength={140} 102 + multiline 103 + style={[ 104 + { 105 + padding: 12, 106 + borderRadius: 8, 107 + backgroundColor: atoms.colors.neutral[800], 108 + color: atoms.colors.white, 109 + borderWidth: 1, 110 + borderColor: atoms.colors.neutral[600], 111 + minHeight: 100, 112 + fontSize: 16, 113 + }, 114 + ]} 115 + /> 116 + <Text 117 + style={[ 118 + { color: atoms.colors.gray[400], fontSize: 12, marginTop: 4 }, 119 + ]} 120 + > 121 + {title.length}/140 characters 122 + </Text> 123 + </View> 124 + 125 + {error && ( 126 + <View 127 + style={[ 128 + { 129 + backgroundColor: atoms.colors.red[900], 130 + padding: 12, 131 + borderRadius: 8, 132 + borderWidth: 1, 133 + borderColor: atoms.colors.red[700], 134 + marginBottom: 16, 135 + }, 136 + ]} 137 + > 138 + <Text style={[{ color: atoms.colors.red[400], fontSize: 13 }]}> 139 + {error} 140 + </Text> 141 + </View> 142 + )} 143 + </View> 144 + 145 + <DialogFooter> 146 + <Button 147 + width="min" 148 + variant="secondary" 149 + onPress={() => { 150 + onClose(); 151 + setError(null); 152 + setTitle(livestream?.record?.title || ""); 153 + }} 154 + disabled={isLoading} 155 + > 156 + <Text>Cancel</Text> 157 + </Button> 158 + <Button 159 + variant="primary" 160 + width="min" 161 + onPress={handleUpdate} 162 + disabled={isLoading || !title.trim()} 163 + > 164 + <Text>{isLoading ? "Updating..." : "Update Title"}</Text> 165 + </Button> 166 + </DialogFooter> 167 + </ResponsiveDialog> 168 + ); 169 + }
+1
js/components/src/index.tsx
··· 34 34 export * from "./components/chat/chat"; 35 35 export * from "./components/chat/chat-box"; 36 36 export * from "./components/chat/system-message"; 37 + export * from "./components/chat/update-stream-title-dialog"; 37 38 export { default as VideoRetry } from "./components/mobile-player/video-retry"; 38 39 export * from "./lib/system-messages"; 39 40