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 6 formatHandle, 7 7 formatHandleWithAt, 8 8 Input, 9 + Text, 9 10 Textarea, 10 11 Tooltip, 11 12 useCreateStreamRecord, ··· 21 22 Image, 22 23 Platform, 23 24 ScrollView, 24 - Text, 25 25 TouchableOpacity, 26 26 View, 27 27 } from "react-native"; ··· 80 80 selectedImage, 81 81 onImageSelect, 82 82 onImageRemove, 83 + onUseLastImage, 84 + hasLastImage, 85 + onGoToMetadata, 83 86 }: { 84 87 selectedImage?: string | File | Blob; 85 88 onImageSelect?: () => void; 86 89 onImageRemove?: () => void; 90 + onUseLastImage?: () => void; 91 + hasLastImage?: boolean; 92 + onGoToMetadata?: () => void; 87 93 }) => { 88 94 const imageUrl = useMemo(() => { 89 95 if (!selectedImage) return undefined; ··· 150 156 </TouchableOpacity> 151 157 </View> 152 158 ) : ( 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> 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 + </> 162 182 )} 163 183 </View> 164 184 ); ··· 185 205 186 206 const [createPost, setCreatePost] = useState(true); 187 207 const [sendPushNotification, setSendPushNotification] = useState(true); 188 - const [canonicalUrl, setCanonicalUrl] = useState<string>( 189 - livestream?.record.canonicalUrl || "", 190 - ); 208 + const [canonicalUrl, setCanonicalUrl] = useState<string>(""); 191 209 const defaultCanonicalUrl = useMemo(() => { 192 210 return `${url}/${profile && formatHandle(profile)}`; 193 211 }, [url, profile?.handle]); ··· 196 214 if (!livestream) { 197 215 return; 198 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 199 224 if ( 200 225 livestream.record.canonicalUrl && 201 226 livestream.record.canonicalUrl !== defaultCanonicalUrl 202 227 ) { 203 228 setCanonicalUrl(livestream.record.canonicalUrl); 204 229 } 230 + 231 + // Prefill notification settings 205 232 if ( 206 233 typeof livestream.record.notificationSettings?.pushNotification === 207 234 "boolean" ··· 210 237 livestream.record.notificationSettings.pushNotification, 211 238 ); 212 239 } 240 + 241 + // Prefill post creation preference 213 242 setCreatePost(typeof livestream.record.post !== "undefined"); 214 243 }, [livestream, defaultCanonicalUrl]); 215 244 ··· 320 349 setSelectedImage(undefined); 321 350 }, []); 322 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 + 323 408 const disabled = useMemo( 324 409 () => !userIsLive || loading || title.trim() === "", 325 410 [userIsLive, loading, title], ··· 569 654 selectedImage={selectedImage} 570 655 onImageSelect={handleImageSelect} 571 656 onImageRemove={handleImageRemove} 657 + onUseLastImage={handleUseLastImage} 658 + hasLastImage={!!livestream?.record.thumb} 659 + onGoToMetadata={() => handleModeChange("metadata")} 572 660 /> 573 661 )} 574 662