Live video on the AT Protocol

restore last title and add button to restore last image

+101 -13
+101 -13
js/app/components/live-dashboard/livestream-panel.tsx
··· 6 formatHandle, 7 formatHandleWithAt, 8 Input, 9 Textarea, 10 Tooltip, 11 useCreateStreamRecord, ··· 21 Image, 22 Platform, 23 ScrollView, 24 - Text, 25 TouchableOpacity, 26 View, 27 } from "react-native"; ··· 80 selectedImage, 81 onImageSelect, 82 onImageRemove, 83 }: { 84 selectedImage?: string | File | Blob; 85 onImageSelect?: () => void; 86 onImageRemove?: () => void; 87 }) => { 88 const imageUrl = useMemo(() => { 89 if (!selectedImage) return undefined; ··· 150 </TouchableOpacity> 151 </View> 152 ) : ( 153 - <TouchableOpacity onPress={onImageSelect} style={containerStyle}> 154 - <ImagePlus size={48} color="#6b7280" /> 155 - <Text style={[text.gray[400], { marginTop: 8, fontSize: 14 }]}> 156 - Add thumbnail image 157 - </Text> 158 - <Text style={[text.gray[500], { fontSize: 12, marginTop: 4 }]}> 159 - Optional • JPG, PNG up to 975KB 160 - </Text> 161 - </TouchableOpacity> 162 )} 163 </View> 164 ); ··· 185 186 const [createPost, setCreatePost] = useState(true); 187 const [sendPushNotification, setSendPushNotification] = useState(true); 188 - const [canonicalUrl, setCanonicalUrl] = useState<string>( 189 - livestream?.record.canonicalUrl || "", 190 - ); 191 const defaultCanonicalUrl = useMemo(() => { 192 return `${url}/${profile && formatHandle(profile)}`; 193 }, [url, profile?.handle]); ··· 196 if (!livestream) { 197 return; 198 } 199 if ( 200 livestream.record.canonicalUrl && 201 livestream.record.canonicalUrl !== defaultCanonicalUrl 202 ) { 203 setCanonicalUrl(livestream.record.canonicalUrl); 204 } 205 if ( 206 typeof livestream.record.notificationSettings?.pushNotification === 207 "boolean" ··· 210 livestream.record.notificationSettings.pushNotification, 211 ); 212 } 213 setCreatePost(typeof livestream.record.post !== "undefined"); 214 }, [livestream, defaultCanonicalUrl]); 215 ··· 320 setSelectedImage(undefined); 321 }, []); 322 323 const disabled = useMemo( 324 () => !userIsLive || loading || title.trim() === "", 325 [userIsLive, loading, title], ··· 569 selectedImage={selectedImage} 570 onImageSelect={handleImageSelect} 571 onImageRemove={handleImageRemove} 572 /> 573 )} 574
··· 6 formatHandle, 7 formatHandleWithAt, 8 Input, 9 + Text, 10 Textarea, 11 Tooltip, 12 useCreateStreamRecord, ··· 22 Image, 23 Platform, 24 ScrollView, 25 TouchableOpacity, 26 View, 27 } from "react-native"; ··· 80 selectedImage, 81 onImageSelect, 82 onImageRemove, 83 + onUseLastImage, 84 + hasLastImage, 85 + onGoToMetadata, 86 }: { 87 selectedImage?: string | File | Blob; 88 onImageSelect?: () => void; 89 onImageRemove?: () => void; 90 + onUseLastImage?: () => void; 91 + hasLastImage?: boolean; 92 + onGoToMetadata?: () => void; 93 }) => { 94 const imageUrl = useMemo(() => { 95 if (!selectedImage) return undefined; ··· 156 </TouchableOpacity> 157 </View> 158 ) : ( 159 + <> 160 + <TouchableOpacity onPress={onImageSelect} style={containerStyle}> 161 + <ImagePlus size={48} color="#6b7280" /> 162 + <Text style={[text.gray[400], { marginTop: 8, fontSize: 14 }]}> 163 + Add thumbnail image 164 + </Text> 165 + <Text style={[text.gray[500], { fontSize: 12, marginTop: 4 }]}> 166 + Optional • JPG, PNG up to 975KB 167 + </Text> 168 + </TouchableOpacity> 169 + {hasLastImage && ( 170 + <Button 171 + variant="secondary" 172 + size="sm" 173 + onPress={onUseLastImage} 174 + style={[{ marginTop: 8 }]} 175 + > 176 + <Text style={[text.gray[300], { fontSize: 14 }]}> 177 + Use Last Image 178 + </Text> 179 + </Button> 180 + )} 181 + </> 182 )} 183 </View> 184 ); ··· 205 206 const [createPost, setCreatePost] = useState(true); 207 const [sendPushNotification, setSendPushNotification] = useState(true); 208 + const [canonicalUrl, setCanonicalUrl] = useState<string>(""); 209 const defaultCanonicalUrl = useMemo(() => { 210 return `${url}/${profile && formatHandle(profile)}`; 211 }, [url, profile?.handle]); ··· 214 if (!livestream) { 215 return; 216 } 217 + 218 + // Prefill title with previous stream's title 219 + if (livestream.record.title) { 220 + setTitle(livestream.record.title); 221 + } 222 + 223 + // Prefill canonical URL 224 if ( 225 livestream.record.canonicalUrl && 226 livestream.record.canonicalUrl !== defaultCanonicalUrl 227 ) { 228 setCanonicalUrl(livestream.record.canonicalUrl); 229 } 230 + 231 + // Prefill notification settings 232 if ( 233 typeof livestream.record.notificationSettings?.pushNotification === 234 "boolean" ··· 237 livestream.record.notificationSettings.pushNotification, 238 ); 239 } 240 + 241 + // Prefill post creation preference 242 setCreatePost(typeof livestream.record.post !== "undefined"); 243 }, [livestream, defaultCanonicalUrl]); 244 ··· 349 setSelectedImage(undefined); 350 }, []); 351 352 + const handleUseLastImage = useCallback(async () => { 353 + if (!livestream?.record.thumb) return; 354 + 355 + try { 356 + const did = livestream.uri.split("/")[2]; 357 + const cid = (livestream.record.thumb.ref as any).$link; 358 + 359 + let didDoc; 360 + 361 + // Resolve the DID document based on DID method 362 + if (did.startsWith("did:web:")) { 363 + // For did:web, construct the URL directly 364 + const domain = did.replace("did:web:", "").replace(/:/g, "/"); 365 + const didDocUrl = `https://${domain}/.well-known/did.json`; 366 + const didResponse = await fetch(didDocUrl); 367 + if (!didResponse.ok) { 368 + throw new Error("Failed to resolve did:web document"); 369 + } 370 + didDoc = await didResponse.json(); 371 + } else if (did.startsWith("did:plc:")) { 372 + // For did:plc, use plc.directory 373 + const didResponse = await fetch(`https://plc.directory/${did}`); 374 + if (!didResponse.ok) { 375 + throw new Error("Failed to resolve DID document"); 376 + } 377 + didDoc = await didResponse.json(); 378 + } else { 379 + throw new Error(`Unsupported DID method: ${did}`); 380 + } 381 + 382 + const pdsService = didDoc.service?.find( 383 + (s: any) => s.id === "#atproto_pds", 384 + ); 385 + 386 + if (!pdsService?.serviceEndpoint) { 387 + throw new Error("No PDS service endpoint found in DID document"); 388 + } 389 + 390 + // Construct the blob URL using the PDS endpoint 391 + const thumbnailUrl = `${pdsService.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`; 392 + 393 + // Fetch the image and convert to blob 394 + const response = await fetch(thumbnailUrl); 395 + if (!response.ok) { 396 + throw new Error(`Failed to fetch blob: ${response.status}`); 397 + } 398 + const blob = await response.blob(); 399 + setSelectedImage(blob); 400 + } catch (error) { 401 + console.error("Failed to fetch last image:", error); 402 + toast.show("Error", "Failed to load previous thumbnail", { 403 + duration: 3, 404 + }); 405 + } 406 + }, [livestream, toast]); 407 + 408 const disabled = useMemo( 409 () => !userIsLive || loading || title.trim() === "", 410 [userIsLive, loading, title], ··· 654 selectedImage={selectedImage} 655 onImageSelect={handleImageSelect} 656 onImageRemove={handleImageRemove} 657 + onUseLastImage={handleUseLastImage} 658 + hasLastImage={!!livestream?.record.thumb} 659 + onGoToMetadata={() => handleModeChange("metadata")} 660 /> 661 )} 662