Live video on the AT Protocol

Compare changes

Choose any two refs to compare.

+229 -580
+1 -1
.prettierrc
··· 14 } 15 } 16 ], 17 - "plugins": ["prettier-plugin-organize-imports", "prettier-plugin-curly"] 18 }
··· 14 } 15 } 16 ], 17 + "plugins": ["prettier-plugin-organize-imports"] 18 }
+5 -15
js/app/components/container.tsx
··· 18 } 19 20 function getMaxWidth(width: number): number { 21 - if (width >= 1660) { 22 - return maxContainerWidths.threeXl; 23 - } 24 - if (width >= 1260) { 25 - return maxContainerWidths.twoXl; 26 - } 27 - if (width >= 800) { 28 - return maxContainerWidths.xl; 29 - } 30 - if (width >= 740) { 31 - return maxContainerWidths.lg; 32 - } 33 - if (width >= 660) { 34 - return maxContainerWidths.md; 35 - } 36 return maxContainerWidths.sm; 37 } 38
··· 18 } 19 20 function getMaxWidth(width: number): number { 21 + if (width >= 1660) return maxContainerWidths.threeXl; 22 + if (width >= 1260) return maxContainerWidths.twoXl; 23 + if (width >= 800) return maxContainerWidths.xl; 24 + if (width >= 740) return maxContainerWidths.lg; 25 + if (width >= 660) return maxContainerWidths.md; 26 return maxContainerWidths.sm; 27 } 28
+5 -11
js/app/components/debug/breakpoint-indicator.tsx
··· 6 7 // Simple breakpoint detection based on width 8 let current = "xs"; 9 - if (width >= 1536) { 10 - current = "xxl"; 11 - } else if (width >= 1280) { 12 - current = "xl"; 13 - } else if (width >= 1024) { 14 - current = "lg"; 15 - } else if (width >= 768) { 16 - current = "md"; 17 - } else if (width >= 640) { 18 - current = "sm"; 19 - } 20 21 return ( 22 <View
··· 6 7 // Simple breakpoint detection based on width 8 let current = "xs"; 9 + if (width >= 1536) current = "xxl"; 10 + else if (width >= 1280) current = "xl"; 11 + else if (width >= 1024) current = "lg"; 12 + else if (width >= 768) current = "md"; 13 + else if (width >= 640) current = "sm"; 14 15 return ( 16 <View
+1 -3
js/app/components/emoji-picker/emoji-picker.tsx
··· 12 } 13 14 export function EmojiPicker({ isOpen, onClose }: EmojiPickerProps) { 15 - if (!isOpen) { 16 - return null; 17 - } 18 19 const isWeb = Platform.OS === "web"; 20
··· 12 } 13 14 export function EmojiPicker({ isOpen, onClose }: EmojiPickerProps) { 15 + if (!isOpen) return null; 16 17 const isWeb = Platform.OS === "web"; 18
+4 -12
js/app/components/follow-button.tsx
··· 32 const unfollowUser = useStore((state) => state.unfollowUser); 33 34 // Hide button if not logged in or viewing own stream 35 - if (!currentUserDID || currentUserDID === streamerDID) { 36 - return null; 37 - } 38 39 // Fetch initial follow state using xrpc 40 useEffect(() => { 41 let cancelled = false; 42 43 const fetchFollowStatus = async () => { 44 - if (!currentUserDID || !streamerDID) { 45 - return; 46 - } 47 48 setError(null); 49 try { ··· 60 } 61 62 const data = await res.json(); 63 - if (cancelled) { 64 - return; 65 - } 66 67 if (data.follow) { 68 setIsFollowing(true); ··· 72 setFollowUri(null); 73 } 74 } catch (err) { 75 - if (!cancelled) { 76 - setError("Could not determine follow state"); 77 - } 78 } 79 }; 80
··· 32 const unfollowUser = useStore((state) => state.unfollowUser); 33 34 // Hide button if not logged in or viewing own stream 35 + if (!currentUserDID || currentUserDID === streamerDID) return null; 36 37 // Fetch initial follow state using xrpc 38 useEffect(() => { 39 let cancelled = false; 40 41 const fetchFollowStatus = async () => { 42 + if (!currentUserDID || !streamerDID) return; 43 44 setError(null); 45 try { ··· 56 } 57 58 const data = await res.json(); 59 + if (cancelled) return; 60 61 if (data.follow) { 62 setIsFollowing(true); ··· 66 setFollowUri(null); 67 } 68 } catch (err) { 69 + if (!cancelled) setError("Could not determine follow state"); 70 } 71 }; 72
+3 -9
js/app/components/live-dashboard/bento-grid.tsx
··· 87 88 // Calculate uptime 89 const getUptime = useCallback((): string => { 90 - if (!ingestStarted || !isLive) { 91 - return "00:00:00"; 92 - } 93 const uptimeMs = Date.now() - ingestStarted; 94 const seconds = Math.floor(uptimeMs / 1000); 95 const hours = Math.floor(seconds / 3600); ··· 100 101 // Calculate bitrate 102 const getBitrate = useCallback((): string => { 103 - if (!seg?.size || !seg?.duration) { 104 - return "0 kbps"; 105 - } 106 const kbps = 107 (seg.size * 8) / 108 ((seg.duration || 1000000000) / 1000000000) / ··· 117 | "good" 118 | "poor" 119 | "offline" => { 120 - if (!isLive) { 121 - return "offline"; 122 - } 123 switch (segmentTiming.connectionQuality) { 124 case "good": 125 return "excellent";
··· 87 88 // Calculate uptime 89 const getUptime = useCallback((): string => { 90 + if (!ingestStarted || !isLive) return "00:00:00"; 91 const uptimeMs = Date.now() - ingestStarted; 92 const seconds = Math.floor(uptimeMs / 1000); 93 const hours = Math.floor(seconds / 3600); ··· 98 99 // Calculate bitrate 100 const getBitrate = useCallback((): string => { 101 + if (!seg?.size || !seg?.duration) return "0 kbps"; 102 const kbps = 103 (seg.size * 8) / 104 ((seg.duration || 1000000000) / 1000000000) / ··· 113 | "good" 114 | "poor" 115 | "offline" => { 116 + if (!isLive) return "offline"; 117 switch (segmentTiming.connectionQuality) { 118 case "good": 119 return "excellent";
+4 -12
js/app/components/live-dashboard/livestream-panel.tsx
··· 96 onGoToMetadata?: () => void; 97 }) => { 98 const imageUrl = useMemo(() => { 99 - if (!selectedImage) { 100 - return undefined; 101 - } 102 if (selectedImage instanceof File || selectedImage instanceof Blob) { 103 return URL.createObjectURL(selectedImage); 104 } ··· 270 ); 271 272 const handleSubmit = useCallback(async () => { 273 - if (!title.trim()) { 274 - return; 275 - } 276 277 setLoading(true); 278 ··· 372 }, []); 373 374 const handleUseLastImage = useCallback(async () => { 375 - if (!livestream?.record.thumb) { 376 - return; 377 - } 378 379 try { 380 const did = livestream.uri.split("/")[2]; ··· 397 ); 398 399 const buttonText = useMemo(() => { 400 - if (loading) { 401 - return "Loading..."; 402 - } 403 if (!userIsLive) { 404 return mode === "create" 405 ? "Waiting for stream to start..."
··· 96 onGoToMetadata?: () => void; 97 }) => { 98 const imageUrl = useMemo(() => { 99 + if (!selectedImage) return undefined; 100 if (selectedImage instanceof File || selectedImage instanceof Blob) { 101 return URL.createObjectURL(selectedImage); 102 } ··· 268 ); 269 270 const handleSubmit = useCallback(async () => { 271 + if (!title.trim()) return; 272 273 setLoading(true); 274 ··· 368 }, []); 369 370 const handleUseLastImage = useCallback(async () => { 371 + if (!livestream?.record.thumb) return; 372 373 try { 374 const did = livestream.uri.split("/")[2]; ··· 391 ); 392 393 const buttonText = useMemo(() => { 394 + if (loading) return "Loading..."; 395 if (!userIsLive) { 396 return mode === "create" 397 ? "Waiting for stream to start..."
+5 -15
js/app/components/live-dashboard/multistream-status.tsx
··· 47 }, [targets, opacity]); 48 49 const loadTargets = useCallback(async () => { 50 - if (!agent) { 51 - return; 52 - } 53 54 try { 55 setLoading(true); ··· 67 68 const toggleTarget = useCallback( 69 async (target: MultistreamTargetViewHydrated, newActiveState: boolean) => { 70 - if (!agent) { 71 - return; 72 - } 73 try { 74 setTogglingTargets((prev) => new Set(prev).add(target.uri)); 75 await agent.place.stream.multistream.putTarget({ ··· 101 const inactiveTargets = targets.filter((t) => !t.record.active); 102 103 const getTargetName = (target: MultistreamTargetViewHydrated) => { 104 - if (target.record.name) { 105 - return target.record.name; 106 - } 107 if (target.record.url) { 108 try { 109 const u = new URL(target.record.url); ··· 116 }; 117 118 const getTargetHostname = (target: MultistreamTargetViewHydrated) => { 119 - if (!target.record.url) { 120 - return null; 121 - } 122 try { 123 const u = new URL(target.record.url); 124 return u.host.split(":")[0]; ··· 128 }; 129 130 const getStatusColor = (target: MultistreamTargetViewHydrated) => { 131 - if (!target.record.active) { 132 - return text.gray[500]; 133 - } 134 135 switch (target.latestEvent?.status) { 136 case "active":
··· 47 }, [targets, opacity]); 48 49 const loadTargets = useCallback(async () => { 50 + if (!agent) return; 51 52 try { 53 setLoading(true); ··· 65 66 const toggleTarget = useCallback( 67 async (target: MultistreamTargetViewHydrated, newActiveState: boolean) => { 68 + if (!agent) return; 69 try { 70 setTogglingTargets((prev) => new Set(prev).add(target.uri)); 71 await agent.place.stream.multistream.putTarget({ ··· 97 const inactiveTargets = targets.filter((t) => !t.record.active); 98 99 const getTargetName = (target: MultistreamTargetViewHydrated) => { 100 + if (target.record.name) return target.record.name; 101 if (target.record.url) { 102 try { 103 const u = new URL(target.record.url); ··· 110 }; 111 112 const getTargetHostname = (target: MultistreamTargetViewHydrated) => { 113 + if (!target.record.url) return null; 114 try { 115 const u = new URL(target.record.url); 116 return u.host.split(":")[0]; ··· 120 }; 121 122 const getStatusColor = (target: MultistreamTargetViewHydrated) => { 123 + if (!target.record.active) return text.gray[500]; 124 125 switch (target.latestEvent?.status) { 126 case "active":
+2 -6
js/app/components/live-dashboard/stream-monitor.tsx
··· 60 61 // Connection quality indicator 62 const getConnectionIcon = () => { 63 - if (!isLive) { 64 - return null; 65 - } 66 67 switch (segmentTiming.connectionQuality) { 68 case "good": ··· 77 }; 78 79 const getConnectionColor = () => { 80 - if (!isLive) { 81 - return "red"; 82 - } 83 84 switch (segmentTiming.connectionQuality) { 85 case "good":
··· 60 61 // Connection quality indicator 62 const getConnectionIcon = () => { 63 + if (!isLive) return null; 64 65 switch (segmentTiming.connectionQuality) { 66 case "good": ··· 75 }; 76 77 const getConnectionColor = () => { 78 + if (!isLive) return "red"; 79 80 switch (segmentTiming.connectionQuality) { 81 case "good":
+1 -3
js/app/components/login/login-form.tsx
··· 69 return; 70 } 71 let clean = handle; 72 - if (handle.startsWith("@")) { 73 - clean = handle.slice(1); 74 - } 75 loginAction(clean, openLoginLink); 76 }; 77
··· 69 return; 70 } 71 let clean = handle; 72 + if (handle.startsWith("@")) clean = handle.slice(1); 73 loginAction(clean, openLoginLink); 74 }; 75
+1
js/app/components/login/login.tsx
··· 27 28 // check for stored return route on mount 29 useEffect(() => { 30 storage.getItem("returnRoute").then((stored) => { 31 if (stored) { 32 try {
··· 27 28 // check for stored return route on mount 29 useEffect(() => { 30 + if (Platform.OS !== "web") return; 31 storage.getItem("returnRoute").then((stored) => { 32 if (stored) { 33 try {
+1 -3
js/app/components/login/pds-host-selector-modal.tsx
··· 110 111 const handleSubmit = () => { 112 const hostToUse = useCustom ? customHost : selectedHost; 113 - if (!hostToUse) { 114 - return; 115 - } 116 117 onSubmit(hostToUse); 118 handleCancel();
··· 110 111 const handleSubmit = () => { 112 const hostToUse = useCustom ? customHost : selectedHost; 113 + if (!hostToUse) return; 114 115 onSubmit(hostToUse); 116 handleCancel();
+2 -6
js/app/components/mobile/desktop-ui/kebab.tsx
··· 131 return ( 132 <DropdownMenuItem 133 onPress={() => { 134 - if (!livestream) { 135 - return; 136 - } 137 onOpenChange?.(false); 138 setReportModalOpen(true); 139 setReportSubject({ ··· 163 return ( 164 <DropdownMenuItem 165 onPress={() => { 166 - if (!profile?.did) { 167 - return; 168 - } 169 onOpenChange?.(false); 170 setReportModalOpen(true); 171 setReportSubject({
··· 131 return ( 132 <DropdownMenuItem 133 onPress={() => { 134 + if (!livestream) return; 135 onOpenChange?.(false); 136 setReportModalOpen(true); 137 setReportSubject({ ··· 161 return ( 162 <DropdownMenuItem 163 onPress={() => { 164 + if (!profile?.did) return; 165 onOpenChange?.(false); 166 setReportModalOpen(true); 167 setReportSubject({
+1 -2
js/app/components/mobile/desktop-ui/live-bubble.tsx
··· 15 return segDate && Date.now() - segDate.getTime() <= 10 * 1000; 16 }, [segDate]); 17 18 - if (!isLive) { 19 return ( 20 <View style={[{ flexDirection: "row" }]}> 21 <View ··· 43 </View> 44 </View> 45 ); 46 - } 47 48 return ( 49 <View style={[{ flexDirection: "row" }]}>
··· 15 return segDate && Date.now() - segDate.getTime() <= 10 * 1000; 16 }, [segDate]); 17 18 + if (!isLive) 19 return ( 20 <View style={[{ flexDirection: "row" }]}> 21 <View ··· 43 </View> 44 </View> 45 ); 46 47 return ( 48 <View style={[{ flexDirection: "row" }]}>
+1 -3
js/app/components/mobile/desktop-ui/mute-overlay.tsx
··· 15 const setMuted = useSetMuted(); 16 const setMuteWasForced = usePlayerStore((state) => state.setMuteWasForced); 17 18 - if (!muteWasForced) { 19 - return null; 20 - } 21 22 return ( 23 <View
··· 15 const setMuted = useSetMuted(); 16 const setMuteWasForced = usePlayerStore((state) => state.setMuteWasForced); 17 18 + if (!muteWasForced) return null; 19 20 return ( 21 <View
+6 -18
js/app/components/mobile/desktop-ui.tsx
··· 90 91 const resetFadeTimer = useCallback(() => { 92 fadeOpacity.value = withTiming(1, { duration: 200 }); 93 - if (fadeTimeout.current) { 94 - clearTimeout(fadeTimeout.current); 95 - } 96 setIsControlsVisible(true); 97 98 fadeTimeout.current = setTimeout(() => { ··· 106 }, [resetFadeTimer]); 107 108 const toggleChat = useCallback(() => { 109 - if (setIsChatOpen) { 110 - setIsChatOpen(!isChatOpen); 111 - } 112 }, []); 113 114 useEffect(() => { 115 resetFadeTimer(); 116 117 return () => { 118 - if (fadeTimeout.current) { 119 - clearTimeout(fadeTimeout.current); 120 - } 121 if (ingestStarting) { 122 setIngestStarting(false); 123 } ··· 139 140 // Picture-in-Picture event listeners 141 useEffect(() => { 142 - if (Platform.OS !== "web") { 143 - return; 144 - } 145 146 let video: HTMLVideoElement | null = null; 147 if (isRefObject(videoRef)) { 148 video = videoRef.current; 149 } 150 - if (!video) { 151 - return; 152 - } 153 154 function onEnter() { 155 setPipActive(true); ··· 170 }, [videoRef]); 171 172 const handlePip = useCallback(() => { 173 - if (pipAction) { 174 - pipAction(); 175 - } 176 }, [pipAction]); 177 178 const hover = Gesture.Hover().onChange((_) => runOnJS(onPlayerHover)());
··· 90 91 const resetFadeTimer = useCallback(() => { 92 fadeOpacity.value = withTiming(1, { duration: 200 }); 93 + if (fadeTimeout.current) clearTimeout(fadeTimeout.current); 94 setIsControlsVisible(true); 95 96 fadeTimeout.current = setTimeout(() => { ··· 104 }, [resetFadeTimer]); 105 106 const toggleChat = useCallback(() => { 107 + if (setIsChatOpen) setIsChatOpen(!isChatOpen); 108 }, []); 109 110 useEffect(() => { 111 resetFadeTimer(); 112 113 return () => { 114 + if (fadeTimeout.current) clearTimeout(fadeTimeout.current); 115 if (ingestStarting) { 116 setIngestStarting(false); 117 } ··· 133 134 // Picture-in-Picture event listeners 135 useEffect(() => { 136 + if (Platform.OS !== "web") return; 137 138 let video: HTMLVideoElement | null = null; 139 if (isRefObject(videoRef)) { 140 video = videoRef.current; 141 } 142 + if (!video) return; 143 144 function onEnter() { 145 setPipActive(true); ··· 160 }, [videoRef]); 161 162 const handlePip = useCallback(() => { 163 + if (pipAction) pipAction(); 164 }, [pipAction]); 165 166 const hover = Gesture.Hover().onChange((_) => runOnJS(onPlayerHover)());
+1 -3
js/app/components/mobile/offline-counter/index.tsx
··· 49 return () => clearInterval(interval); 50 }, [offline, segment?.startTime]); 51 52 - if (!offline) { 53 - return null; 54 - } 55 56 const titleFontSize = isMobile ? 24 : 32; 57 const subtitleFontSize = isMobile ? 16 : 18;
··· 49 return () => clearInterval(interval); 50 }, [offline, segment?.startTime]); 51 52 + if (!offline) return null; 53 54 const titleFontSize = isMobile ? 24 : 32; 55 const subtitleFontSize = isMobile ? 16 : 18;
+3 -9
js/app/components/mobile/ui.tsx
··· 98 }, [ingestStarting, setIngestStarting]); 99 100 useEffect(() => { 101 - if (recordSubmitted) { 102 - setShowLoading(false); 103 - } 104 }, [recordSubmitted]); 105 106 const isSelfAndNotLive = ingest === "new"; ··· 118 119 const resetFadeTimer = () => { 120 fadeOpacity.value = withTiming(1, { duration: 200 }); 121 - if (fadeTimeout.current) { 122 - clearTimeout(fadeTimeout.current); 123 - } 124 fadeTimeout.current = setTimeout(() => { 125 fadeOpacity.value = withTiming(0, { duration: 400 }); 126 }, FADE_OUT_DELAY); ··· 129 useEffect(() => { 130 resetFadeTimer(); 131 return () => { 132 - if (fadeTimeout.current) { 133 - clearTimeout(fadeTimeout.current); 134 - } 135 }; 136 }, []); 137
··· 98 }, [ingestStarting, setIngestStarting]); 99 100 useEffect(() => { 101 + if (recordSubmitted) setShowLoading(false); 102 }, [recordSubmitted]); 103 104 const isSelfAndNotLive = ingest === "new"; ··· 116 117 const resetFadeTimer = () => { 118 fadeOpacity.value = withTiming(1, { duration: 200 }); 119 + if (fadeTimeout.current) clearTimeout(fadeTimeout.current); 120 fadeTimeout.current = setTimeout(() => { 121 fadeOpacity.value = withTiming(0, { duration: 400 }); 122 }, FADE_OUT_DELAY); ··· 125 useEffect(() => { 126 resetFadeTimer(); 127 return () => { 128 + if (fadeTimeout.current) clearTimeout(fadeTimeout.current); 129 }; 130 }, []); 131
+3 -9
js/app/components/mobile/user-offline.tsx
··· 56 const detailedProfile = profile ? pfp[profile?.did] : null; 57 58 useEffect(() => { 59 - if (!profile?.did) { 60 - return; 61 - } 62 63 let mounted = true; 64 ··· 67 try { 68 console.log("fetching recommendations for", profile.did); 69 const result = await getRecommendations(profile.did); 70 - if (!mounted) { 71 - return; 72 - } 73 if (result.recommendations && result.recommendations.length > 0) { 74 // Get the first livestream recommendation 75 const firstLivestream = result.recommendations.find( ··· 87 } catch (err) { 88 console.error("failed to get recommendations", err); 89 } finally { 90 - if (mounted) { 91 - setIsLoadingRecommendation(false); 92 - } 93 } 94 }; 95
··· 56 const detailedProfile = profile ? pfp[profile?.did] : null; 57 58 useEffect(() => { 59 + if (!profile?.did) return; 60 61 let mounted = true; 62 ··· 65 try { 66 console.log("fetching recommendations for", profile.did); 67 const result = await getRecommendations(profile.did); 68 + if (!mounted) return; 69 if (result.recommendations && result.recommendations.length > 0) { 70 // Get the first livestream recommendation 71 const firstLivestream = result.recommendations.find( ··· 83 } catch (err) { 84 console.error("failed to get recommendations", err); 85 } finally { 86 + if (mounted) setIsLoadingRecommendation(false); 87 } 88 }; 89
+1 -3
js/app/components/settings/key-manager.tsx
··· 98 99 const [deletingKeys, setDeletingKeys] = useState<Set<string>>(new Set()); 100 const deleteKeyRecord = (rkey: string) => { 101 - if (deletingKeys.has(rkey)) { 102 - return; 103 - } // Prevent double deletes 104 setDeletingKeys((prev) => new Set(prev).add(rkey)); 105 deleteStreamKeyRecord(rkey).finally(() => { 106 setDeletingKeys((prev) => {
··· 98 99 const [deletingKeys, setDeletingKeys] = useState<Set<string>>(new Set()); 100 const deleteKeyRecord = (rkey: string) => { 101 + if (deletingKeys.has(rkey)) return; // Prevent double deletes 102 setDeletingKeys((prev) => new Set(prev).add(rkey)); 103 deleteStreamKeyRecord(rkey).finally(() => { 104 setDeletingKeys((prev) => {
+5 -15
js/app/components/settings/multistream-manager.tsx
··· 83 const [formError, setFormError] = useState<string>(""); 84 85 const loadMultistreamTargets = async () => { 86 - if (!agent) { 87 - return; 88 - } 89 90 try { 91 setLoading(true); ··· 104 const createMultistreamTarget = async ( 105 record: PlaceStreamMultistreamTarget.Record, 106 ) => { 107 - if (!agent) { 108 - return; 109 - } 110 try { 111 setFormError(""); 112 setFormLoading(true); ··· 130 uri: string, 131 record: PlaceStreamMultistreamTarget.Record, 132 ) => { 133 - if (!agent) { 134 - return; 135 - } 136 try { 137 setFormError(""); 138 setFormLoading(true); ··· 155 target: MultistreamTargetViewHydrated, 156 newActiveState: boolean, 157 ) => { 158 - if (!agent) { 159 - return; 160 - } 161 try { 162 setTogglingTargets((prev) => new Set(prev).add(target.uri)); 163 await agent.place.stream.multistream.putTarget({ ··· 181 }; 182 183 const deleteMultistreamTarget = async (uri: string) => { 184 - if (!agent) { 185 - return; 186 - } 187 try { 188 setFormError(""); 189 setDeletingTargets((prev) => new Set(prev).add(uri));
··· 83 const [formError, setFormError] = useState<string>(""); 84 85 const loadMultistreamTargets = async () => { 86 + if (!agent) return; 87 88 try { 89 setLoading(true); ··· 102 const createMultistreamTarget = async ( 103 record: PlaceStreamMultistreamTarget.Record, 104 ) => { 105 + if (!agent) return; 106 try { 107 setFormError(""); 108 setFormLoading(true); ··· 126 uri: string, 127 record: PlaceStreamMultistreamTarget.Record, 128 ) => { 129 + if (!agent) return; 130 try { 131 setFormError(""); 132 setFormLoading(true); ··· 149 target: MultistreamTargetViewHydrated, 150 newActiveState: boolean, 151 ) => { 152 + if (!agent) return; 153 try { 154 setTogglingTargets((prev) => new Set(prev).add(target.uri)); 155 await agent.place.stream.multistream.putTarget({ ··· 173 }; 174 175 const deleteMultistreamTarget = async (uri: string) => { 176 + if (!agent) return; 177 try { 178 setFormError(""); 179 setDeletingTargets((prev) => new Set(prev).add(uri));
+5 -15
js/app/components/settings/recommendations-manager.tsx
··· 63 const { t } = useTranslation("settings"); 64 65 const loadRecommendations = async () => { 66 - if (!agent) { 67 - return; 68 - } 69 70 try { 71 setLoading(true); ··· 113 }; 114 115 const saveRecommendations = async (newStreamers: string[]) => { 116 - if (!agent || saving) { 117 - return; 118 - } 119 120 try { 121 if (!agent.did) { ··· 253 }; 254 255 const handleSaveEdit = async () => { 256 - if (editingIndex === null) { 257 - return; 258 - } 259 260 const trimmed = editValue.trim(); 261 if (!trimmed) { ··· 291 }; 292 293 const confirmDelete = async () => { 294 - if (deleteDialog.index === null) { 295 - return; 296 - } 297 298 const newStreamers = streamers.filter((_, i) => i !== deleteDialog.index); 299 await saveRecommendations(newStreamers); ··· 301 }; 302 303 useEffect(() => { 304 - if (!agent) { 305 - return; 306 - } 307 loadRecommendations(); 308 }, [agent]); 309
··· 63 const { t } = useTranslation("settings"); 64 65 const loadRecommendations = async () => { 66 + if (!agent) return; 67 68 try { 69 setLoading(true); ··· 111 }; 112 113 const saveRecommendations = async (newStreamers: string[]) => { 114 + if (!agent || saving) return; 115 116 try { 117 if (!agent.did) { ··· 249 }; 250 251 const handleSaveEdit = async () => { 252 + if (editingIndex === null) return; 253 254 const trimmed = editValue.trim(); 255 if (!trimmed) { ··· 285 }; 286 287 const confirmDelete = async () => { 288 + if (deleteDialog.index === null) return; 289 290 const newStreamers = streamers.filter((_, i) => i !== deleteDialog.index); 291 await saveRecommendations(newStreamers); ··· 293 }; 294 295 useEffect(() => { 296 + if (!agent) return; 297 loadRecommendations(); 298 }, [agent]); 299
+6 -18
js/app/components/settings/webhook-manager.tsx
··· 631 const { t } = useTranslation("settings"); 632 633 const loadWebhooks = async () => { 634 - if (!agent) { 635 - return; 636 - } 637 638 try { 639 setLoading(true); ··· 659 }; 660 661 const createWebhook = async (data: WebhookFormData) => { 662 - if (!agent) { 663 - return; 664 - } 665 666 try { 667 setFormLoading(true); ··· 697 }; 698 699 const updateWebhook = async (data: WebhookFormData) => { 700 - if (!agent || !editingWebhook) { 701 - return; 702 - } 703 704 try { 705 setFormLoading(true); ··· 737 738 const deleteWebhook = async (id: string) => { 739 const webhook = webhooks?.find((w) => w.id === id); 740 - if (!webhook) { 741 - return; 742 - } 743 744 setDeleteDialog({ isVisible: true, webhook }); 745 }; 746 747 const confirmDelete = async () => { 748 - if (!agent || !deleteDialog.webhook) { 749 - return; 750 - } 751 752 const id = deleteDialog.webhook.id; 753 ··· 790 }; 791 792 useEffect(() => { 793 - if (!agent) { 794 - return; 795 - } 796 loadWebhooks(); 797 }, [agent]); 798
··· 631 const { t } = useTranslation("settings"); 632 633 const loadWebhooks = async () => { 634 + if (!agent) return; 635 636 try { 637 setLoading(true); ··· 657 }; 658 659 const createWebhook = async (data: WebhookFormData) => { 660 + if (!agent) return; 661 662 try { 663 setFormLoading(true); ··· 693 }; 694 695 const updateWebhook = async (data: WebhookFormData) => { 696 + if (!agent || !editingWebhook) return; 697 698 try { 699 setFormLoading(true); ··· 731 732 const deleteWebhook = async (id: string) => { 733 const webhook = webhooks?.find((w) => w.id === id); 734 + if (!webhook) return; 735 736 setDeleteDialog({ isVisible: true, webhook }); 737 }; 738 739 const confirmDelete = async () => { 740 + if (!agent || !deleteDialog.webhook) return; 741 742 const id = deleteDialog.webhook.id; 743 ··· 780 }; 781 782 useEffect(() => { 783 + if (!agent) return; 784 loadWebhooks(); 785 }, [agent]); 786
+6 -1
js/app/features/bluesky/blueskyProvider.tsx
··· 2 import { storage } from "@streamplace/components"; 3 import { useURL } from "expo-linking"; 4 import { useEffect, useState } from "react"; 5 import { useStore } from "store"; 6 import { useIsReady, useOAuthSession, useUserProfile } from "store/hooks"; 7 import { navigateToRoute } from "utils/navigation"; ··· 23 loadOAuthClient(); 24 25 // load return route from storage on mount 26 storage.getItem("returnRoute").then((stored) => { 27 if (stored) { 28 try { ··· 82 if ( 83 lastAuthStatus !== "loggedIn" && 84 authStatus === "loggedIn" && 85 - returnRoute 86 ) { 87 console.log( 88 "Login successful, navigating back to returnRoute:",
··· 2 import { storage } from "@streamplace/components"; 3 import { useURL } from "expo-linking"; 4 import { useEffect, useState } from "react"; 5 + import { Platform } from "react-native"; 6 import { useStore } from "store"; 7 import { useIsReady, useOAuthSession, useUserProfile } from "store/hooks"; 8 import { navigateToRoute } from "utils/navigation"; ··· 24 loadOAuthClient(); 25 26 // load return route from storage on mount 27 + if (Platform.OS !== "web") { 28 + return; 29 + } 30 storage.getItem("returnRoute").then((stored) => { 31 if (stored) { 32 try { ··· 86 if ( 87 lastAuthStatus !== "loggedIn" && 88 authStatus === "loggedIn" && 89 + returnRoute && 90 + Platform.OS === "web" 91 ) { 92 console.log( 93 "Login successful, navigating back to returnRoute:",
+2 -6
js/app/hooks/usePreloadEmoji.ts
··· 6 7 export function usePreloadEmoji({ immediate }: { immediate?: boolean } = {}) { 8 const preload = React.useCallback(async () => { 9 - if (loadRequested) { 10 - return; 11 - } 12 loadRequested = true; 13 let data; 14 if (Platform.OS === "web") { ··· 19 init({ data }); 20 }, []); 21 22 - if (immediate) { 23 - preload(); 24 - } 25 return preload; 26 }
··· 6 7 export function usePreloadEmoji({ immediate }: { immediate?: boolean } = {}) { 8 const preload = React.useCallback(async () => { 9 + if (loadRequested) return; 10 loadRequested = true; 11 let data; 12 if (Platform.OS === "web") { ··· 17 init({ data }); 18 }, []); 19 20 + if (immediate) preload(); 21 return preload; 22 }
+1 -3
js/app/hooks/useSegmentTiming.tsx
··· 8 range: number | null, 9 numOfSegments: number = 1, 10 ): ConnectionQuality { 11 - if (timeBetweenSegments === null || range === null) { 12 - return "poor"; 13 - } 14 15 if (timeBetweenSegments <= 1500 && range <= (1500 * 60) / numOfSegments) { 16 return "good";
··· 8 range: number | null, 9 numOfSegments: number = 1, 10 ): ConnectionQuality { 11 + if (timeBetweenSegments === null || range === null) return "poor"; 12 13 if (timeBetweenSegments <= 1500 && range <= (1500 * 60) / numOfSegments) { 14 return "good";
+1 -3
js/app/hooks/useSidebarControl.tsx
··· 49 const isActive = useIsLargeScreen(); 50 useEffect(() => { 51 if (isActive) { 52 - if (!isHidden && targetWidth < 64) { 53 - targetWidth == 64; 54 - } 55 // Only animate if the sidebar is active 56 animatedWidth.value = withTiming(targetWidth, { duration: 250 }); 57 } else {
··· 49 const isActive = useIsLargeScreen(); 50 useEffect(() => { 51 if (isActive) { 52 + if (!isHidden && targetWidth < 64) targetWidth == 64; 53 // Only animate if the sidebar is active 54 animatedWidth.value = withTiming(targetWidth, { duration: 250 }); 55 } else {
+1 -3
js/app/src/screens/chat-popout.native.tsx
··· 65 }, [seg?.id]); 66 67 const getLatency = useCallback((): string => { 68 - if (!segmentReceivedTimeRef.current) { 69 - return ""; 70 - } 71 const secondsAgo = Math.floor(Date.now() - segmentReceivedTimeRef.current); 72 const isThreeDigits = secondsAgo >= 100 && secondsAgo < 1000; 73 if (isThreeDigits) {
··· 65 }, [seg?.id]); 66 67 const getLatency = useCallback((): string => { 68 + if (!segmentReceivedTimeRef.current) return ""; 69 const secondsAgo = Math.floor(Date.now() - segmentReceivedTimeRef.current); 70 const isThreeDigits = secondsAgo >= 100 && secondsAgo < 1000; 71 if (isThreeDigits) {
+5 -15
js/app/src/screens/danmu-obs.tsx
··· 22 const params: DanmuParams = {}; 23 24 const opacity = query.get("opacity"); 25 - if (opacity) { 26 - params.opacity = parseInt(opacity); 27 - } 28 29 const speed = query.get("speed"); 30 - if (speed) { 31 - params.speed = parseFloat(speed); 32 - } 33 34 const laneCount = query.get("laneCount"); 35 - if (laneCount) { 36 - params.laneCount = parseInt(laneCount); 37 - } 38 39 const maxMessages = query.get("maxMessages"); 40 - if (maxMessages) { 41 - params.maxMessages = parseInt(maxMessages); 42 - } 43 44 const enabled = query.get("enabled"); 45 - if (enabled !== null) { 46 - params.enabled = enabled !== "false"; 47 - } 48 49 return params; 50 };
··· 22 const params: DanmuParams = {}; 23 24 const opacity = query.get("opacity"); 25 + if (opacity) params.opacity = parseInt(opacity); 26 27 const speed = query.get("speed"); 28 + if (speed) params.speed = parseFloat(speed); 29 30 const laneCount = query.get("laneCount"); 31 + if (laneCount) params.laneCount = parseInt(laneCount); 32 33 const maxMessages = query.get("maxMessages"); 34 + if (maxMessages) params.maxMessages = parseInt(maxMessages); 35 36 const enabled = query.get("enabled"); 37 + if (enabled !== null) params.enabled = enabled !== "false"; 38 39 return params; 40 };
+9 -27
js/app/src/screens/home.tsx
··· 64 } 65 66 function getHomeScreenItemSize(width: number): StreamCardSize { 67 - if (width >= 1536) { 68 - return "md"; 69 - } // xxl 70 - if (width >= 1280) { 71 - return "sm"; 72 - } // xl 73 - if (width >= 1024) { 74 - return "sm"; 75 - } // lg 76 - if (width >= 768) { 77 - return "sm"; 78 - } // md 79 return "xs"; // sm and below 80 } 81 82 function getHomeScreenCols(width: number): number { 83 - if (width >= 1550) { 84 - return 4; 85 - } 86 - if (width >= 1280) { 87 - return 3; 88 - } 89 - if (width >= 1024) { 90 - return 2; 91 - } 92 - if (width >= 768) { 93 - return 2; 94 - } 95 return 1; 96 } 97 98 // Get the ratio for the first card based on column count 99 function getPadPercentage(cols: number): number { 100 - if (cols >= 3) { 101 - return 2.07; 102 - } 103 return 1; 104 } 105
··· 64 } 65 66 function getHomeScreenItemSize(width: number): StreamCardSize { 67 + if (width >= 1536) return "md"; // xxl 68 + if (width >= 1280) return "sm"; // xl 69 + if (width >= 1024) return "sm"; // lg 70 + if (width >= 768) return "sm"; // md 71 return "xs"; // sm and below 72 } 73 74 function getHomeScreenCols(width: number): number { 75 + if (width >= 1550) return 4; 76 + if (width >= 1280) return 3; 77 + if (width >= 1024) return 2; 78 + if (width >= 768) return 2; 79 return 1; 80 } 81 82 // Get the ratio for the first card based on column count 83 function getPadPercentage(cols: number): number { 84 + if (cols >= 3) return 2.07; 85 return 1; 86 } 87
+1 -3
js/app/store/hooks.ts
··· 25 const oauthSession = useOAuthSession(); 26 const profiles = useProfiles(); 27 const did = oauthSession?.did; 28 - if (!did) { 29 - return null; 30 - } 31 return profiles[did]; 32 }; 33 export const useIsReady = () => {
··· 25 const oauthSession = useOAuthSession(); 26 const profiles = useProfiles(); 27 const did = oauthSession?.did; 28 + if (!did) return null; 29 return profiles[did]; 30 }; 31 export const useIsReady = () => {
+2 -6
js/app/store/slices/platformSlice.native.ts
··· 38 notificationToken: null, 39 notificationDestination: null, 40 handleNotification: (payload) => { 41 - if (!payload) { 42 - return; 43 - } 44 - if (typeof payload.path !== "string") { 45 - return; 46 - } 47 set({ notificationDestination: payload.path }); 48 }, 49 clearNotification: () => {
··· 38 notificationToken: null, 39 notificationDestination: null, 40 handleNotification: (payload) => { 41 + if (!payload) return; 42 + if (typeof payload.path !== "string") return; 43 set({ notificationDestination: payload.path }); 44 }, 45 clearNotification: () => {
+1 -3
js/atproto-oauth-client-react-native/src/sqlite-keystore.ts
··· 22 23 async get(key: string): Promise<T | undefined> { 24 const itemStr = await this.store.get(key); 25 - if (!itemStr) { 26 - return undefined; 27 - } 28 const item = JSON.parse(itemStr) as T; 29 if (item.dpopKey) { 30 item.dpopKey = new JoseKey(item.dpopKey as unknown as Jwk);
··· 22 23 async get(key: string): Promise<T | undefined> { 24 const itemStr = await this.store.get(key); 25 + if (!itemStr) return undefined; 26 const item = JSON.parse(itemStr) as T; 27 if (item.dpopKey) { 28 item.dpopKey = new JoseKey(item.dpopKey as unknown as Jwk);
+1 -3
js/components/scripts/migrate-i18n.js
··· 353 allNewKeys.push(...namespacesForLocale[namespace]); 354 } 355 356 - if (allNewKeys.length === 0) { 357 - continue; 358 - } 359 360 // Add all keys to the specified namespace 361 const targetFile = addKeysToFtlFile(
··· 353 allNewKeys.push(...namespacesForLocale[namespace]); 354 } 355 356 + if (allNewKeys.length === 0) continue; 357 358 // Add all keys to the specified namespace 359 const targetFile = addKeysToFtlFile(
+8 -24
js/components/src/components/chat/chat-box.tsx
··· 93 }, [pdsAgent, userDID, setActiveTeleportUri]); 94 95 const authors = useMemo(() => { 96 - if (!chat) { 97 - return null; 98 - } 99 return chat.reduce((acc, msg) => { 100 // our fake system user "did" 101 - if (msg.author.did === "did:sys:system") { 102 - return acc; 103 - } 104 - if (acc.has(msg.author.handle)) { 105 - return acc; 106 - } 107 acc.set(msg.author.handle, msg.chatProfile); 108 return acc; 109 }, new Map<string, ChatMessageViewHydrated["chatProfile"]>()); ··· 149 if (colonIndex !== -1) { 150 const searchText = text.slice(colonIndex + 1).toLowerCase(); 151 if (searchText.length >= 3) { 152 - if (!emojiData) { 153 - return; 154 - } 155 const aliasMatches = Object.entries(emojiData.aliases) 156 .map(([alias, emojiId]) => { 157 const aliasLower = alias.toLowerCase(); ··· 177 { matchType: number; index: number; alias: string } 178 > = {}; 179 for (const match of aliasMatches) { 180 - if (!match) { 181 - continue; 182 - } 183 const prev = bestAliasMatch[match.emojiId]; 184 if ( 185 !prev || ··· 242 // Sort by alias match type, then position, then fallback 243 .sort((a, b) => { 244 for (let i = 0; i < a.sort.length; ++i) { 245 - if (a.sort[i] !== b.sort[i]) { 246 - return a.sort[i] - b.sort[i]; 247 - } 248 } 249 return 0; 250 }) ··· 270 }; 271 272 const submit = async () => { 273 - if (!message.trim()) { 274 - return; 275 - } 276 if (graphemer.countGraphemes(message) > 300) { 277 toast.show( 278 "Message too long", ··· 589 aria-label="Popout Chat" 590 style={{ borderRadius: 16, maxWidth: 44, aspectRatio: 1 }} 591 onPress={() => { 592 - if (!linfo) { 593 - return; 594 - } 595 const u = new URL(window.location.href); 596 u.pathname = `/chat-popout/${linfo?.author?.did}`; 597 window.open(u.toString(), "_blank", "popup=true");
··· 93 }, [pdsAgent, userDID, setActiveTeleportUri]); 94 95 const authors = useMemo(() => { 96 + if (!chat) return null; 97 return chat.reduce((acc, msg) => { 98 // our fake system user "did" 99 + if (msg.author.did === "did:sys:system") return acc; 100 + if (acc.has(msg.author.handle)) return acc; 101 acc.set(msg.author.handle, msg.chatProfile); 102 return acc; 103 }, new Map<string, ChatMessageViewHydrated["chatProfile"]>()); ··· 143 if (colonIndex !== -1) { 144 const searchText = text.slice(colonIndex + 1).toLowerCase(); 145 if (searchText.length >= 3) { 146 + if (!emojiData) return; 147 const aliasMatches = Object.entries(emojiData.aliases) 148 .map(([alias, emojiId]) => { 149 const aliasLower = alias.toLowerCase(); ··· 169 { matchType: number; index: number; alias: string } 170 > = {}; 171 for (const match of aliasMatches) { 172 + if (!match) continue; 173 const prev = bestAliasMatch[match.emojiId]; 174 if ( 175 !prev || ··· 232 // Sort by alias match type, then position, then fallback 233 .sort((a, b) => { 234 for (let i = 0; i < a.sort.length; ++i) { 235 + if (a.sort[i] !== b.sort[i]) return a.sort[i] - b.sort[i]; 236 } 237 return 0; 238 }) ··· 258 }; 259 260 const submit = async () => { 261 + if (!message.trim()) return; 262 if (graphemer.countGraphemes(message) > 300) { 263 toast.show( 264 "Message too long", ··· 575 aria-label="Popout Chat" 576 style={{ borderRadius: 16, maxWidth: 44, aspectRatio: 1 }} 577 onPress={() => { 578 + if (!linfo) return; 579 const u = new URL(window.location.href); 580 u.pathname = `/chat-popout/${linfo?.author?.did}`; 581 window.open(u.toString(), "_blank", "popup=true");
+1 -3
js/components/src/components/chat/chat-message.tsx
··· 83 text: string; 84 facets: ChatMessageViewHydrated["record"]["facets"]; 85 }) => { 86 - if (!facets?.length) { 87 - return <Text>{text}</Text>; 88 - } 89 90 const userCache = useLivestreamStore((state) => state.authors); 91
··· 83 text: string; 84 facets: ChatMessageViewHydrated["record"]["facets"]; 85 }) => { 86 + if (!facets?.length) return <Text>{text}</Text>; 87 88 const userCache = useLivestreamStore((state) => state.authors); 89
+2 -5
js/components/src/components/chat/chat.tsx
··· 77 const setReply = useSetReplyToMessage(); 78 const setModMsg = usePlayerStore((state) => state.setModMessage); 79 80 - if (!visible) { 81 - return null; 82 - } 83 84 return ( 85 <View ··· 292 } 293 }; 294 295 - if (!chat) { 296 return ( 297 <View style={[flex.shrink[1], { minWidth: 0, maxWidth: "100%" }]}> 298 <Text>Loading chat...</Text> 299 </View> 300 ); 301 - } 302 303 return ( 304 <View
··· 77 const setReply = useSetReplyToMessage(); 78 const setModMsg = usePlayerStore((state) => state.setModMessage); 79 80 + if (!visible) return null; 81 82 return ( 83 <View ··· 290 } 291 }; 292 293 + if (!chat) 294 return ( 295 <View style={[flex.shrink[1], { minWidth: 0, maxWidth: "100%" }]}> 296 <Text>Loading chat...</Text> 297 </View> 298 ); 299 300 return ( 301 <View
+4 -12
js/components/src/components/chat/mod-view.tsx
··· 206 <DropdownMenuItem 207 disabled={isHideLoading || messageRemoved} 208 onPress={() => { 209 - if (isHideLoading || messageRemoved) { 210 - return; 211 - } 212 createHideChat(message.uri, streamerDID ?? undefined) 213 .then((r) => setMessageRemoved(true)) 214 .catch((e) => console.error(e)); ··· 232 <DropdownMenuItem 233 disabled={isBlockLoading} 234 onPress={() => { 235 - if (isBlockLoading) { 236 - return; 237 - } 238 createBlock(message.author.did, streamerDID ?? undefined) 239 .then((r) => { 240 toast.show( ··· 315 <DropdownMenuItem 316 closeOnPress={false} 317 onPress={() => { 318 - if (!message) { 319 - return; 320 - } 321 if (!confirming) { 322 setConfirming(DeleteState.Confirmed); 323 return; ··· 362 return ( 363 <DropdownMenuItem 364 onPress={() => { 365 - if (!message) { 366 - return; 367 - } 368 onOpenChange?.(false); 369 setReportModalOpen(true); 370 setReportSubject({
··· 206 <DropdownMenuItem 207 disabled={isHideLoading || messageRemoved} 208 onPress={() => { 209 + if (isHideLoading || messageRemoved) return; 210 createHideChat(message.uri, streamerDID ?? undefined) 211 .then((r) => setMessageRemoved(true)) 212 .catch((e) => console.error(e)); ··· 230 <DropdownMenuItem 231 disabled={isBlockLoading} 232 onPress={() => { 233 + if (isBlockLoading) return; 234 createBlock(message.author.did, streamerDID ?? undefined) 235 .then((r) => { 236 toast.show( ··· 311 <DropdownMenuItem 312 closeOnPress={false} 313 onPress={() => { 314 + if (!message) return; 315 if (!confirming) { 316 setConfirming(DeleteState.Confirmed); 317 return; ··· 356 return ( 357 <DropdownMenuItem 358 onPress={() => { 359 + if (!message) return; 360 onOpenChange?.(false); 361 setReportModalOpen(true); 362 setReportSubject({
+1 -3
js/components/src/components/content-metadata/content-metadata-form.tsx
··· 137 } 138 139 const loadMetadata = async () => { 140 - if (!pdsAgent || !did) { 141 - return; 142 - } 143 144 try { 145 const metadata = await getContentMetadata();
··· 137 } 138 139 const loadMetadata = async () => { 140 + if (!pdsAgent || !did) return; 141 142 try { 143 const metadata = await getContentMetadata();
+2 -5
js/components/src/components/danmu/danmu-message.tsx
··· 71 }; 72 73 useEffect(() => { 74 - if (messageWidth === 0) { 75 - return; 76 - } // Wait for layout measurement 77 78 const duration = baseDuration(message, MAX_DURATION, MIN_DURATION); 79 ··· 92 setTotalDuration(duration); 93 } 94 95 - if (__DEV__) { 96 console.log( 97 `[danmu] animation started: "${message.record.text}" (duration: ${duration.toFixed(0)}ms, remaining: ${remainingDuration.toFixed(0)}ms, speed: ${speed}x)`, 98 ); 99 - } 100 101 translateX.value = startPosition; 102
··· 71 }; 72 73 useEffect(() => { 74 + if (messageWidth === 0) return; // Wait for layout measurement 75 76 const duration = baseDuration(message, MAX_DURATION, MIN_DURATION); 77 ··· 90 setTotalDuration(duration); 91 } 92 93 + if (__DEV__) 94 console.log( 95 `[danmu] animation started: "${message.record.text}" (duration: ${duration.toFixed(0)}ms, remaining: ${remainingDuration.toFixed(0)}ms, speed: ${speed}x)`, 96 ); 97 98 translateX.value = startPosition; 99
+4 -11
js/components/src/components/danmu/danmu-overlay.tsx
··· 81 ); 82 83 useEffect(() => { 84 - if (!enabled || !chat || containerWidth === 0) { 85 - return; 86 - } 87 88 // only check new messages since last render (chat is sorted newest first) 89 const newMessageCount = chat.length - lastChatLength.current; 90 - if (newMessageCount <= 0) { 91 - return; 92 - } 93 94 const messagesToCheck = chat.slice(0, newMessageCount); 95 lastChatLength.current = chat.length; ··· 103 return !hasProcessed && !isSystem && isAfterMount; 104 }); 105 106 - if (newMessages.length === 0) { 107 - return; 108 - } 109 110 const messagesToAdd: ActiveDanmuMessage[] = []; 111 ··· 125 } 126 127 const duration = baseDuration(message, MAX_DURATION, MIN_DURATION); 128 - if (__DEV__) { 129 console.log("[danmu] message", message.record.text, { 130 duration, 131 speed, 132 }); 133 - } 134 const lane = assignLane(message.uri, duration); 135 136 if (lane !== null) {
··· 81 ); 82 83 useEffect(() => { 84 + if (!enabled || !chat || containerWidth === 0) return; 85 86 // only check new messages since last render (chat is sorted newest first) 87 const newMessageCount = chat.length - lastChatLength.current; 88 + if (newMessageCount <= 0) return; 89 90 const messagesToCheck = chat.slice(0, newMessageCount); 91 lastChatLength.current = chat.length; ··· 99 return !hasProcessed && !isSystem && isAfterMount; 100 }); 101 102 + if (newMessages.length === 0) return; 103 104 const messagesToAdd: ActiveDanmuMessage[] = []; 105 ··· 119 } 120 121 const duration = baseDuration(message, MAX_DURATION, MIN_DURATION); 122 + if (__DEV__) 123 console.log("[danmu] message", message.record.text, { 124 duration, 125 speed, 126 }); 127 const lane = assignLane(message.uri, duration); 128 129 if (lane !== null) {
+1 -3
js/components/src/components/danmu/use-danmu-lanes.ts
··· 34 (d) => d.lane === laneIndex && d.endTime > now, 35 ); 36 37 - if (danmuInLane.length === 0) { 38 - return true; 39 - } 40 41 // check the most recent danmu in this lane 42 const mostRecent = danmuInLane.reduce((latest, current) =>
··· 34 (d) => d.lane === laneIndex && d.endTime > now, 35 ); 36 37 + if (danmuInLane.length === 0) return true; 38 39 // check the most recent danmu in this lane 40 const mostRecent = danmuInLane.reduce((latest, current) =>
+4 -12
js/components/src/components/dashboard/header.tsx
··· 42 43 function StatusIndicator({ status, isLive }: StatusIndicatorProps) { 44 const getStatusColor = () => { 45 - if (!isLive) { 46 - return bg.gray[500]; 47 - } 48 switch (status) { 49 case "excellent": 50 return bg.green[500]; ··· 60 }; 61 62 const getStatusText = () => { 63 - if (!isLive) { 64 - return "OFFLINE"; 65 - } 66 switch (status) { 67 case "excellent": 68 return "EXCELLENT"; ··· 121 onProblemsPress, 122 }: HeaderProps) { 123 const getConnectionQuality = (): "good" | "warning" | "error" => { 124 - if (timeBetweenSegments <= 1500) { 125 - return "good"; 126 - } 127 - if (timeBetweenSegments <= 3000) { 128 - return "warning"; 129 - } 130 return "error"; 131 }; 132
··· 42 43 function StatusIndicator({ status, isLive }: StatusIndicatorProps) { 44 const getStatusColor = () => { 45 + if (!isLive) return bg.gray[500]; 46 switch (status) { 47 case "excellent": 48 return bg.green[500]; ··· 58 }; 59 60 const getStatusText = () => { 61 + if (!isLive) return "OFFLINE"; 62 switch (status) { 63 case "excellent": 64 return "EXCELLENT"; ··· 117 onProblemsPress, 118 }: HeaderProps) { 119 const getConnectionQuality = (): "good" | "warning" | "error" => { 120 + if (timeBetweenSegments <= 1500) return "good"; 121 + if (timeBetweenSegments <= 3000) return "warning"; 122 return "error"; 123 }; 124
+11 -33
js/components/src/components/dashboard/information-widget.tsx
··· 54 const viewers = useViewers(); 55 56 const getBitrate = useCallback((): number => { 57 - if (!seg?.size || !seg?.duration) { 58 - return 0; 59 - } 60 const kbps = 61 (seg.size * 8) / ((seg.duration || 1000000000) / 1000000000) / 1000; 62 return kbps; ··· 94 }, [seg?.startTime, streamStartTime]); 95 96 const getBitrateStatus = (): "good" | "warning" | "error" | "neutral" => { 97 - if (currentBitrate > 2000) { 98 - return "good"; 99 - } 100 - if (currentBitrate > 1000) { 101 - return "warning"; 102 - } 103 - if (currentBitrate > 0) { 104 - return "error"; 105 - } 106 return "neutral"; 107 }; 108 109 const getConnectionStatus = (): "good" | "warning" | "error" | "neutral" => { 110 - if (!seg) { 111 - return "error"; 112 - } 113 - if (currentBitrate > 1500) { 114 - return "good"; 115 - } 116 - if (currentBitrate > 500) { 117 - return "warning"; 118 - } 119 return "error"; 120 }; 121 ··· 454 const maxDataValue = Math.max(...data, 1); 455 const minDataValue = Math.min(...data); 456 const getSmartRange = (max: number) => { 457 - if (max <= 1000) { 458 - return { min: 0, max: 1000, step: 500 }; 459 - } 460 - if (max <= 2000) { 461 - return { min: 1000, max: 2000, step: 1000 }; 462 - } 463 - if (max <= 7000) { 464 - return { min: 4000, max: 7000, step: 1500 }; 465 - } 466 - if (max <= 10000) { 467 - return { min: 4000, max: 10000, step: 5000 }; 468 - } 469 470 const roundedMax = Math.ceil(max / 5000) * 5000; 471 return { min: 0, max: roundedMax, step: roundedMax / 2 };
··· 54 const viewers = useViewers(); 55 56 const getBitrate = useCallback((): number => { 57 + if (!seg?.size || !seg?.duration) return 0; 58 const kbps = 59 (seg.size * 8) / ((seg.duration || 1000000000) / 1000000000) / 1000; 60 return kbps; ··· 92 }, [seg?.startTime, streamStartTime]); 93 94 const getBitrateStatus = (): "good" | "warning" | "error" | "neutral" => { 95 + if (currentBitrate > 2000) return "good"; 96 + if (currentBitrate > 1000) return "warning"; 97 + if (currentBitrate > 0) return "error"; 98 return "neutral"; 99 }; 100 101 const getConnectionStatus = (): "good" | "warning" | "error" | "neutral" => { 102 + if (!seg) return "error"; 103 + if (currentBitrate > 1500) return "good"; 104 + if (currentBitrate > 500) return "warning"; 105 return "error"; 106 }; 107 ··· 440 const maxDataValue = Math.max(...data, 1); 441 const minDataValue = Math.min(...data); 442 const getSmartRange = (max: number) => { 443 + if (max <= 1000) return { min: 0, max: 1000, step: 500 }; 444 + if (max <= 2000) return { min: 1000, max: 2000, step: 1000 }; 445 + if (max <= 7000) return { min: 4000, max: 7000, step: 1500 }; 446 + if (max <= 10000) return { min: 4000, max: 10000, step: 5000 }; 447 448 const roundedMax = Math.ceil(max / 5000) * 5000; 449 return { min: 0, max: roundedMax, step: roundedMax / 2 };
+2 -6
js/components/src/components/dashboard/moderator-panel.tsx
··· 388 // Clear error when user starts typing 389 const handleDIDChange = (text: string) => { 390 setModeratorDID(text); 391 - if (error) { 392 - setError(null); 393 - } 394 }; 395 396 const handleAdd = async () => { ··· 430 <ResponsiveDialog 431 open={visible} 432 onOpenChange={(open) => { 433 - if (!open) { 434 - onClose(); 435 - } 436 }} 437 title="Add Moderator" 438 description="Enter the DID or handle of the user you want to add as a moderator and select their permissions."
··· 388 // Clear error when user starts typing 389 const handleDIDChange = (text: string) => { 390 setModeratorDID(text); 391 + if (error) setError(null); 392 }; 393 394 const handleAdd = async () => { ··· 428 <ResponsiveDialog 429 open={visible} 430 onOpenChange={(open) => { 431 + if (!open) onClose(); 432 }} 433 title="Add Moderator" 434 description="Enter the DID or handle of the user you want to add as a moderator and select their permissions."
+6 -18
js/components/src/components/mobile-player/rotation-lock.tsx
··· 59 60 // Manual rotation functions 61 const rotateToLandscape = async () => { 62 - if (!enabled || !canRotate || !ScreenOrientation) { 63 - return; 64 - } 65 66 try { 67 await ScreenOrientation.unlockAsync(); ··· 82 }; 83 84 const rotateToPortrait = async () => { 85 - if (!enabled || !canRotate || !ScreenOrientation) { 86 - return; 87 - } 88 89 try { 90 await ScreenOrientation.unlockAsync(); ··· 104 }; 105 106 const toggleRotation = async () => { 107 - if (!ScreenOrientation) { 108 - return; 109 - } 110 111 const isLandscape = 112 currentOrientation === ScreenOrientation.Orientation.LANDSCAPE_LEFT || ··· 127 128 // Track orientation changes 129 useEffect(() => { 130 - if (!enabled) { 131 - return; 132 - } 133 134 const getCurrentOrientation = async () => { 135 - if (!ScreenOrientation) { 136 - return; 137 - } 138 try { 139 const orient = await ScreenOrientation.getOrientationAsync(); 140 setCurrentOrientation(orient); ··· 149 150 getCurrentOrientation(); 151 152 - if (!ScreenOrientation) { 153 - return; 154 - } 155 156 const subscription = ScreenOrientation.addOrientationChangeListener( 157 (event) => {
··· 59 60 // Manual rotation functions 61 const rotateToLandscape = async () => { 62 + if (!enabled || !canRotate || !ScreenOrientation) return; 63 64 try { 65 await ScreenOrientation.unlockAsync(); ··· 80 }; 81 82 const rotateToPortrait = async () => { 83 + if (!enabled || !canRotate || !ScreenOrientation) return; 84 85 try { 86 await ScreenOrientation.unlockAsync(); ··· 100 }; 101 102 const toggleRotation = async () => { 103 + if (!ScreenOrientation) return; 104 105 const isLandscape = 106 currentOrientation === ScreenOrientation.Orientation.LANDSCAPE_LEFT || ··· 121 122 // Track orientation changes 123 useEffect(() => { 124 + if (!enabled) return; 125 126 const getCurrentOrientation = async () => { 127 + if (!ScreenOrientation) return; 128 try { 129 const orient = await ScreenOrientation.getOrientationAsync(); 130 setCurrentOrientation(orient); ··· 139 140 getCurrentOrientation(); 141 142 + if (!ScreenOrientation) return; 143 144 const subscription = ScreenOrientation.addOrientationChangeListener( 145 (event) => {
+1 -3
js/components/src/components/mobile-player/ui/autoplay-button.tsx
··· 40 } 41 }; 42 43 - if (!autoplayFailed) { 44 - return null; 45 - } 46 47 return ( 48 <View
··· 40 } 41 }; 42 43 + if (!autoplayFailed) return null; 44 45 return ( 46 <View
+3 -9
js/components/src/components/mobile-player/ui/countdown.tsx
··· 36 }; 37 38 const handleDone = () => { 39 - if (onDone) { 40 - onDone(); 41 - } 42 }; 43 44 // Accurate countdown using useFrameCallback 45 useFrameCallback(({ timestamp }) => { 46 - if (!visible) { 47 - return; 48 - } 49 50 // Set start timestamp on first frame 51 if (startTimestamp.value === null) { ··· 90 opacity: opacity.value, 91 })); 92 93 - if (!visible || countdown === 0) { 94 - return null; 95 - } 96 97 return ( 98 <Animated.View
··· 36 }; 37 38 const handleDone = () => { 39 + if (onDone) onDone(); 40 }; 41 42 // Accurate countdown using useFrameCallback 43 useFrameCallback(({ timestamp }) => { 44 + if (!visible) return; 45 46 // Set start timestamp on first frame 47 if (startTimestamp.value === null) { ··· 86 opacity: opacity.value, 87 })); 88 89 + if (!visible || countdown === 0) return null; 90 91 return ( 92 <Animated.View
+1 -3
js/components/src/components/mobile-player/ui/report-modal.tsx
··· 84 }; 85 86 const handleSubmit = async () => { 87 - if (!selectedReason) { 88 - return; 89 - } 90 91 setIsSubmitting(true); 92 setSubmitError(null);
··· 84 }; 85 86 const handleSubmit = async () => { 87 + if (!selectedReason) return; 88 89 setIsSubmitting(true); 90 setSubmitError(null);
+2 -6
js/components/src/components/mobile-player/ui/streamer-loading-overlay.tsx
··· 80 81 // Trigger animation on each message change 82 useEffect(() => { 83 - if (!visible) { 84 - return; 85 - } 86 87 const fadeDuration = Math.min(interval / 2, 250); // Simplified fade duration 88 ··· 117 opacity: wholeOpacity.value, 118 })); 119 120 - if (!shouldRender) { 121 - return null; 122 - } 123 124 return ( 125 <Animated.View
··· 80 81 // Trigger animation on each message change 82 useEffect(() => { 83 + if (!visible) return; 84 85 const fadeDuration = Math.min(interval / 2, 250); // Simplified fade duration 86 ··· 115 opacity: wholeOpacity.value, 116 })); 117 118 + if (!shouldRender) return null; 119 120 return ( 121 <Animated.View
+31 -5
js/components/src/components/mobile-player/ui/viewer-context-menu.tsx
··· 58 const setReportModalOpen = usePlayerStore((x) => x.setReportModalOpen); 59 const setReportSubject = usePlayerStore((x) => x.setReportSubject); 60 61 const { profile } = useLivestreamInfo(); 62 63 const avatars = useAvatars(profile?.did ? [profile?.did] : []); ··· 215 > 216 <Text>Quality</Text> 217 <Text muted size={isMobile ? "base" : "sm"}> 218 - {quality === "source" ? "Source" : quality},{" "} 219 {lowLatency ? "Low Latency" : ""} 220 </Text> 221 </View> ··· 227 onValueChange={setQuality} 228 > 229 <DropdownMenuRadioItem value="source"> 230 - <Text>Source (Original Quality)</Text> 231 </DropdownMenuRadioItem> 232 {qualities.map((r) => ( 233 <DropdownMenuRadioItem key={r.name} value={r.name}> ··· 294 return ( 295 <DropdownMenuItem 296 onPress={() => { 297 - if (!livestream) { 298 - return; 299 - } 300 onOpenChange?.(false); 301 setReportModalOpen(true); 302 setReportSubject({
··· 58 const setReportModalOpen = usePlayerStore((x) => x.setReportModalOpen); 59 const setReportSubject = usePlayerStore((x) => x.setReportSubject); 60 61 + const latestSegment = useLivestreamStore((x) => x.segment); 62 + // get highest height x width rendition for video 63 + const videoRendition = latestSegment?.video?.reduce((prev, current) => { 64 + const prevPixels = prev.width * prev.height; 65 + const currentPixels = current.width * current.height; 66 + return currentPixels > prevPixels ? current : prev; 67 + }, latestSegment?.video?.[0]); 68 + const highestLength = videoRendition 69 + ? videoRendition.height < videoRendition.width 70 + ? videoRendition.height 71 + : videoRendition?.width 72 + : 0; 73 + 74 + // ugh i hate this 75 + const frames = videoRendition?.framerate as 76 + | { num: number; den: number } 77 + | undefined; 78 + const fps = 79 + frames?.num && frames?.den 80 + ? Math.round((frames.num / frames.den) * 100) / 100 81 + : 0; 82 + 83 + const resolutionDisplay = highestLength 84 + ? `(${highestLength}p${fps > 0 ? fps : ""})` 85 + : "(Original Quality)"; 86 + 87 const { profile } = useLivestreamInfo(); 88 89 const avatars = useAvatars(profile?.did ? [profile?.did] : []); ··· 241 > 242 <Text>Quality</Text> 243 <Text muted size={isMobile ? "base" : "sm"}> 244 + {quality === "source" 245 + ? `Source${resolutionDisplay ? " " + resolutionDisplay + "\n" : ", "}` 246 + : quality} 247 {lowLatency ? "Low Latency" : ""} 248 </Text> 249 </View> ··· 255 onValueChange={setQuality} 256 > 257 <DropdownMenuRadioItem value="source"> 258 + <Text>Source {resolutionDisplay}</Text> 259 </DropdownMenuRadioItem> 260 {qualities.map((r) => ( 261 <DropdownMenuRadioItem key={r.name} value={r.name}> ··· 322 return ( 323 <DropdownMenuItem 324 onPress={() => { 325 + if (!livestream) return; 326 onOpenChange?.(false); 327 setReportModalOpen(true); 328 setReportSubject({
+5 -14
js/components/src/components/mobile-player/video.tsx
··· 29 | undefined, 30 instance: HTMLVideoElement | null, 31 ) { 32 - if (!ref) { 33 - return; 34 - } 35 - if (typeof ref === "function") { 36 - ref(instance); 37 - } else { 38 - ref.current = instance; 39 - } 40 } 41 42 type VideoProps = { ··· 50 const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); 51 52 useEffect(() => { 53 - if (!videoRef.current) { 54 - return; 55 - } 56 57 function updateSize() { 58 setDimensions({ ··· 393 } 394 }, [diagnostics]); 395 396 - if (!diagnostics.done) { 397 - return <></>; 398 - } 399 400 if (webrtcError) { 401 setProtocol(PlayerProtocol.HLS);
··· 29 | undefined, 30 instance: HTMLVideoElement | null, 31 ) { 32 + if (!ref) return; 33 + if (typeof ref === "function") ref(instance); 34 + else ref.current = instance; 35 } 36 37 type VideoProps = { ··· 45 const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); 46 47 useEffect(() => { 48 + if (!videoRef.current) return; 49 50 function updateSize() { 51 setDimensions({ ··· 386 } 387 }, [diagnostics]); 388 389 + if (!diagnostics.done) return <></>; 390 391 if (webrtcError) { 392 setProtocol(PlayerProtocol.HLS);
+1 -3
js/components/src/components/stream-notification/teleport-notification.tsx
··· 89 }, []); 90 91 useEffect(() => { 92 - if (showStripes) { 93 - return; 94 - } 95 96 // animate progress bar 97 const percentage = (timeLeft / countdown) * 100;
··· 89 }, []); 90 91 useEffect(() => { 92 + if (showStripes) return; 93 94 // animate progress bar 95 const percentage = (timeLeft / countdown) * 100;
+1 -3
js/components/src/components/ui/admonition.tsx
··· 26 const { theme, icons } = useTheme(); 27 28 const defaultIconLeft = (() => { 29 - if (iconLeft) { 30 - return iconLeft; 31 - } 32 switch (variant) { 33 case "success": 34 return CheckCircle;
··· 26 const { theme, icons } = useTheme(); 27 28 const defaultIconLeft = (() => { 29 + if (iconLeft) return iconLeft; 30 switch (variant) { 31 case "success": 32 return CheckCircle;
+3 -9
js/components/src/components/ui/dialog.tsx
··· 350 const { theme } = useTheme(); 351 const styles = React.useMemo(() => createStyles(theme), [theme]); 352 353 - if (!children) { 354 - return null; 355 - } 356 357 return ( 358 <Text ref={ref} style={[styles.title, style]} {...props}> ··· 375 const { theme } = useTheme(); 376 const styles = React.useMemo(() => createStyles(theme), [theme]); 377 378 - if (!children) { 379 - return null; 380 - } 381 382 return ( 383 <Text ref={ref} style={[styles.description, style]} {...props}> ··· 418 const { theme } = useTheme(); 419 const styles = React.useMemo(() => createStyles(theme), [theme]); 420 421 - if (!children) { 422 - return null; 423 - } 424 425 return ( 426 <ModalPrimitive.Footer
··· 350 const { theme } = useTheme(); 351 const styles = React.useMemo(() => createStyles(theme), [theme]); 352 353 + if (!children) return null; 354 355 return ( 356 <Text ref={ref} style={[styles.title, style]} {...props}> ··· 373 const { theme } = useTheme(); 374 const styles = React.useMemo(() => createStyles(theme), [theme]); 375 376 + if (!children) return null; 377 378 return ( 379 <Text ref={ref} style={[styles.description, style]} {...props}> ··· 414 const { theme } = useTheme(); 415 const styles = React.useMemo(() => createStyles(theme), [theme]); 416 417 + if (!children) return null; 418 419 return ( 420 <ModalPrimitive.Footer
+3 -8
js/components/src/components/ui/dropdown.native.tsx
··· 190 191 const push = (item: NavigationStackItem) => { 192 setStack((prev) => { 193 - if (!Array.isArray(prev)) { 194 return [{ key: "root", content: children }, item]; 195 - } 196 return [...prev, item]; 197 }); 198 ··· 221 }; 222 223 const pop = () => { 224 - if (stack.length <= 1) { 225 - return; 226 - } 227 228 slideAnim.value = withTiming(40, { duration: 150 }); 229 fadeAnim.value = withTiming(0, { duration: 150 }, (finished) => { ··· 247 const isNested = stack.length > 1; 248 249 const onBackgroundTap = () => { 250 - if (sheetRef.current) { 251 - sheetRef.current?.close(); 252 - } 253 254 setTimeout(() => { 255 onOpenChange?.(false);
··· 190 191 const push = (item: NavigationStackItem) => { 192 setStack((prev) => { 193 + if (!Array.isArray(prev)) 194 return [{ key: "root", content: children }, item]; 195 return [...prev, item]; 196 }); 197 ··· 220 }; 221 222 const pop = () => { 223 + if (stack.length <= 1) return; 224 225 slideAnim.value = withTiming(40, { duration: 150 }); 226 fadeAnim.value = withTiming(0, { duration: 150 }, (finished) => { ··· 244 const isNested = stack.length > 1; 245 246 const onBackgroundTap = () => { 247 + if (sheetRef.current) sheetRef.current?.close(); 248 249 setTimeout(() => { 250 onOpenChange?.(false);
+1 -3
js/components/src/components/ui/primitives/button.tsx
··· 198 199 export const ButtonLoading = forwardRef<View, ButtonLoadingProps>( 200 ({ children, visible = false, style, ...props }, ref) => { 201 - if (!visible) { 202 - return null; 203 - } 204 205 return ( 206 <View ref={ref} style={[primitiveStyles.loading, style]} {...props}>
··· 198 199 export const ButtonLoading = forwardRef<View, ButtonLoadingProps>( 200 ({ children, visible = false, style, ...props }, ref) => { 201 + if (!visible) return null; 202 203 return ( 204 <View ref={ref} style={[primitiveStyles.loading, style]} {...props}>
+1 -3
js/components/src/components/ui/primitives/input.tsx
··· 230 231 export const InputError = forwardRef<Text, InputErrorProps>( 232 ({ children, visible = true, style, ...props }, ref) => { 233 - if (!visible || !children) { 234 - return null; 235 - } 236 237 return ( 238 <Text ref={ref} style={[primitiveStyles.error, style]} {...props}>
··· 230 231 export const InputError = forwardRef<Text, InputErrorProps>( 232 ({ children, visible = true, style, ...props }, ref) => { 233 + if (!visible || !children) return null; 234 235 return ( 236 <Text ref={ref} style={[primitiveStyles.error, style]} {...props}>
+2 -6
js/components/src/components/ui/resizeable.tsx
··· 67 }) 68 .onUpdate((event) => { 69 let newHeight = startHeight.value - event.translationY; 70 - if (newHeight > MAX_HEIGHT) { 71 - newHeight = MAX_HEIGHT; 72 - } 73 - if (newHeight < MIN_HEIGHT) { 74 - newHeight = MIN_HEIGHT; 75 - } 76 sheetHeight.value = newHeight; 77 78 const nowCollapsed = newHeight < COLLAPSE_HEIGHT;
··· 67 }) 68 .onUpdate((event) => { 69 let newHeight = startHeight.value - event.translationY; 70 + if (newHeight > MAX_HEIGHT) newHeight = MAX_HEIGHT; 71 + if (newHeight < MIN_HEIGHT) newHeight = MIN_HEIGHT; 72 sheetHeight.value = newHeight; 73 74 const nowCollapsed = newHeight < COLLAPSE_HEIGHT;
+1 -3
js/components/src/components/ui/toast.tsx
··· 400 key={toastState.id} 401 {...toastState} 402 onOpenChange={(open) => { 403 - if (!open) { 404 - toastManager.hide(toastState.id); 405 - } 406 }} 407 index={index} 408 isLatest={index === 0}
··· 400 key={toastState.id} 401 {...toastState} 402 onOpenChange={(open) => { 403 + if (!open) toastManager.hide(toastState.id); 404 }} 405 index={index} 406 isLatest={index === 0}
+1 -3
js/components/src/hooks/useAvatars.tsx
··· 18 ); 19 20 useEffect(() => { 21 - if (missingDids.length === 0 || !agent) { 22 - return; 23 - } 24 const toFetch = missingDids.slice(0, 25); 25 toFetch.forEach((did) => inFlight.current.add(did)); 26
··· 18 ); 19 20 useEffect(() => { 21 + if (missingDids.length === 0 || !agent) return; 22 const toFetch = missingDids.slice(0, 25); 23 toFetch.forEach((did) => inFlight.current.add(did)); 24
+1 -3
js/components/src/hooks/useLivestreamInfo.ts
··· 41 ) => { 42 if (!ingestStarting) { 43 // Optionally close keyboard if provided 44 - if (closeKeyboard) { 45 - closeKeyboard(); 46 - } 47 setShowCountdown(true); 48 setIngestStarting(true); 49 setIngestLive(true);
··· 41 ) => { 42 if (!ingestStarting) { 43 // Optionally close keyboard if provided 44 + if (closeKeyboard) closeKeyboard(); 45 setShowCountdown(true); 46 setIngestStarting(true); 47 setIngestLive(true);
+1 -3
js/components/src/hooks/useSegmentTiming.tsx
··· 8 range: number | null, 9 numOfSegments: number = 1, 10 ): ConnectionQuality { 11 - if (timeBetweenSegments === null || range === null) { 12 - return "poor"; 13 - } 14 15 if (timeBetweenSegments <= 1500 && range <= (1500 * 60) / numOfSegments) { 16 return "good";
··· 8 range: number | null, 9 numOfSegments: number = 1, 10 ): ConnectionQuality { 11 + if (timeBetweenSegments === null || range === null) return "poor"; 12 13 if (timeBetweenSegments <= 1500 && range <= (1500 * 60) / numOfSegments) { 14 return "good";
+1 -3
js/components/src/i18n/i18next-config.ts
··· 94 function getFallbackChain(code: string): string[] { 95 const fallbacks: string[] = []; 96 97 - if (!code) { 98 - return manifest.fallbackChain; 99 - } 100 101 // Regional fallbacks 102 if (code.match(/^es-/)) {
··· 94 function getFallbackChain(code: string): string[] { 95 const fallbacks: string[] = []; 96 97 + if (!code) return manifest.fallbackChain; 98 99 // Regional fallbacks 100 if (code.match(/^es-/)) {
+1 -3
js/components/src/lib/system-messages.ts
··· 162 const type = getSystemMessageType(message); 163 const text = message.record.text; 164 165 - if (!type) { 166 - return metadata; 167 - } 168 169 switch (type) { 170 case "stream_end": {
··· 162 const type = getSystemMessageType(message); 163 const text = message.record.text; 164 165 + if (!type) return metadata; 166 167 switch (type) { 168 case "stream_end": {
+7 -21
js/components/src/lib/theme/theme.tsx
··· 397 398 // Determine if dark mode should be active 399 const isDark = useMemo(() => { 400 - if (forcedTheme === "light") { 401 - return false; 402 - } 403 - if (forcedTheme === "dark") { 404 - return true; 405 - } 406 - if (currentTheme === "light") { 407 - return false; 408 - } 409 - if (currentTheme === "dark") { 410 - return true; 411 - } 412 - if (currentTheme === "system") { 413 - return systemColorScheme === "dark"; 414 - } 415 return systemColorScheme === "dark"; 416 }, [forcedTheme, currentTheme, systemColorScheme]); 417 ··· 454 const toggleTheme = () => { 455 if (!forcedTheme) { 456 setCurrentTheme((prev) => { 457 - if (prev === "light") { 458 - return "dark"; 459 - } 460 - if (prev === "dark") { 461 - return "system"; 462 - } 463 return "light"; 464 }); 465 }
··· 397 398 // Determine if dark mode should be active 399 const isDark = useMemo(() => { 400 + if (forcedTheme === "light") return false; 401 + if (forcedTheme === "dark") return true; 402 + if (currentTheme === "light") return false; 403 + if (currentTheme === "dark") return true; 404 + if (currentTheme === "system") return systemColorScheme === "dark"; 405 return systemColorScheme === "dark"; 406 }, [forcedTheme, currentTheme, systemColorScheme]); 407 ··· 444 const toggleTheme = () => { 445 if (!forcedTheme) { 446 setCurrentTheme((prev) => { 447 + if (prev === "light") return "dark"; 448 + if (prev === "dark") return "system"; 449 return "light"; 450 }); 451 }
+8 -24
js/components/src/lib/utils.ts
··· 48 }, 49 screenWidth: number, 50 ): T { 51 - if (screenWidth >= 1280 && values.xl !== undefined) { 52 - return values.xl; 53 - } 54 - if (screenWidth >= 1024 && values.lg !== undefined) { 55 - return values.lg; 56 - } 57 - if (screenWidth >= 768 && values.md !== undefined) { 58 - return values.md; 59 - } 60 - if (screenWidth >= 640 && values.sm !== undefined) { 61 - return values.sm; 62 - } 63 return values.default; 64 } 65 ··· 74 }): Style { 75 const Platform = require("react-native").Platform; 76 77 - if (Platform.OS === "ios" && styles.ios) { 78 - return styles.ios; 79 - } 80 - if (Platform.OS === "android" && styles.android) { 81 - return styles.android; 82 - } 83 - if (Platform.OS === "web" && styles.web) { 84 - return styles.web; 85 - } 86 return styles.default || {}; 87 } 88 ··· 91 */ 92 export function hexToRgba(hex: string, alpha: number = 1): string { 93 const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); 94 - if (!result) { 95 - return hex; 96 - } 97 98 const r = parseInt(result[1], 16); 99 const g = parseInt(result[2], 16);
··· 48 }, 49 screenWidth: number, 50 ): T { 51 + if (screenWidth >= 1280 && values.xl !== undefined) return values.xl; 52 + if (screenWidth >= 1024 && values.lg !== undefined) return values.lg; 53 + if (screenWidth >= 768 && values.md !== undefined) return values.md; 54 + if (screenWidth >= 640 && values.sm !== undefined) return values.sm; 55 return values.default; 56 } 57 ··· 66 }): Style { 67 const Platform = require("react-native").Platform; 68 69 + if (Platform.OS === "ios" && styles.ios) return styles.ios; 70 + if (Platform.OS === "android" && styles.android) return styles.android; 71 + if (Platform.OS === "web" && styles.web) return styles.web; 72 return styles.default || {}; 73 } 74 ··· 77 */ 78 export function hexToRgba(hex: string, alpha: number = 1): string { 79 const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); 80 + if (!result) return hex; 81 82 const r = parseInt(result[1], 16); 83 const g = parseInt(result[2], 16);
+1 -3
js/components/src/livestream-provider/index.tsx
··· 62 const prevActiveTeleportRef = useRef(activeTeleport); 63 64 useEffect(() => { 65 - if (!activeTeleport || !profile[activeTeleport.streamer]) { 66 - return; 67 - } 68 69 const startsAt = new Date(activeTeleport.startsAt); 70 const now = new Date();
··· 62 const prevActiveTeleportRef = useRef(activeTeleport); 63 64 useEffect(() => { 65 + if (!activeTeleport || !profile[activeTeleport.streamer]) return; 66 67 const startsAt = new Date(activeTeleport.startsAt); 68 const now = new Date();
+1 -3
js/components/src/livestream-store/stream-key.tsx
··· 25 const [error, setError] = useState<string | null>(null); 26 27 useEffect(() => { 28 - if (key) { 29 - return; 30 - } // already have key 31 32 const generateKey = async () => { 33 if (!pdsAgent) {
··· 25 const [error, setError] = useState<string | null>(null); 26 27 useEffect(() => { 28 + if (key) return; // already have key 29 30 const generateKey = async () => { 31 if (!pdsAgent) {
+4 -12
js/components/src/streamplace-store/branding.tsx
··· 38 ]; 39 40 function getMetaContent(key: string): BrandingAsset | null { 41 - if (typeof window === "undefined" || !window.document) { 42 - return null; 43 - } 44 const meta = document.querySelector(`meta[name="internal-brand:${key}`); 45 if (meta && meta.getAttribute("content")) { 46 let content = meta.getAttribute("content"); 47 - if (content) { 48 - return JSON.parse(content) as BrandingAsset; 49 - } 50 } 51 52 return null; ··· 69 // hrmmmmmmmmmmmm 70 if (meta && meta.getAttribute("content")) { 71 let content = meta.getAttribute("content"); 72 - if (content) { 73 - acc[key] = JSON.parse(content) as BrandingAsset; 74 - } 75 } 76 return acc; 77 }, ··· 120 121 return useCallback( 122 async ({ force = true } = {}) => { 123 - if (!broadcasterDID) { 124 - return; 125 - } 126 127 try { 128 store.setState({ brandingLoading: true });
··· 38 ]; 39 40 function getMetaContent(key: string): BrandingAsset | null { 41 + if (typeof window === "undefined" || !window.document) return null; 42 const meta = document.querySelector(`meta[name="internal-brand:${key}`); 43 if (meta && meta.getAttribute("content")) { 44 let content = meta.getAttribute("content"); 45 + if (content) return JSON.parse(content) as BrandingAsset; 46 } 47 48 return null; ··· 65 // hrmmmmmmmmmmmm 66 if (meta && meta.getAttribute("content")) { 67 let content = meta.getAttribute("content"); 68 + if (content) acc[key] = JSON.parse(content) as BrandingAsset; 69 } 70 return acc; 71 }, ··· 114 115 return useCallback( 116 async ({ force = true } = {}) => { 117 + if (!broadcasterDID) return; 118 119 try { 120 store.setState({ brandingLoading: true });
+1 -3
js/components/src/streamplace-store/stream.tsx
··· 26 pdsAgent: StreamplaceAgent, 27 customThumbnail?: Blob, 28 ) => { 29 - if (!customThumbnail) { 30 - return undefined; 31 - } 32 33 abortRef.current = new AbortController(); 34 const { signal } = abortRef.current;
··· 26 pdsAgent: StreamplaceAgent, 27 customThumbnail?: Blob, 28 ) => { 29 + if (!customThumbnail) return undefined; 30 31 abortRef.current = new AbortController(); 32 const { signal } = abortRef.current;
+1 -3
js/components/src/streamplace-store/xrpc.tsx
··· 10 // - SessionManager when logged in 11 return useMemo(() => { 12 if (!oauthSession) { 13 - if (oauthSession === undefined) { 14 - return null; 15 - } 16 // TODO: change once we allow unauthed requests + profile indexing 17 // it's bluesky's AppView b/c otherwise we'd have goosewithpipe.jpg 18 // showing up everywhere
··· 10 // - SessionManager when logged in 11 return useMemo(() => { 12 if (!oauthSession) { 13 + if (oauthSession === undefined) return null; 14 // TODO: change once we allow unauthed requests + profile indexing 15 // it's bluesky's AppView b/c otherwise we'd have goosewithpipe.jpg 16 // showing up everywhere
+1 -3
js/desktop/src/updater.ts
··· 110 }; 111 112 dialog.showMessageBox(dialogOpts).then(({ response }) => { 113 - if (response === 0) { 114 - autoUpdater.quitAndInstall(); 115 - } 116 }); 117 }, 118 );
··· 110 }; 111 112 dialog.showMessageBox(dialogOpts).then(({ response }) => { 113 + if (response === 0) autoUpdater.quitAndInstall(); 114 }); 115 }, 116 );
-1
package.json
··· 34 "lerna": "^8.2.2", 35 "lint-staged": "^15.2.10", 36 "prettier": "3.4.2", 37 - "prettier-plugin-curly": "^0.4.1", 38 "typescript": "^5.8.3" 39 }, 40 "workspaces": [
··· 34 "lerna": "^8.2.2", 35 "lint-staged": "^15.2.10", 36 "prettier": "3.4.2", 37 "typescript": "^5.8.3" 38 }, 39 "workspaces": [
+1 -15
pnpm-lock.yaml
··· 42 prettier: 43 specifier: 3.4.2 44 version: 3.4.2 45 - prettier-plugin-curly: 46 - specifier: ^0.4.1 47 - version: 0.4.1(prettier@3.4.2) 48 typescript: 49 specifier: ^5.8.3 50 version: 5.8.3 ··· 7822 glob@8.1.0: 7823 resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} 7824 engines: {node: '>=12'} 7825 - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me 7826 7827 glob@9.3.5: 7828 resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} 7829 engines: {node: '>=16 || 14 >=14.17'} 7830 - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me 7831 7832 global-agent@3.0.0: 7833 resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} ··· 10645 prelude-ls@1.2.1: 10646 resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} 10647 engines: {node: '>= 0.8.0'} 10648 - 10649 - prettier-plugin-curly@0.4.1: 10650 - resolution: {integrity: sha512-Xc7zatoD0/08zYFv+hwnlqT5ekM81DCbBr73CWAsr1Fmx7qLQT/M0wfPx6w/+zfnmXH009xYvjzLUPcwzq7Fbw==} 10651 - engines: {node: '>=18'} 10652 - peerDependencies: 10653 - prettier: ^3 10654 10655 prettier-plugin-organize-imports@4.1.0: 10656 resolution: {integrity: sha512-5aWRdCgv645xaa58X8lOxzZoiHAldAPChljr/MT0crXVOWTZ+Svl4hIWlz+niYSlO6ikE5UXkN1JrRvIP2ut0A==} ··· 26943 tunnel-agent: 0.6.0 26944 26945 prelude-ls@1.2.1: {} 26946 - 26947 - prettier-plugin-curly@0.4.1(prettier@3.4.2): 26948 - dependencies: 26949 - prettier: 3.4.2 26950 26951 prettier-plugin-organize-imports@4.1.0(prettier@3.4.2)(typescript@5.8.3): 26952 dependencies:
··· 42 prettier: 43 specifier: 3.4.2 44 version: 3.4.2 45 typescript: 46 specifier: ^5.8.3 47 version: 5.8.3 ··· 7819 glob@8.1.0: 7820 resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} 7821 engines: {node: '>=12'} 7822 + deprecated: Glob versions prior to v9 are no longer supported 7823 7824 glob@9.3.5: 7825 resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} 7826 engines: {node: '>=16 || 14 >=14.17'} 7827 7828 global-agent@3.0.0: 7829 resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} ··· 10641 prelude-ls@1.2.1: 10642 resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} 10643 engines: {node: '>= 0.8.0'} 10644 10645 prettier-plugin-organize-imports@4.1.0: 10646 resolution: {integrity: sha512-5aWRdCgv645xaa58X8lOxzZoiHAldAPChljr/MT0crXVOWTZ+Svl4hIWlz+niYSlO6ikE5UXkN1JrRvIP2ut0A==} ··· 26933 tunnel-agent: 0.6.0 26934 26935 prelude-ls@1.2.1: {} 26936 26937 prettier-plugin-organize-imports@4.1.0(prettier@3.4.2)(typescript@5.8.3): 26938 dependencies: