Live video on the AT Protocol

Compare changes

Choose any two refs to compare.

+1466 -591
+1 -1
.prettierrc
··· 14 14 } 15 15 } 16 16 ], 17 - "plugins": ["prettier-plugin-organize-imports", "prettier-plugin-curly"] 17 + "plugins": ["prettier-plugin-organize-imports"] 18 18 }
+5 -15
js/app/components/container.tsx
··· 18 18 } 19 19 20 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 - } 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; 36 26 return maxContainerWidths.sm; 37 27 } 38 28
+5 -11
js/app/components/debug/breakpoint-indicator.tsx
··· 6 6 7 7 // Simple breakpoint detection based on width 8 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 - } 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"; 20 14 21 15 return ( 22 16 <View
+1 -3
js/app/components/emoji-picker/emoji-picker.tsx
··· 12 12 } 13 13 14 14 export function EmojiPicker({ isOpen, onClose }: EmojiPickerProps) { 15 - if (!isOpen) { 16 - return null; 17 - } 15 + if (!isOpen) return null; 18 16 19 17 const isWeb = Platform.OS === "web"; 20 18
+4 -12
js/app/components/follow-button.tsx
··· 32 32 const unfollowUser = useStore((state) => state.unfollowUser); 33 33 34 34 // Hide button if not logged in or viewing own stream 35 - if (!currentUserDID || currentUserDID === streamerDID) { 36 - return null; 37 - } 35 + if (!currentUserDID || currentUserDID === streamerDID) return null; 38 36 39 37 // Fetch initial follow state using xrpc 40 38 useEffect(() => { 41 39 let cancelled = false; 42 40 43 41 const fetchFollowStatus = async () => { 44 - if (!currentUserDID || !streamerDID) { 45 - return; 46 - } 42 + if (!currentUserDID || !streamerDID) return; 47 43 48 44 setError(null); 49 45 try { ··· 60 56 } 61 57 62 58 const data = await res.json(); 63 - if (cancelled) { 64 - return; 65 - } 59 + if (cancelled) return; 66 60 67 61 if (data.follow) { 68 62 setIsFollowing(true); ··· 72 66 setFollowUri(null); 73 67 } 74 68 } catch (err) { 75 - if (!cancelled) { 76 - setError("Could not determine follow state"); 77 - } 69 + if (!cancelled) setError("Could not determine follow state"); 78 70 } 79 71 }; 80 72
+3 -9
js/app/components/live-dashboard/bento-grid.tsx
··· 87 87 88 88 // Calculate uptime 89 89 const getUptime = useCallback((): string => { 90 - if (!ingestStarted || !isLive) { 91 - return "00:00:00"; 92 - } 90 + if (!ingestStarted || !isLive) return "00:00:00"; 93 91 const uptimeMs = Date.now() - ingestStarted; 94 92 const seconds = Math.floor(uptimeMs / 1000); 95 93 const hours = Math.floor(seconds / 3600); ··· 100 98 101 99 // Calculate bitrate 102 100 const getBitrate = useCallback((): string => { 103 - if (!seg?.size || !seg?.duration) { 104 - return "0 kbps"; 105 - } 101 + if (!seg?.size || !seg?.duration) return "0 kbps"; 106 102 const kbps = 107 103 (seg.size * 8) / 108 104 ((seg.duration || 1000000000) / 1000000000) / ··· 117 113 | "good" 118 114 | "poor" 119 115 | "offline" => { 120 - if (!isLive) { 121 - return "offline"; 122 - } 116 + if (!isLive) return "offline"; 123 117 switch (segmentTiming.connectionQuality) { 124 118 case "good": 125 119 return "excellent";
+4 -12
js/app/components/live-dashboard/livestream-panel.tsx
··· 96 96 onGoToMetadata?: () => void; 97 97 }) => { 98 98 const imageUrl = useMemo(() => { 99 - if (!selectedImage) { 100 - return undefined; 101 - } 99 + if (!selectedImage) return undefined; 102 100 if (selectedImage instanceof File || selectedImage instanceof Blob) { 103 101 return URL.createObjectURL(selectedImage); 104 102 } ··· 270 268 ); 271 269 272 270 const handleSubmit = useCallback(async () => { 273 - if (!title.trim()) { 274 - return; 275 - } 271 + if (!title.trim()) return; 276 272 277 273 setLoading(true); 278 274 ··· 372 368 }, []); 373 369 374 370 const handleUseLastImage = useCallback(async () => { 375 - if (!livestream?.record.thumb) { 376 - return; 377 - } 371 + if (!livestream?.record.thumb) return; 378 372 379 373 try { 380 374 const did = livestream.uri.split("/")[2]; ··· 397 391 ); 398 392 399 393 const buttonText = useMemo(() => { 400 - if (loading) { 401 - return "Loading..."; 402 - } 394 + if (loading) return "Loading..."; 403 395 if (!userIsLive) { 404 396 return mode === "create" 405 397 ? "Waiting for stream to start..."
+5 -15
js/app/components/live-dashboard/multistream-status.tsx
··· 47 47 }, [targets, opacity]); 48 48 49 49 const loadTargets = useCallback(async () => { 50 - if (!agent) { 51 - return; 52 - } 50 + if (!agent) return; 53 51 54 52 try { 55 53 setLoading(true); ··· 67 65 68 66 const toggleTarget = useCallback( 69 67 async (target: MultistreamTargetViewHydrated, newActiveState: boolean) => { 70 - if (!agent) { 71 - return; 72 - } 68 + if (!agent) return; 73 69 try { 74 70 setTogglingTargets((prev) => new Set(prev).add(target.uri)); 75 71 await agent.place.stream.multistream.putTarget({ ··· 101 97 const inactiveTargets = targets.filter((t) => !t.record.active); 102 98 103 99 const getTargetName = (target: MultistreamTargetViewHydrated) => { 104 - if (target.record.name) { 105 - return target.record.name; 106 - } 100 + if (target.record.name) return target.record.name; 107 101 if (target.record.url) { 108 102 try { 109 103 const u = new URL(target.record.url); ··· 116 110 }; 117 111 118 112 const getTargetHostname = (target: MultistreamTargetViewHydrated) => { 119 - if (!target.record.url) { 120 - return null; 121 - } 113 + if (!target.record.url) return null; 122 114 try { 123 115 const u = new URL(target.record.url); 124 116 return u.host.split(":")[0]; ··· 128 120 }; 129 121 130 122 const getStatusColor = (target: MultistreamTargetViewHydrated) => { 131 - if (!target.record.active) { 132 - return text.gray[500]; 133 - } 123 + if (!target.record.active) return text.gray[500]; 134 124 135 125 switch (target.latestEvent?.status) { 136 126 case "active":
+2 -6
js/app/components/live-dashboard/stream-monitor.tsx
··· 60 60 61 61 // Connection quality indicator 62 62 const getConnectionIcon = () => { 63 - if (!isLive) { 64 - return null; 65 - } 63 + if (!isLive) return null; 66 64 67 65 switch (segmentTiming.connectionQuality) { 68 66 case "good": ··· 77 75 }; 78 76 79 77 const getConnectionColor = () => { 80 - if (!isLive) { 81 - return "red"; 82 - } 78 + if (!isLive) return "red"; 83 79 84 80 switch (segmentTiming.connectionQuality) { 85 81 case "good":
+1 -3
js/app/components/login/login-form.tsx
··· 69 69 return; 70 70 } 71 71 let clean = handle; 72 - if (handle.startsWith("@")) { 73 - clean = handle.slice(1); 74 - } 72 + if (handle.startsWith("@")) clean = handle.slice(1); 75 73 loginAction(clean, openLoginLink); 76 74 }; 77 75
+1
js/app/components/login/login.tsx
··· 27 27 28 28 // check for stored return route on mount 29 29 useEffect(() => { 30 + if (Platform.OS !== "web") return; 30 31 storage.getItem("returnRoute").then((stored) => { 31 32 if (stored) { 32 33 try {
+1 -3
js/app/components/login/pds-host-selector-modal.tsx
··· 110 110 111 111 const handleSubmit = () => { 112 112 const hostToUse = useCustom ? customHost : selectedHost; 113 - if (!hostToUse) { 114 - return; 115 - } 113 + if (!hostToUse) return; 116 114 117 115 onSubmit(hostToUse); 118 116 handleCancel();
+2 -6
js/app/components/mobile/desktop-ui/kebab.tsx
··· 131 131 return ( 132 132 <DropdownMenuItem 133 133 onPress={() => { 134 - if (!livestream) { 135 - return; 136 - } 134 + if (!livestream) return; 137 135 onOpenChange?.(false); 138 136 setReportModalOpen(true); 139 137 setReportSubject({ ··· 163 161 return ( 164 162 <DropdownMenuItem 165 163 onPress={() => { 166 - if (!profile?.did) { 167 - return; 168 - } 164 + if (!profile?.did) return; 169 165 onOpenChange?.(false); 170 166 setReportModalOpen(true); 171 167 setReportSubject({
+1 -2
js/app/components/mobile/desktop-ui/live-bubble.tsx
··· 15 15 return segDate && Date.now() - segDate.getTime() <= 10 * 1000; 16 16 }, [segDate]); 17 17 18 - if (!isLive) { 18 + if (!isLive) 19 19 return ( 20 20 <View style={[{ flexDirection: "row" }]}> 21 21 <View ··· 43 43 </View> 44 44 </View> 45 45 ); 46 - } 47 46 48 47 return ( 49 48 <View style={[{ flexDirection: "row" }]}>
+1 -3
js/app/components/mobile/desktop-ui/mute-overlay.tsx
··· 15 15 const setMuted = useSetMuted(); 16 16 const setMuteWasForced = usePlayerStore((state) => state.setMuteWasForced); 17 17 18 - if (!muteWasForced) { 19 - return null; 20 - } 18 + if (!muteWasForced) return null; 21 19 22 20 return ( 23 21 <View
+6 -18
js/app/components/mobile/desktop-ui.tsx
··· 90 90 91 91 const resetFadeTimer = useCallback(() => { 92 92 fadeOpacity.value = withTiming(1, { duration: 200 }); 93 - if (fadeTimeout.current) { 94 - clearTimeout(fadeTimeout.current); 95 - } 93 + if (fadeTimeout.current) clearTimeout(fadeTimeout.current); 96 94 setIsControlsVisible(true); 97 95 98 96 fadeTimeout.current = setTimeout(() => { ··· 106 104 }, [resetFadeTimer]); 107 105 108 106 const toggleChat = useCallback(() => { 109 - if (setIsChatOpen) { 110 - setIsChatOpen(!isChatOpen); 111 - } 107 + if (setIsChatOpen) setIsChatOpen(!isChatOpen); 112 108 }, []); 113 109 114 110 useEffect(() => { 115 111 resetFadeTimer(); 116 112 117 113 return () => { 118 - if (fadeTimeout.current) { 119 - clearTimeout(fadeTimeout.current); 120 - } 114 + if (fadeTimeout.current) clearTimeout(fadeTimeout.current); 121 115 if (ingestStarting) { 122 116 setIngestStarting(false); 123 117 } ··· 139 133 140 134 // Picture-in-Picture event listeners 141 135 useEffect(() => { 142 - if (Platform.OS !== "web") { 143 - return; 144 - } 136 + if (Platform.OS !== "web") return; 145 137 146 138 let video: HTMLVideoElement | null = null; 147 139 if (isRefObject(videoRef)) { 148 140 video = videoRef.current; 149 141 } 150 - if (!video) { 151 - return; 152 - } 142 + if (!video) return; 153 143 154 144 function onEnter() { 155 145 setPipActive(true); ··· 170 160 }, [videoRef]); 171 161 172 162 const handlePip = useCallback(() => { 173 - if (pipAction) { 174 - pipAction(); 175 - } 163 + if (pipAction) pipAction(); 176 164 }, [pipAction]); 177 165 178 166 const hover = Gesture.Hover().onChange((_) => runOnJS(onPlayerHover)());
+1 -3
js/app/components/mobile/offline-counter/index.tsx
··· 49 49 return () => clearInterval(interval); 50 50 }, [offline, segment?.startTime]); 51 51 52 - if (!offline) { 53 - return null; 54 - } 52 + if (!offline) return null; 55 53 56 54 const titleFontSize = isMobile ? 24 : 32; 57 55 const subtitleFontSize = isMobile ? 16 : 18;
+3 -9
js/app/components/mobile/ui.tsx
··· 98 98 }, [ingestStarting, setIngestStarting]); 99 99 100 100 useEffect(() => { 101 - if (recordSubmitted) { 102 - setShowLoading(false); 103 - } 101 + if (recordSubmitted) setShowLoading(false); 104 102 }, [recordSubmitted]); 105 103 106 104 const isSelfAndNotLive = ingest === "new"; ··· 118 116 119 117 const resetFadeTimer = () => { 120 118 fadeOpacity.value = withTiming(1, { duration: 200 }); 121 - if (fadeTimeout.current) { 122 - clearTimeout(fadeTimeout.current); 123 - } 119 + if (fadeTimeout.current) clearTimeout(fadeTimeout.current); 124 120 fadeTimeout.current = setTimeout(() => { 125 121 fadeOpacity.value = withTiming(0, { duration: 400 }); 126 122 }, FADE_OUT_DELAY); ··· 129 125 useEffect(() => { 130 126 resetFadeTimer(); 131 127 return () => { 132 - if (fadeTimeout.current) { 133 - clearTimeout(fadeTimeout.current); 134 - } 128 + if (fadeTimeout.current) clearTimeout(fadeTimeout.current); 135 129 }; 136 130 }, []); 137 131
+3 -9
js/app/components/mobile/user-offline.tsx
··· 56 56 const detailedProfile = profile ? pfp[profile?.did] : null; 57 57 58 58 useEffect(() => { 59 - if (!profile?.did) { 60 - return; 61 - } 59 + if (!profile?.did) return; 62 60 63 61 let mounted = true; 64 62 ··· 67 65 try { 68 66 console.log("fetching recommendations for", profile.did); 69 67 const result = await getRecommendations(profile.did); 70 - if (!mounted) { 71 - return; 72 - } 68 + if (!mounted) return; 73 69 if (result.recommendations && result.recommendations.length > 0) { 74 70 // Get the first livestream recommendation 75 71 const firstLivestream = result.recommendations.find( ··· 87 83 } catch (err) { 88 84 console.error("failed to get recommendations", err); 89 85 } finally { 90 - if (mounted) { 91 - setIsLoadingRecommendation(false); 92 - } 86 + if (mounted) setIsLoadingRecommendation(false); 93 87 } 94 88 }; 95 89
+1 -3
js/app/components/settings/key-manager.tsx
··· 98 98 99 99 const [deletingKeys, setDeletingKeys] = useState<Set<string>>(new Set()); 100 100 const deleteKeyRecord = (rkey: string) => { 101 - if (deletingKeys.has(rkey)) { 102 - return; 103 - } // Prevent double deletes 101 + if (deletingKeys.has(rkey)) return; // Prevent double deletes 104 102 setDeletingKeys((prev) => new Set(prev).add(rkey)); 105 103 deleteStreamKeyRecord(rkey).finally(() => { 106 104 setDeletingKeys((prev) => {
+5 -15
js/app/components/settings/multistream-manager.tsx
··· 83 83 const [formError, setFormError] = useState<string>(""); 84 84 85 85 const loadMultistreamTargets = async () => { 86 - if (!agent) { 87 - return; 88 - } 86 + if (!agent) return; 89 87 90 88 try { 91 89 setLoading(true); ··· 104 102 const createMultistreamTarget = async ( 105 103 record: PlaceStreamMultistreamTarget.Record, 106 104 ) => { 107 - if (!agent) { 108 - return; 109 - } 105 + if (!agent) return; 110 106 try { 111 107 setFormError(""); 112 108 setFormLoading(true); ··· 130 126 uri: string, 131 127 record: PlaceStreamMultistreamTarget.Record, 132 128 ) => { 133 - if (!agent) { 134 - return; 135 - } 129 + if (!agent) return; 136 130 try { 137 131 setFormError(""); 138 132 setFormLoading(true); ··· 155 149 target: MultistreamTargetViewHydrated, 156 150 newActiveState: boolean, 157 151 ) => { 158 - if (!agent) { 159 - return; 160 - } 152 + if (!agent) return; 161 153 try { 162 154 setTogglingTargets((prev) => new Set(prev).add(target.uri)); 163 155 await agent.place.stream.multistream.putTarget({ ··· 181 173 }; 182 174 183 175 const deleteMultistreamTarget = async (uri: string) => { 184 - if (!agent) { 185 - return; 186 - } 176 + if (!agent) return; 187 177 try { 188 178 setFormError(""); 189 179 setDeletingTargets((prev) => new Set(prev).add(uri));
+5 -15
js/app/components/settings/recommendations-manager.tsx
··· 63 63 const { t } = useTranslation("settings"); 64 64 65 65 const loadRecommendations = async () => { 66 - if (!agent) { 67 - return; 68 - } 66 + if (!agent) return; 69 67 70 68 try { 71 69 setLoading(true); ··· 113 111 }; 114 112 115 113 const saveRecommendations = async (newStreamers: string[]) => { 116 - if (!agent || saving) { 117 - return; 118 - } 114 + if (!agent || saving) return; 119 115 120 116 try { 121 117 if (!agent.did) { ··· 253 249 }; 254 250 255 251 const handleSaveEdit = async () => { 256 - if (editingIndex === null) { 257 - return; 258 - } 252 + if (editingIndex === null) return; 259 253 260 254 const trimmed = editValue.trim(); 261 255 if (!trimmed) { ··· 291 285 }; 292 286 293 287 const confirmDelete = async () => { 294 - if (deleteDialog.index === null) { 295 - return; 296 - } 288 + if (deleteDialog.index === null) return; 297 289 298 290 const newStreamers = streamers.filter((_, i) => i !== deleteDialog.index); 299 291 await saveRecommendations(newStreamers); ··· 301 293 }; 302 294 303 295 useEffect(() => { 304 - if (!agent) { 305 - return; 306 - } 296 + if (!agent) return; 307 297 loadRecommendations(); 308 298 }, [agent]); 309 299
+6 -18
js/app/components/settings/webhook-manager.tsx
··· 631 631 const { t } = useTranslation("settings"); 632 632 633 633 const loadWebhooks = async () => { 634 - if (!agent) { 635 - return; 636 - } 634 + if (!agent) return; 637 635 638 636 try { 639 637 setLoading(true); ··· 659 657 }; 660 658 661 659 const createWebhook = async (data: WebhookFormData) => { 662 - if (!agent) { 663 - return; 664 - } 660 + if (!agent) return; 665 661 666 662 try { 667 663 setFormLoading(true); ··· 697 693 }; 698 694 699 695 const updateWebhook = async (data: WebhookFormData) => { 700 - if (!agent || !editingWebhook) { 701 - return; 702 - } 696 + if (!agent || !editingWebhook) return; 703 697 704 698 try { 705 699 setFormLoading(true); ··· 737 731 738 732 const deleteWebhook = async (id: string) => { 739 733 const webhook = webhooks?.find((w) => w.id === id); 740 - if (!webhook) { 741 - return; 742 - } 734 + if (!webhook) return; 743 735 744 736 setDeleteDialog({ isVisible: true, webhook }); 745 737 }; 746 738 747 739 const confirmDelete = async () => { 748 - if (!agent || !deleteDialog.webhook) { 749 - return; 750 - } 740 + if (!agent || !deleteDialog.webhook) return; 751 741 752 742 const id = deleteDialog.webhook.id; 753 743 ··· 790 780 }; 791 781 792 782 useEffect(() => { 793 - if (!agent) { 794 - return; 795 - } 783 + if (!agent) return; 796 784 loadWebhooks(); 797 785 }, [agent]); 798 786
+6 -1
js/app/features/bluesky/blueskyProvider.tsx
··· 2 2 import { storage } from "@streamplace/components"; 3 3 import { useURL } from "expo-linking"; 4 4 import { useEffect, useState } from "react"; 5 + import { Platform } from "react-native"; 5 6 import { useStore } from "store"; 6 7 import { useIsReady, useOAuthSession, useUserProfile } from "store/hooks"; 7 8 import { navigateToRoute } from "utils/navigation"; ··· 23 24 loadOAuthClient(); 24 25 25 26 // load return route from storage on mount 27 + if (Platform.OS !== "web") { 28 + return; 29 + } 26 30 storage.getItem("returnRoute").then((stored) => { 27 31 if (stored) { 28 32 try { ··· 82 86 if ( 83 87 lastAuthStatus !== "loggedIn" && 84 88 authStatus === "loggedIn" && 85 - returnRoute 89 + returnRoute && 90 + Platform.OS === "web" 86 91 ) { 87 92 console.log( 88 93 "Login successful, navigating back to returnRoute:",
+2 -6
js/app/hooks/usePreloadEmoji.ts
··· 6 6 7 7 export function usePreloadEmoji({ immediate }: { immediate?: boolean } = {}) { 8 8 const preload = React.useCallback(async () => { 9 - if (loadRequested) { 10 - return; 11 - } 9 + if (loadRequested) return; 12 10 loadRequested = true; 13 11 let data; 14 12 if (Platform.OS === "web") { ··· 19 17 init({ data }); 20 18 }, []); 21 19 22 - if (immediate) { 23 - preload(); 24 - } 20 + if (immediate) preload(); 25 21 return preload; 26 22 }
+1 -3
js/app/hooks/useSegmentTiming.tsx
··· 8 8 range: number | null, 9 9 numOfSegments: number = 1, 10 10 ): ConnectionQuality { 11 - if (timeBetweenSegments === null || range === null) { 12 - return "poor"; 13 - } 11 + if (timeBetweenSegments === null || range === null) return "poor"; 14 12 15 13 if (timeBetweenSegments <= 1500 && range <= (1500 * 60) / numOfSegments) { 16 14 return "good";
+1 -3
js/app/hooks/useSidebarControl.tsx
··· 49 49 const isActive = useIsLargeScreen(); 50 50 useEffect(() => { 51 51 if (isActive) { 52 - if (!isHidden && targetWidth < 64) { 53 - targetWidth == 64; 54 - } 52 + if (!isHidden && targetWidth < 64) targetWidth == 64; 55 53 // Only animate if the sidebar is active 56 54 animatedWidth.value = withTiming(targetWidth, { duration: 250 }); 57 55 } else {
+1 -3
js/app/src/screens/chat-popout.native.tsx
··· 65 65 }, [seg?.id]); 66 66 67 67 const getLatency = useCallback((): string => { 68 - if (!segmentReceivedTimeRef.current) { 69 - return ""; 70 - } 68 + if (!segmentReceivedTimeRef.current) return ""; 71 69 const secondsAgo = Math.floor(Date.now() - segmentReceivedTimeRef.current); 72 70 const isThreeDigits = secondsAgo >= 100 && secondsAgo < 1000; 73 71 if (isThreeDigits) {
+5 -15
js/app/src/screens/danmu-obs.tsx
··· 22 22 const params: DanmuParams = {}; 23 23 24 24 const opacity = query.get("opacity"); 25 - if (opacity) { 26 - params.opacity = parseInt(opacity); 27 - } 25 + if (opacity) params.opacity = parseInt(opacity); 28 26 29 27 const speed = query.get("speed"); 30 - if (speed) { 31 - params.speed = parseFloat(speed); 32 - } 28 + if (speed) params.speed = parseFloat(speed); 33 29 34 30 const laneCount = query.get("laneCount"); 35 - if (laneCount) { 36 - params.laneCount = parseInt(laneCount); 37 - } 31 + if (laneCount) params.laneCount = parseInt(laneCount); 38 32 39 33 const maxMessages = query.get("maxMessages"); 40 - if (maxMessages) { 41 - params.maxMessages = parseInt(maxMessages); 42 - } 34 + if (maxMessages) params.maxMessages = parseInt(maxMessages); 43 35 44 36 const enabled = query.get("enabled"); 45 - if (enabled !== null) { 46 - params.enabled = enabled !== "false"; 47 - } 37 + if (enabled !== null) params.enabled = enabled !== "false"; 48 38 49 39 return params; 50 40 };
+9 -27
js/app/src/screens/home.tsx
··· 64 64 } 65 65 66 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 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 79 71 return "xs"; // sm and below 80 72 } 81 73 82 74 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 - } 75 + if (width >= 1550) return 4; 76 + if (width >= 1280) return 3; 77 + if (width >= 1024) return 2; 78 + if (width >= 768) return 2; 95 79 return 1; 96 80 } 97 81 98 82 // Get the ratio for the first card based on column count 99 83 function getPadPercentage(cols: number): number { 100 - if (cols >= 3) { 101 - return 2.07; 102 - } 84 + if (cols >= 3) return 2.07; 103 85 return 1; 104 86 } 105 87
+1 -3
js/app/store/hooks.ts
··· 25 25 const oauthSession = useOAuthSession(); 26 26 const profiles = useProfiles(); 27 27 const did = oauthSession?.did; 28 - if (!did) { 29 - return null; 30 - } 28 + if (!did) return null; 31 29 return profiles[did]; 32 30 }; 33 31 export const useIsReady = () => {
+2 -6
js/app/store/slices/platformSlice.native.ts
··· 38 38 notificationToken: null, 39 39 notificationDestination: null, 40 40 handleNotification: (payload) => { 41 - if (!payload) { 42 - return; 43 - } 44 - if (typeof payload.path !== "string") { 45 - return; 46 - } 41 + if (!payload) return; 42 + if (typeof payload.path !== "string") return; 47 43 set({ notificationDestination: payload.path }); 48 44 }, 49 45 clearNotification: () => {
+1 -3
js/atproto-oauth-client-react-native/src/sqlite-keystore.ts
··· 22 22 23 23 async get(key: string): Promise<T | undefined> { 24 24 const itemStr = await this.store.get(key); 25 - if (!itemStr) { 26 - return undefined; 27 - } 25 + if (!itemStr) return undefined; 28 26 const item = JSON.parse(itemStr) as T; 29 27 if (item.dpopKey) { 30 28 item.dpopKey = new JoseKey(item.dpopKey as unknown as Jwk);
js/components/assets/badges/live.png

This is a binary file and will not be displayed.

js/components/assets/badges/mod.png

This is a binary file and will not be displayed.

js/components/assets/badges/vip.png

This is a binary file and will not be displayed.

+1 -3
js/components/scripts/migrate-i18n.js
··· 353 353 allNewKeys.push(...namespacesForLocale[namespace]); 354 354 } 355 355 356 - if (allNewKeys.length === 0) { 357 - continue; 358 - } 356 + if (allNewKeys.length === 0) continue; 359 357 360 358 // Add all keys to the specified namespace 361 359 const targetFile = addKeysToFtlFile(
+8 -24
js/components/src/components/chat/chat-box.tsx
··· 93 93 }, [pdsAgent, userDID, setActiveTeleportUri]); 94 94 95 95 const authors = useMemo(() => { 96 - if (!chat) { 97 - return null; 98 - } 96 + if (!chat) return null; 99 97 return chat.reduce((acc, msg) => { 100 98 // 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 - } 99 + if (msg.author.did === "did:sys:system") return acc; 100 + if (acc.has(msg.author.handle)) return acc; 107 101 acc.set(msg.author.handle, msg.chatProfile); 108 102 return acc; 109 103 }, new Map<string, ChatMessageViewHydrated["chatProfile"]>()); ··· 149 143 if (colonIndex !== -1) { 150 144 const searchText = text.slice(colonIndex + 1).toLowerCase(); 151 145 if (searchText.length >= 3) { 152 - if (!emojiData) { 153 - return; 154 - } 146 + if (!emojiData) return; 155 147 const aliasMatches = Object.entries(emojiData.aliases) 156 148 .map(([alias, emojiId]) => { 157 149 const aliasLower = alias.toLowerCase(); ··· 177 169 { matchType: number; index: number; alias: string } 178 170 > = {}; 179 171 for (const match of aliasMatches) { 180 - if (!match) { 181 - continue; 182 - } 172 + if (!match) continue; 183 173 const prev = bestAliasMatch[match.emojiId]; 184 174 if ( 185 175 !prev || ··· 242 232 // Sort by alias match type, then position, then fallback 243 233 .sort((a, b) => { 244 234 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 - } 235 + if (a.sort[i] !== b.sort[i]) return a.sort[i] - b.sort[i]; 248 236 } 249 237 return 0; 250 238 }) ··· 270 258 }; 271 259 272 260 const submit = async () => { 273 - if (!message.trim()) { 274 - return; 275 - } 261 + if (!message.trim()) return; 276 262 if (graphemer.countGraphemes(message) > 300) { 277 263 toast.show( 278 264 "Message too long", ··· 589 575 aria-label="Popout Chat" 590 576 style={{ borderRadius: 16, maxWidth: 44, aspectRatio: 1 }} 591 577 onPress={() => { 592 - if (!linfo) { 593 - return; 594 - } 578 + if (!linfo) return; 595 579 const u = new URL(window.location.href); 596 580 u.pathname = `/chat-popout/${linfo?.author?.did}`; 597 581 window.open(u.toString(), "_blank", "popup=true");
+28 -4
js/components/src/components/chat/chat-message.tsx
··· 4 4 Mention, 5 5 } from "@atproto/api/dist/client/types/app/bsky/richtext/facet"; 6 6 import { memo, useCallback } from "react"; 7 - import { Linking, View } from "react-native"; 7 + import { Image, Linking, View } from "react-native"; 8 8 import { ChatMessageViewHydrated } from "streamplace"; 9 9 import { RichtextSegment, segmentize } from "../../lib/facet"; 10 10 import { borders, flex, gap, ml, mr, opacity, pl } from "../../lib/theme/atoms"; ··· 23 23 }>; 24 24 } 25 25 26 + import { zero } from "../.."; 26 27 import { useLivestreamStore } from "../../livestream-store"; 27 28 import { Text } from "../ui/text"; 28 29 ··· 83 84 text: string; 84 85 facets: ChatMessageViewHydrated["record"]["facets"]; 85 86 }) => { 86 - if (!facets?.length) { 87 - return <Text>{text}</Text>; 88 - } 87 + if (!facets?.length) return <Text>{text}</Text>; 89 88 90 89 const userCache = useLivestreamStore((state) => state.authors); 91 90 ··· 166 165 style={{ 167 166 fontVariant: ["tabular-nums"], 168 167 color: colors.gray[400], 168 + width: 44, 169 169 }} 170 170 > 171 171 {formatTime(item.record.createdAt)} 172 172 </Text> 173 173 )} 174 + {item.badges?.length ? ( 175 + <View style={[zero.layout.flex.align.end]}> 176 + {item.badges.map((badge, index) => ( 177 + <View style={{ height: 3 }} key={`badge-${index}`}> 178 + {badge.badgeType === "place.stream.badge.defs#mod" ? ( 179 + <Image 180 + source={require("../../../assets/badges/mod.png")} 181 + style={{ height: 20, width: 20, marginTop: 3 }} 182 + /> 183 + ) : badge.badgeType === "place.stream.badge.defs#streamer" ? ( 184 + <Image 185 + source={require("../../../assets/badges/live.png")} 186 + style={{ height: 20, width: 20, marginTop: 3 }} 187 + /> 188 + ) : ( 189 + <Image 190 + source={require("../../../assets/badges/vip.png")} 191 + style={{ height: 20, width: 20, marginTop: 3 }} 192 + /> 193 + )} 194 + </View> 195 + ))} 196 + </View> 197 + ) : null} 174 198 <Text 175 199 weight="bold" 176 200 color="default"
+2 -5
js/components/src/components/chat/chat.tsx
··· 77 77 const setReply = useSetReplyToMessage(); 78 78 const setModMsg = usePlayerStore((state) => state.setModMessage); 79 79 80 - if (!visible) { 81 - return null; 82 - } 80 + if (!visible) return null; 83 81 84 82 return ( 85 83 <View ··· 292 290 } 293 291 }; 294 292 295 - if (!chat) { 293 + if (!chat) 296 294 return ( 297 295 <View style={[flex.shrink[1], { minWidth: 0, maxWidth: "100%" }]}> 298 296 <Text>Loading chat...</Text> 299 297 </View> 300 298 ); 301 - } 302 299 303 300 return ( 304 301 <View
+4 -12
js/components/src/components/chat/mod-view.tsx
··· 206 206 <DropdownMenuItem 207 207 disabled={isHideLoading || messageRemoved} 208 208 onPress={() => { 209 - if (isHideLoading || messageRemoved) { 210 - return; 211 - } 209 + if (isHideLoading || messageRemoved) return; 212 210 createHideChat(message.uri, streamerDID ?? undefined) 213 211 .then((r) => setMessageRemoved(true)) 214 212 .catch((e) => console.error(e)); ··· 232 230 <DropdownMenuItem 233 231 disabled={isBlockLoading} 234 232 onPress={() => { 235 - if (isBlockLoading) { 236 - return; 237 - } 233 + if (isBlockLoading) return; 238 234 createBlock(message.author.did, streamerDID ?? undefined) 239 235 .then((r) => { 240 236 toast.show( ··· 315 311 <DropdownMenuItem 316 312 closeOnPress={false} 317 313 onPress={() => { 318 - if (!message) { 319 - return; 320 - } 314 + if (!message) return; 321 315 if (!confirming) { 322 316 setConfirming(DeleteState.Confirmed); 323 317 return; ··· 362 356 return ( 363 357 <DropdownMenuItem 364 358 onPress={() => { 365 - if (!message) { 366 - return; 367 - } 359 + if (!message) return; 368 360 onOpenChange?.(false); 369 361 setReportModalOpen(true); 370 362 setReportSubject({
+1 -3
js/components/src/components/content-metadata/content-metadata-form.tsx
··· 137 137 } 138 138 139 139 const loadMetadata = async () => { 140 - if (!pdsAgent || !did) { 141 - return; 142 - } 140 + if (!pdsAgent || !did) return; 143 141 144 142 try { 145 143 const metadata = await getContentMetadata();
+2 -5
js/components/src/components/danmu/danmu-message.tsx
··· 71 71 }; 72 72 73 73 useEffect(() => { 74 - if (messageWidth === 0) { 75 - return; 76 - } // Wait for layout measurement 74 + if (messageWidth === 0) return; // Wait for layout measurement 77 75 78 76 const duration = baseDuration(message, MAX_DURATION, MIN_DURATION); 79 77 ··· 92 90 setTotalDuration(duration); 93 91 } 94 92 95 - if (__DEV__) { 93 + if (__DEV__) 96 94 console.log( 97 95 `[danmu] animation started: "${message.record.text}" (duration: ${duration.toFixed(0)}ms, remaining: ${remainingDuration.toFixed(0)}ms, speed: ${speed}x)`, 98 96 ); 99 - } 100 97 101 98 translateX.value = startPosition; 102 99
+4 -11
js/components/src/components/danmu/danmu-overlay.tsx
··· 81 81 ); 82 82 83 83 useEffect(() => { 84 - if (!enabled || !chat || containerWidth === 0) { 85 - return; 86 - } 84 + if (!enabled || !chat || containerWidth === 0) return; 87 85 88 86 // only check new messages since last render (chat is sorted newest first) 89 87 const newMessageCount = chat.length - lastChatLength.current; 90 - if (newMessageCount <= 0) { 91 - return; 92 - } 88 + if (newMessageCount <= 0) return; 93 89 94 90 const messagesToCheck = chat.slice(0, newMessageCount); 95 91 lastChatLength.current = chat.length; ··· 103 99 return !hasProcessed && !isSystem && isAfterMount; 104 100 }); 105 101 106 - if (newMessages.length === 0) { 107 - return; 108 - } 102 + if (newMessages.length === 0) return; 109 103 110 104 const messagesToAdd: ActiveDanmuMessage[] = []; 111 105 ··· 125 119 } 126 120 127 121 const duration = baseDuration(message, MAX_DURATION, MIN_DURATION); 128 - if (__DEV__) { 122 + if (__DEV__) 129 123 console.log("[danmu] message", message.record.text, { 130 124 duration, 131 125 speed, 132 126 }); 133 - } 134 127 const lane = assignLane(message.uri, duration); 135 128 136 129 if (lane !== null) {
+1 -3
js/components/src/components/danmu/use-danmu-lanes.ts
··· 34 34 (d) => d.lane === laneIndex && d.endTime > now, 35 35 ); 36 36 37 - if (danmuInLane.length === 0) { 38 - return true; 39 - } 37 + if (danmuInLane.length === 0) return true; 40 38 41 39 // check the most recent danmu in this lane 42 40 const mostRecent = danmuInLane.reduce((latest, current) =>
+4 -12
js/components/src/components/dashboard/header.tsx
··· 42 42 43 43 function StatusIndicator({ status, isLive }: StatusIndicatorProps) { 44 44 const getStatusColor = () => { 45 - if (!isLive) { 46 - return bg.gray[500]; 47 - } 45 + if (!isLive) return bg.gray[500]; 48 46 switch (status) { 49 47 case "excellent": 50 48 return bg.green[500]; ··· 60 58 }; 61 59 62 60 const getStatusText = () => { 63 - if (!isLive) { 64 - return "OFFLINE"; 65 - } 61 + if (!isLive) return "OFFLINE"; 66 62 switch (status) { 67 63 case "excellent": 68 64 return "EXCELLENT"; ··· 121 117 onProblemsPress, 122 118 }: HeaderProps) { 123 119 const getConnectionQuality = (): "good" | "warning" | "error" => { 124 - if (timeBetweenSegments <= 1500) { 125 - return "good"; 126 - } 127 - if (timeBetweenSegments <= 3000) { 128 - return "warning"; 129 - } 120 + if (timeBetweenSegments <= 1500) return "good"; 121 + if (timeBetweenSegments <= 3000) return "warning"; 130 122 return "error"; 131 123 }; 132 124
+11 -33
js/components/src/components/dashboard/information-widget.tsx
··· 54 54 const viewers = useViewers(); 55 55 56 56 const getBitrate = useCallback((): number => { 57 - if (!seg?.size || !seg?.duration) { 58 - return 0; 59 - } 57 + if (!seg?.size || !seg?.duration) return 0; 60 58 const kbps = 61 59 (seg.size * 8) / ((seg.duration || 1000000000) / 1000000000) / 1000; 62 60 return kbps; ··· 94 92 }, [seg?.startTime, streamStartTime]); 95 93 96 94 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 - } 95 + if (currentBitrate > 2000) return "good"; 96 + if (currentBitrate > 1000) return "warning"; 97 + if (currentBitrate > 0) return "error"; 106 98 return "neutral"; 107 99 }; 108 100 109 101 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 - } 102 + if (!seg) return "error"; 103 + if (currentBitrate > 1500) return "good"; 104 + if (currentBitrate > 500) return "warning"; 119 105 return "error"; 120 106 }; 121 107 ··· 454 440 const maxDataValue = Math.max(...data, 1); 455 441 const minDataValue = Math.min(...data); 456 442 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 - } 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 }; 469 447 470 448 const roundedMax = Math.ceil(max / 5000) * 5000; 471 449 return { min: 0, max: roundedMax, step: roundedMax / 2 };
+2 -6
js/components/src/components/dashboard/moderator-panel.tsx
··· 388 388 // Clear error when user starts typing 389 389 const handleDIDChange = (text: string) => { 390 390 setModeratorDID(text); 391 - if (error) { 392 - setError(null); 393 - } 391 + if (error) setError(null); 394 392 }; 395 393 396 394 const handleAdd = async () => { ··· 430 428 <ResponsiveDialog 431 429 open={visible} 432 430 onOpenChange={(open) => { 433 - if (!open) { 434 - onClose(); 435 - } 431 + if (!open) onClose(); 436 432 }} 437 433 title="Add Moderator" 438 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 59 60 60 // Manual rotation functions 61 61 const rotateToLandscape = async () => { 62 - if (!enabled || !canRotate || !ScreenOrientation) { 63 - return; 64 - } 62 + if (!enabled || !canRotate || !ScreenOrientation) return; 65 63 66 64 try { 67 65 await ScreenOrientation.unlockAsync(); ··· 82 80 }; 83 81 84 82 const rotateToPortrait = async () => { 85 - if (!enabled || !canRotate || !ScreenOrientation) { 86 - return; 87 - } 83 + if (!enabled || !canRotate || !ScreenOrientation) return; 88 84 89 85 try { 90 86 await ScreenOrientation.unlockAsync(); ··· 104 100 }; 105 101 106 102 const toggleRotation = async () => { 107 - if (!ScreenOrientation) { 108 - return; 109 - } 103 + if (!ScreenOrientation) return; 110 104 111 105 const isLandscape = 112 106 currentOrientation === ScreenOrientation.Orientation.LANDSCAPE_LEFT || ··· 127 121 128 122 // Track orientation changes 129 123 useEffect(() => { 130 - if (!enabled) { 131 - return; 132 - } 124 + if (!enabled) return; 133 125 134 126 const getCurrentOrientation = async () => { 135 - if (!ScreenOrientation) { 136 - return; 137 - } 127 + if (!ScreenOrientation) return; 138 128 try { 139 129 const orient = await ScreenOrientation.getOrientationAsync(); 140 130 setCurrentOrientation(orient); ··· 149 139 150 140 getCurrentOrientation(); 151 141 152 - if (!ScreenOrientation) { 153 - return; 154 - } 142 + if (!ScreenOrientation) return; 155 143 156 144 const subscription = ScreenOrientation.addOrientationChangeListener( 157 145 (event) => {
+1 -3
js/components/src/components/mobile-player/ui/autoplay-button.tsx
··· 40 40 } 41 41 }; 42 42 43 - if (!autoplayFailed) { 44 - return null; 45 - } 43 + if (!autoplayFailed) return null; 46 44 47 45 return ( 48 46 <View
+3 -9
js/components/src/components/mobile-player/ui/countdown.tsx
··· 36 36 }; 37 37 38 38 const handleDone = () => { 39 - if (onDone) { 40 - onDone(); 41 - } 39 + if (onDone) onDone(); 42 40 }; 43 41 44 42 // Accurate countdown using useFrameCallback 45 43 useFrameCallback(({ timestamp }) => { 46 - if (!visible) { 47 - return; 48 - } 44 + if (!visible) return; 49 45 50 46 // Set start timestamp on first frame 51 47 if (startTimestamp.value === null) { ··· 90 86 opacity: opacity.value, 91 87 })); 92 88 93 - if (!visible || countdown === 0) { 94 - return null; 95 - } 89 + if (!visible || countdown === 0) return null; 96 90 97 91 return ( 98 92 <Animated.View
+1 -3
js/components/src/components/mobile-player/ui/report-modal.tsx
··· 84 84 }; 85 85 86 86 const handleSubmit = async () => { 87 - if (!selectedReason) { 88 - return; 89 - } 87 + if (!selectedReason) return; 90 88 91 89 setIsSubmitting(true); 92 90 setSubmitError(null);
+2 -6
js/components/src/components/mobile-player/ui/streamer-loading-overlay.tsx
··· 80 80 81 81 // Trigger animation on each message change 82 82 useEffect(() => { 83 - if (!visible) { 84 - return; 85 - } 83 + if (!visible) return; 86 84 87 85 const fadeDuration = Math.min(interval / 2, 250); // Simplified fade duration 88 86 ··· 117 115 opacity: wholeOpacity.value, 118 116 })); 119 117 120 - if (!shouldRender) { 121 - return null; 122 - } 118 + if (!shouldRender) return null; 123 119 124 120 return ( 125 121 <Animated.View
+1 -3
js/components/src/components/mobile-player/ui/viewer-context-menu.tsx
··· 294 294 return ( 295 295 <DropdownMenuItem 296 296 onPress={() => { 297 - if (!livestream) { 298 - return; 299 - } 297 + if (!livestream) return; 300 298 onOpenChange?.(false); 301 299 setReportModalOpen(true); 302 300 setReportSubject({
+5 -14
js/components/src/components/mobile-player/video.tsx
··· 29 29 | undefined, 30 30 instance: HTMLVideoElement | null, 31 31 ) { 32 - if (!ref) { 33 - return; 34 - } 35 - if (typeof ref === "function") { 36 - ref(instance); 37 - } else { 38 - ref.current = instance; 39 - } 32 + if (!ref) return; 33 + if (typeof ref === "function") ref(instance); 34 + else ref.current = instance; 40 35 } 41 36 42 37 type VideoProps = { ··· 50 45 const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); 51 46 52 47 useEffect(() => { 53 - if (!videoRef.current) { 54 - return; 55 - } 48 + if (!videoRef.current) return; 56 49 57 50 function updateSize() { 58 51 setDimensions({ ··· 393 386 } 394 387 }, [diagnostics]); 395 388 396 - if (!diagnostics.done) { 397 - return <></>; 398 - } 389 + if (!diagnostics.done) return <></>; 399 390 400 391 if (webrtcError) { 401 392 setProtocol(PlayerProtocol.HLS);
+1 -3
js/components/src/components/stream-notification/teleport-notification.tsx
··· 89 89 }, []); 90 90 91 91 useEffect(() => { 92 - if (showStripes) { 93 - return; 94 - } 92 + if (showStripes) return; 95 93 96 94 // animate progress bar 97 95 const percentage = (timeLeft / countdown) * 100;
+1 -3
js/components/src/components/ui/admonition.tsx
··· 26 26 const { theme, icons } = useTheme(); 27 27 28 28 const defaultIconLeft = (() => { 29 - if (iconLeft) { 30 - return iconLeft; 31 - } 29 + if (iconLeft) return iconLeft; 32 30 switch (variant) { 33 31 case "success": 34 32 return CheckCircle;
+3 -9
js/components/src/components/ui/dialog.tsx
··· 350 350 const { theme } = useTheme(); 351 351 const styles = React.useMemo(() => createStyles(theme), [theme]); 352 352 353 - if (!children) { 354 - return null; 355 - } 353 + if (!children) return null; 356 354 357 355 return ( 358 356 <Text ref={ref} style={[styles.title, style]} {...props}> ··· 375 373 const { theme } = useTheme(); 376 374 const styles = React.useMemo(() => createStyles(theme), [theme]); 377 375 378 - if (!children) { 379 - return null; 380 - } 376 + if (!children) return null; 381 377 382 378 return ( 383 379 <Text ref={ref} style={[styles.description, style]} {...props}> ··· 418 414 const { theme } = useTheme(); 419 415 const styles = React.useMemo(() => createStyles(theme), [theme]); 420 416 421 - if (!children) { 422 - return null; 423 - } 417 + if (!children) return null; 424 418 425 419 return ( 426 420 <ModalPrimitive.Footer
+3 -8
js/components/src/components/ui/dropdown.native.tsx
··· 190 190 191 191 const push = (item: NavigationStackItem) => { 192 192 setStack((prev) => { 193 - if (!Array.isArray(prev)) { 193 + if (!Array.isArray(prev)) 194 194 return [{ key: "root", content: children }, item]; 195 - } 196 195 return [...prev, item]; 197 196 }); 198 197 ··· 221 220 }; 222 221 223 222 const pop = () => { 224 - if (stack.length <= 1) { 225 - return; 226 - } 223 + if (stack.length <= 1) return; 227 224 228 225 slideAnim.value = withTiming(40, { duration: 150 }); 229 226 fadeAnim.value = withTiming(0, { duration: 150 }, (finished) => { ··· 247 244 const isNested = stack.length > 1; 248 245 249 246 const onBackgroundTap = () => { 250 - if (sheetRef.current) { 251 - sheetRef.current?.close(); 252 - } 247 + if (sheetRef.current) sheetRef.current?.close(); 253 248 254 249 setTimeout(() => { 255 250 onOpenChange?.(false);
+1 -3
js/components/src/components/ui/primitives/button.tsx
··· 198 198 199 199 export const ButtonLoading = forwardRef<View, ButtonLoadingProps>( 200 200 ({ children, visible = false, style, ...props }, ref) => { 201 - if (!visible) { 202 - return null; 203 - } 201 + if (!visible) return null; 204 202 205 203 return ( 206 204 <View ref={ref} style={[primitiveStyles.loading, style]} {...props}>
+1 -3
js/components/src/components/ui/primitives/input.tsx
··· 230 230 231 231 export const InputError = forwardRef<Text, InputErrorProps>( 232 232 ({ children, visible = true, style, ...props }, ref) => { 233 - if (!visible || !children) { 234 - return null; 235 - } 233 + if (!visible || !children) return null; 236 234 237 235 return ( 238 236 <Text ref={ref} style={[primitiveStyles.error, style]} {...props}>
+2 -6
js/components/src/components/ui/resizeable.tsx
··· 67 67 }) 68 68 .onUpdate((event) => { 69 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 - } 70 + if (newHeight > MAX_HEIGHT) newHeight = MAX_HEIGHT; 71 + if (newHeight < MIN_HEIGHT) newHeight = MIN_HEIGHT; 76 72 sheetHeight.value = newHeight; 77 73 78 74 const nowCollapsed = newHeight < COLLAPSE_HEIGHT;
+1 -3
js/components/src/components/ui/toast.tsx
··· 400 400 key={toastState.id} 401 401 {...toastState} 402 402 onOpenChange={(open) => { 403 - if (!open) { 404 - toastManager.hide(toastState.id); 405 - } 403 + if (!open) toastManager.hide(toastState.id); 406 404 }} 407 405 index={index} 408 406 isLatest={index === 0}
+1 -3
js/components/src/hooks/useAvatars.tsx
··· 18 18 ); 19 19 20 20 useEffect(() => { 21 - if (missingDids.length === 0 || !agent) { 22 - return; 23 - } 21 + if (missingDids.length === 0 || !agent) return; 24 22 const toFetch = missingDids.slice(0, 25); 25 23 toFetch.forEach((did) => inFlight.current.add(did)); 26 24
+1 -3
js/components/src/hooks/useLivestreamInfo.ts
··· 41 41 ) => { 42 42 if (!ingestStarting) { 43 43 // Optionally close keyboard if provided 44 - if (closeKeyboard) { 45 - closeKeyboard(); 46 - } 44 + if (closeKeyboard) closeKeyboard(); 47 45 setShowCountdown(true); 48 46 setIngestStarting(true); 49 47 setIngestLive(true);
+1 -3
js/components/src/hooks/useSegmentTiming.tsx
··· 8 8 range: number | null, 9 9 numOfSegments: number = 1, 10 10 ): ConnectionQuality { 11 - if (timeBetweenSegments === null || range === null) { 12 - return "poor"; 13 - } 11 + if (timeBetweenSegments === null || range === null) return "poor"; 14 12 15 13 if (timeBetweenSegments <= 1500 && range <= (1500 * 60) / numOfSegments) { 16 14 return "good";
+1 -3
js/components/src/i18n/i18next-config.ts
··· 94 94 function getFallbackChain(code: string): string[] { 95 95 const fallbacks: string[] = []; 96 96 97 - if (!code) { 98 - return manifest.fallbackChain; 99 - } 97 + if (!code) return manifest.fallbackChain; 100 98 101 99 // Regional fallbacks 102 100 if (code.match(/^es-/)) {
+1 -3
js/components/src/lib/system-messages.ts
··· 162 162 const type = getSystemMessageType(message); 163 163 const text = message.record.text; 164 164 165 - if (!type) { 166 - return metadata; 167 - } 165 + if (!type) return metadata; 168 166 169 167 switch (type) { 170 168 case "stream_end": {
+7 -21
js/components/src/lib/theme/theme.tsx
··· 397 397 398 398 // Determine if dark mode should be active 399 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 - } 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"; 415 405 return systemColorScheme === "dark"; 416 406 }, [forcedTheme, currentTheme, systemColorScheme]); 417 407 ··· 454 444 const toggleTheme = () => { 455 445 if (!forcedTheme) { 456 446 setCurrentTheme((prev) => { 457 - if (prev === "light") { 458 - return "dark"; 459 - } 460 - if (prev === "dark") { 461 - return "system"; 462 - } 447 + if (prev === "light") return "dark"; 448 + if (prev === "dark") return "system"; 463 449 return "light"; 464 450 }); 465 451 }
+8 -24
js/components/src/lib/utils.ts
··· 48 48 }, 49 49 screenWidth: number, 50 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 - } 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; 63 55 return values.default; 64 56 } 65 57 ··· 74 66 }): Style { 75 67 const Platform = require("react-native").Platform; 76 68 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 - } 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; 86 72 return styles.default || {}; 87 73 } 88 74 ··· 91 77 */ 92 78 export function hexToRgba(hex: string, alpha: number = 1): string { 93 79 const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); 94 - if (!result) { 95 - return hex; 96 - } 80 + if (!result) return hex; 97 81 98 82 const r = parseInt(result[1], 16); 99 83 const g = parseInt(result[2], 16);
+1 -3
js/components/src/livestream-provider/index.tsx
··· 62 62 const prevActiveTeleportRef = useRef(activeTeleport); 63 63 64 64 useEffect(() => { 65 - if (!activeTeleport || !profile[activeTeleport.streamer]) { 66 - return; 67 - } 65 + if (!activeTeleport || !profile[activeTeleport.streamer]) return; 68 66 69 67 const startsAt = new Date(activeTeleport.startsAt); 70 68 const now = new Date();
+1 -3
js/components/src/livestream-store/stream-key.tsx
··· 25 25 const [error, setError] = useState<string | null>(null); 26 26 27 27 useEffect(() => { 28 - if (key) { 29 - return; 30 - } // already have key 28 + if (key) return; // already have key 31 29 32 30 const generateKey = async () => { 33 31 if (!pdsAgent) {
+1
js/components/src/livestream-store/websocket-consumer.tsx
··· 80 80 chatProfile: (message as any).chatProfile, 81 81 replyTo: (message as any).replyTo, 82 82 deleted: message.deleted, 83 + badges: message.badges, 83 84 }; 84 85 state = reduceChat(state, [hydrated], [], []); 85 86 } else if (PlaceStreamSegment.isRecord(message)) {
+4 -12
js/components/src/streamplace-store/branding.tsx
··· 38 38 ]; 39 39 40 40 function getMetaContent(key: string): BrandingAsset | null { 41 - if (typeof window === "undefined" || !window.document) { 42 - return null; 43 - } 41 + if (typeof window === "undefined" || !window.document) return null; 44 42 const meta = document.querySelector(`meta[name="internal-brand:${key}`); 45 43 if (meta && meta.getAttribute("content")) { 46 44 let content = meta.getAttribute("content"); 47 - if (content) { 48 - return JSON.parse(content) as BrandingAsset; 49 - } 45 + if (content) return JSON.parse(content) as BrandingAsset; 50 46 } 51 47 52 48 return null; ··· 69 65 // hrmmmmmmmmmmmm 70 66 if (meta && meta.getAttribute("content")) { 71 67 let content = meta.getAttribute("content"); 72 - if (content) { 73 - acc[key] = JSON.parse(content) as BrandingAsset; 74 - } 68 + if (content) acc[key] = JSON.parse(content) as BrandingAsset; 75 69 } 76 70 return acc; 77 71 }, ··· 120 114 121 115 return useCallback( 122 116 async ({ force = true } = {}) => { 123 - if (!broadcasterDID) { 124 - return; 125 - } 117 + if (!broadcasterDID) return; 126 118 127 119 try { 128 120 store.setState({ brandingLoading: true });
+1 -3
js/components/src/streamplace-store/stream.tsx
··· 26 26 pdsAgent: StreamplaceAgent, 27 27 customThumbnail?: Blob, 28 28 ) => { 29 - if (!customThumbnail) { 30 - return undefined; 31 - } 29 + if (!customThumbnail) return undefined; 32 30 33 31 abortRef.current = new AbortController(); 34 32 const { signal } = abortRef.current;
+1 -3
js/components/src/streamplace-store/xrpc.tsx
··· 10 10 // - SessionManager when logged in 11 11 return useMemo(() => { 12 12 if (!oauthSession) { 13 - if (oauthSession === undefined) { 14 - return null; 15 - } 13 + if (oauthSession === undefined) return null; 16 14 // TODO: change once we allow unauthed requests + profile indexing 17 15 // it's bluesky's AppView b/c otherwise we'd have goosewithpipe.jpg 18 16 // showing up everywhere
+1 -3
js/desktop/src/updater.ts
··· 110 110 }; 111 111 112 112 dialog.showMessageBox(dialogOpts).then(({ response }) => { 113 - if (response === 0) { 114 - autoUpdater.quitAndInstall(); 115 - } 113 + if (response === 0) autoUpdater.quitAndInstall(); 116 114 }); 117 115 }, 118 116 );
+4
js/docs/astro.config.mjs
··· 81 81 autogenerate: { directory: "guides/installing" }, 82 82 }, 83 83 { 84 + label: "Features (Dev)", 85 + autogenerate: { directory: "features-dev" }, 86 + }, 87 + { 84 88 label: "Video Metadata", 85 89 autogenerate: { directory: "video-metadata" }, 86 90 },
+39
js/docs/src/content/docs/features-dev/badges.md
··· 1 + --- 2 + title: badges system 3 + description: user badges for chat messages 4 + --- 5 + 6 + ## Overview 7 + 8 + Badges appear next to usernames in chat messages. they're small icons that indicate status (streamer, mod, vip, etc.). There will be max 3 badges shown at once. One of the badges is server-based (e.g. streamer, mod, node staff badge), but the other two can be selected from a pool of cosmetic badges (such as subscription badges, event badges et al.). These cosmetic badges are cryptographically signed by the issuing party, and all the user needs to do is apply them to their chat profile. Note that certain badges may appear/disappear based on the current streamer's chat tktk. 9 + 10 + ## Lexicon schemas 11 + 12 + We have three relevant lexicons. 13 + 14 + 1. **`place.stream.badge.defs`** - badge definitions and view model 15 + 16 + - defines known badge types: `mod`, `streamer`, `vip` 17 + - `badgeView` object: `{badgeType, issuer, recipient, signature?}` 18 + 19 + 2. **`place.stream.badge.issuance`** - record of badge grant 20 + 21 + - stored as atproto record (key: tid) 22 + - issued by streamer or other authorized entity 23 + - example: streamer issues vip badge to a user 24 + 25 + 3. **`place.stream.badge.display`** - user's badge selection 26 + - user-controlled record defining which badges to show 27 + - array of up to 3 `badgeSelection` objects 28 + - first slot server-controlled (mod/streamer/staff), second slot is streamer-specific (vip, subscription), third slot is user-set (event, staff2, node subscription, etc.) 29 + 30 + :::note 31 + This may get changed to be in the user's chat profile? Maybe we could have a "main" chat profile and a streamer-specific profile? 32 + ::: 33 + 34 + ## TODO 35 + 36 + - [ ] implement cryptographic signatures for badge issuance 37 + - [ ] implement badge issuance ui (streamer grants vip badges) 38 + - [ ] implement badge selection ui (users choose which badges to display) 39 + - [ ] add more badge types (subscriber, founder, staff, etc)
+108
js/docs/src/content/docs/lex-reference/badge/place-stream-badge-defs.md
··· 1 + --- 2 + title: place.stream.badge.defs 3 + description: Reference for the place.stream.badge.defs lexicon 4 + --- 5 + 6 + **Lexicon Version:** 1 7 + 8 + ## Definitions 9 + 10 + <a name="badgeview"></a> 11 + 12 + ### `badgeView` 13 + 14 + **Type:** `object` 15 + 16 + View of a badge record, with fields resolved for display. If the DID in issuer is not the current streamplace node, the signature field shall be required. 17 + 18 + **Properties:** 19 + 20 + | Name | Type | Req'd | Description | Constraints | 21 + | ----------- | -------- | ----- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------- | 22 + | `badgeType` | `string` | โœ… | | Known Values: `place.stream.badge.defs#mod`, `place.stream.badge.defs#streamer` | 23 + | `issuer` | `string` | โœ… | DID of the badge issuer. | Format: `did` | 24 + | `recipient` | `string` | โœ… | DID of the badge recipient. | Format: `did` | 25 + | `signature` | `string` | โŒ | TODO: Cryptographic signature of the badge (of a place.stream.key). | | 26 + 27 + --- 28 + 29 + <a name="mod"></a> 30 + 31 + ### `mod` 32 + 33 + **Type:** `token` 34 + 35 + This user is a moderator. Displayed with a sword icon. 36 + 37 + --- 38 + 39 + <a name="streamer"></a> 40 + 41 + ### `streamer` 42 + 43 + **Type:** `token` 44 + 45 + This user is the streamer. Displayed with a star icon. 46 + 47 + --- 48 + 49 + <a name="vip"></a> 50 + 51 + ### `vip` 52 + 53 + **Type:** `token` 54 + 55 + This user is a very important person. 56 + 57 + --- 58 + 59 + ## Lexicon Source 60 + 61 + ```json 62 + { 63 + "lexicon": 1, 64 + "id": "place.stream.badge.defs", 65 + "defs": { 66 + "badgeView": { 67 + "type": "object", 68 + "required": ["badgeType", "issuer", "recipient"], 69 + "description": "View of a badge record, with fields resolved for display. If the DID in issuer is not the current streamplace node, the signature field shall be required.", 70 + "properties": { 71 + "badgeType": { 72 + "type": "string", 73 + "knownValues": [ 74 + "place.stream.badge.defs#mod", 75 + "place.stream.badge.defs#streamer" 76 + ] 77 + }, 78 + "issuer": { 79 + "type": "string", 80 + "format": "did", 81 + "description": "DID of the badge issuer." 82 + }, 83 + "recipient": { 84 + "type": "string", 85 + "format": "did", 86 + "description": "DID of the badge recipient." 87 + }, 88 + "signature": { 89 + "type": "string", 90 + "description": "TODO: Cryptographic signature of the badge (of a place.stream.key)." 91 + } 92 + } 93 + }, 94 + "mod": { 95 + "type": "token", 96 + "description": "This user is a moderator. Displayed with a sword icon." 97 + }, 98 + "streamer": { 99 + "type": "token", 100 + "description": "This user is the streamer. Displayed with a star icon." 101 + }, 102 + "vip": { 103 + "type": "token", 104 + "description": "This user is a very important person." 105 + } 106 + } 107 + } 108 + ```
+90
js/docs/src/content/docs/lex-reference/badge/place-stream-badge-display.md
··· 1 + --- 2 + title: place.stream.badge.display 3 + description: Reference for the place.stream.badge.display lexicon 4 + --- 5 + 6 + **Lexicon Version:** 1 7 + 8 + ## Definitions 9 + 10 + <a name="main"></a> 11 + 12 + ### `main` 13 + 14 + **Type:** `record` 15 + 16 + Record issuing a badge to a user. 17 + 18 + **Record Properties:** 19 + 20 + | Name | Type | Req'd | Description | Constraints | 21 + | -------- | --------------------------------------------- | ----- | ----------------------------------------------------------------------------------------------------------------------- | ------------ | 22 + | `badges` | Array of [`#badgeSelection`](#badgeselection) | โœ… | Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable. | Max Items: 3 | 23 + 24 + --- 25 + 26 + <a name="badgeselection"></a> 27 + 28 + ### `badgeSelection` 29 + 30 + **Type:** `object` 31 + 32 + A badge selected for display. May be a full badgeView from the server, or a token representing a badge type that the client can look up for display info. 33 + 34 + **Properties:** 35 + 36 + | Name | Type | Req'd | Description | Constraints | 37 + | ----------- | -------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | 38 + | `badgeType` | `string` | โœ… | | Known Values: `place.stream.badge.defs#mod`, `place.stream.badge.defs#vip` | 39 + | `issuance` | `string` | โŒ | URI of the badge issuance record (place.stream.badge.issuance) that represents this badge. Required if badgeType is not recognized. | Format: `at-uri` | 40 + 41 + --- 42 + 43 + ## Lexicon Source 44 + 45 + ```json 46 + { 47 + "lexicon": 1, 48 + "id": "place.stream.badge.display", 49 + "defs": { 50 + "main": { 51 + "type": "record", 52 + "description": "Record issuing a badge to a user.", 53 + "record": { 54 + "type": "object", 55 + "required": ["badges"], 56 + "properties": { 57 + "badges": { 58 + "type": "array", 59 + "description": "Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable.", 60 + "maxLength": 3, 61 + "items": { 62 + "type": "ref", 63 + "ref": "#badgeSelection" 64 + } 65 + } 66 + } 67 + } 68 + }, 69 + "badgeSelection": { 70 + "type": "object", 71 + "description": "A badge selected for display. May be a full badgeView from the server, or a token representing a badge type that the client can look up for display info.", 72 + "required": ["badgeType"], 73 + "properties": { 74 + "badgeType": { 75 + "type": "string", 76 + "knownValues": [ 77 + "place.stream.badge.defs#mod", 78 + "place.stream.badge.defs#vip" 79 + ] 80 + }, 81 + "issuance": { 82 + "type": "string", 83 + "format": "at-uri", 84 + "description": "URI of the badge issuance record (place.stream.badge.issuance) that represents this badge. Required if badgeType is not recognized." 85 + } 86 + } 87 + } 88 + } 89 + } 90 + ```
+63
js/docs/src/content/docs/lex-reference/badge/place-stream-badge-issuance.md
··· 1 + --- 2 + title: place.stream.badge.issuance 3 + description: Reference for the place.stream.badge.issuance lexicon 4 + --- 5 + 6 + **Lexicon Version:** 1 7 + 8 + ## Definitions 9 + 10 + <a name="main"></a> 11 + 12 + ### `main` 13 + 14 + **Type:** `record` 15 + 16 + Record issuing a badge to a user. 17 + 18 + **Record Key:** `tid` 19 + 20 + **Record Properties:** 21 + 22 + | Name | Type | Req'd | Description | Constraints | 23 + | ----------- | -------- | ----- | ------------------------------------------------------------------- | ------------------------------------------- | 24 + | `badgeType` | `string` | โœ… | | Known Values: `place.stream.badge.defs#vip` | 25 + | `recipient` | `string` | โœ… | DID of the badge recipient. | Format: `did` | 26 + | `signature` | `string` | โœ… | TODO: Cryptographic signature of the badge (of a place.stream.key). | | 27 + 28 + --- 29 + 30 + ## Lexicon Source 31 + 32 + ```json 33 + { 34 + "lexicon": 1, 35 + "id": "place.stream.badge.issuance", 36 + "defs": { 37 + "main": { 38 + "type": "record", 39 + "key": "tid", 40 + "description": "Record issuing a badge to a user.", 41 + "record": { 42 + "type": "object", 43 + "required": ["badgeType", "recipient", "signature"], 44 + "properties": { 45 + "badgeType": { 46 + "type": "string", 47 + "knownValues": ["place.stream.badge.defs#vip"] 48 + }, 49 + "recipient": { 50 + "type": "string", 51 + "format": "did", 52 + "description": "DID of the badge recipient." 53 + }, 54 + "signature": { 55 + "type": "string", 56 + "description": "TODO: Cryptographic signature of the badge (of a place.stream.key)." 57 + } 58 + } 59 + } 60 + } 61 + } 62 + } 63 + ```
+20 -10
js/docs/src/content/docs/lex-reference/chat/place-stream-chat-defs.md
··· 15 15 16 16 **Properties:** 17 17 18 - | Name | Type | Req'd | Description | Constraints | 19 - | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | -------------------------------------------------------------------------------------- | ------------------ | 20 - | `uri` | `string` | โœ… | | Format: `at-uri` | 21 - | `cid` | `string` | โœ… | | Format: `cid` | 22 - | `author` | [`app.bsky.actor.defs#profileViewBasic`](https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky/actor/defs.json#profileViewBasic) | โœ… | | | 23 - | `record` | `unknown` | โœ… | | | 24 - | `indexedAt` | `string` | โœ… | | Format: `datetime` | 25 - | `chatProfile` | [`place.stream.chat.profile`](/lex-reference/place-stream-chat-profile) | โŒ | | | 26 - | `replyTo` | Union of:<br/>&nbsp;&nbsp;[`#messageView`](#messageview) | โŒ | | | 27 - | `deleted` | `boolean` | โŒ | If true, this message has been deleted or labeled and should be cleared from the cache | | 18 + | Name | Type | Req'd | Description | Constraints | 19 + | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | 20 + | `uri` | `string` | โœ… | | Format: `at-uri` | 21 + | `cid` | `string` | โœ… | | Format: `cid` | 22 + | `author` | [`app.bsky.actor.defs#profileViewBasic`](https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky/actor/defs.json#profileViewBasic) | โœ… | | | 23 + | `record` | `unknown` | โœ… | | | 24 + | `indexedAt` | `string` | โœ… | | Format: `datetime` | 25 + | `chatProfile` | [`place.stream.chat.profile`](/lex-reference/place-stream-chat-profile) | โŒ | | | 26 + | `replyTo` | Union of:<br/>&nbsp;&nbsp;[`#messageView`](#messageview) | โŒ | | | 27 + | `deleted` | `boolean` | โŒ | If true, this message has been deleted or labeled and should be cleared from the cache | | 28 + | `badges` | Array of [`place.stream.badge.defs#badgeView`](/lex-reference/place-stream-badge-defs#badgeview) | โŒ | Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable. Tokens are looked up in badges.json for display info. | Max Items: 3 | 28 29 29 30 --- 30 31 ··· 69 70 "deleted": { 70 71 "type": "boolean", 71 72 "description": "If true, this message has been deleted or labeled and should be cleared from the cache" 73 + }, 74 + "badges": { 75 + "type": "array", 76 + "description": "Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable. Tokens are looked up in badges.json for display info.", 77 + "maxLength": 3, 78 + "items": { 79 + "type": "ref", 80 + "ref": "place.stream.badge.defs#badgeView" 81 + } 72 82 } 73 83 } 74 84 }
+46
lexicons/place/stream/badge/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.badge.defs", 4 + "defs": { 5 + "badgeView": { 6 + "type": "object", 7 + "required": ["badgeType", "issuer", "recipient"], 8 + "description": "View of a badge record, with fields resolved for display. If the DID in issuer is not the current streamplace node, the signature field shall be required.", 9 + "properties": { 10 + "badgeType": { 11 + "type": "string", 12 + "knownValues": [ 13 + "place.stream.badge.defs#mod", 14 + "place.stream.badge.defs#streamer" 15 + ] 16 + }, 17 + "issuer": { 18 + "type": "string", 19 + "format": "did", 20 + "description": "DID of the badge issuer." 21 + }, 22 + "recipient": { 23 + "type": "string", 24 + "format": "did", 25 + "description": "DID of the badge recipient." 26 + }, 27 + "signature": { 28 + "type": "string", 29 + "description": "TODO: Cryptographic signature of the badge (of a place.stream.key)." 30 + } 31 + } 32 + }, 33 + "mod": { 34 + "type": "token", 35 + "description": "This user is a moderator. Displayed with a sword icon." 36 + }, 37 + "streamer": { 38 + "type": "token", 39 + "description": "This user is the streamer. Displayed with a star icon." 40 + }, 41 + "vip": { 42 + "type": "token", 43 + "description": "This user is a very important person." 44 + } 45 + } 46 + }
+44
lexicons/place/stream/badge/display.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.badge.display", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Record issuing a badge to a user.", 8 + "record": { 9 + "type": "object", 10 + "required": ["badges"], 11 + "properties": { 12 + "badges": { 13 + "type": "array", 14 + "description": "Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable.", 15 + "maxLength": 3, 16 + "items": { 17 + "type": "ref", 18 + "ref": "#badgeSelection" 19 + } 20 + } 21 + } 22 + } 23 + }, 24 + "badgeSelection": { 25 + "type": "object", 26 + "description": "A badge selected for display. May be a full badgeView from the server, or a token representing a badge type that the client can look up for display info.", 27 + "required": ["badgeType"], 28 + "properties": { 29 + "badgeType": { 30 + "type": "string", 31 + "knownValues": [ 32 + "place.stream.badge.defs#mod", 33 + "place.stream.badge.defs#vip" 34 + ] 35 + }, 36 + "issuance": { 37 + "type": "string", 38 + "format": "at-uri", 39 + "description": "URI of the badge issuance record (place.stream.badge.issuance) that represents this badge. Required if badgeType is not recognized." 40 + } 41 + } 42 + } 43 + } 44 + }
+30
lexicons/place/stream/badge/issuance.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.badge.issuance", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "description": "Record issuing a badge to a user.", 9 + "record": { 10 + "type": "object", 11 + "required": ["badgeType", "recipient", "signature"], 12 + "properties": { 13 + "badgeType": { 14 + "type": "string", 15 + "knownValues": ["place.stream.badge.defs#vip"] 16 + }, 17 + "recipient": { 18 + "type": "string", 19 + "format": "did", 20 + "description": "DID of the badge recipient." 21 + }, 22 + "signature": { 23 + "type": "string", 24 + "description": "TODO: Cryptographic signature of the badge (of a place.stream.key)." 25 + } 26 + } 27 + } 28 + } 29 + } 30 + }
+9
lexicons/place/stream/chat/defs.json
··· 25 25 "deleted": { 26 26 "type": "boolean", 27 27 "description": "If true, this message has been deleted or labeled and should be cleared from the cache" 28 + }, 29 + "badges": { 30 + "type": "array", 31 + "description": "Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable. Tokens are looked up in badges.json for display info.", 32 + "maxLength": 3, 33 + "items": { 34 + "type": "ref", 35 + "ref": "place.stream.badge.defs#badgeView" 36 + } 28 37 } 29 38 } 30 39 }
-1
package.json
··· 34 34 "lerna": "^8.2.2", 35 35 "lint-staged": "^15.2.10", 36 36 "prettier": "3.4.2", 37 - "prettier-plugin-curly": "^0.4.1", 38 37 "typescript": "^5.8.3" 39 38 }, 40 39 "workspaces": [
+9
pkg/api/websocket.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "fmt" 6 7 "net" 7 8 "net/http" 8 9 "time" ··· 12 13 "github.com/gorilla/websocket" 13 14 "github.com/julienschmidt/httprouter" 14 15 16 + "stream.place/streamplace/pkg/atproto" 15 17 apierrors "stream.place/streamplace/pkg/errors" 16 18 "stream.place/streamplace/pkg/log" 17 19 "stream.place/streamplace/pkg/renditions" ··· 237 239 log.Error(ctx, "could not get chat messages", "error", err) 238 240 return 239 241 } 242 + 243 + // Add mod badges to messages 244 + issuerDID := fmt.Sprintf("did:web:%s", a.CLI.BroadcasterHost) 240 245 for _, message := range messages { 246 + err := atproto.AddModBadgeIfApplicable(ctx, message, repoDID, issuerDID, a.Model) 247 + if err != nil { 248 + log.Error(ctx, "failed to add mod badge to message", "error", err) 249 + } 241 250 initialBurst <- message 242 251 } 243 252 }()
+61
pkg/atproto/badges.go
··· 1 + package atproto 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + 7 + "stream.place/streamplace/pkg/constants" 8 + "stream.place/streamplace/pkg/log" 9 + "stream.place/streamplace/pkg/model" 10 + "stream.place/streamplace/pkg/streamplace" 11 + ) 12 + 13 + // AddModBadgeIfApplicable checks if a message author has mod permissions for the streamer 14 + // and adds a mod or streamer badge as the first badge (server-controlled). 15 + // - If the author is the streamer, adds a "streamer" badge 16 + // - If the author has moderation permissions, adds a "mod" badge 17 + func AddModBadgeIfApplicable(ctx context.Context, message *streamplace.ChatDefs_MessageView, streamerDID string, issuerDID string, m model.Model) error { 18 + if message == nil { 19 + return fmt.Errorf("message is nil") 20 + } 21 + 22 + authorDID := message.Author.Did 23 + 24 + var badge *streamplace.BadgeDefs_BadgeView 25 + 26 + // Check if author is the streamer 27 + if authorDID == streamerDID { 28 + badge = &streamplace.BadgeDefs_BadgeView{ 29 + BadgeType: constants.BadgeTypeStreamer, 30 + Issuer: issuerDID, 31 + Recipient: authorDID, 32 + } 33 + } else { 34 + // Check if author has any moderation permissions for the streamer 35 + delegations, err := m.GetModerationDelegations(ctx, streamerDID, authorDID) 36 + if err != nil { 37 + log.Error(ctx, "failed to get moderation delegations", "err", err, "authorDID", authorDID, "streamerDID", streamerDID) 38 + return err 39 + } 40 + 41 + // If the author has any delegations (meaning they're a moderator), add a mod badge 42 + if len(delegations) > 0 { 43 + badge = &streamplace.BadgeDefs_BadgeView{ 44 + BadgeType: constants.BadgeTypeMod, 45 + Issuer: issuerDID, 46 + Recipient: authorDID, 47 + } 48 + } 49 + } 50 + 51 + // Prepend the badge if one was created (server-controlled badge is first) 52 + if badge != nil { 53 + if message.Badges == nil { 54 + message.Badges = []*streamplace.BadgeDefs_BadgeView{badge} 55 + } else { 56 + message.Badges = append([]*streamplace.BadgeDefs_BadgeView{badge}, message.Badges...) 57 + } 58 + } 59 + 60 + return nil 61 + }
+100
pkg/atproto/badges_test.go
··· 1 + package atproto 2 + 3 + import ( 4 + "context" 5 + "testing" 6 + "time" 7 + 8 + bsky "github.com/bluesky-social/indigo/api/bsky" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "github.com/bluesky-social/indigo/util" 11 + "github.com/stretchr/testify/require" 12 + "stream.place/streamplace/pkg/model" 13 + "stream.place/streamplace/pkg/streamplace" 14 + ) 15 + 16 + func TestAddModBadge(t *testing.T) { 17 + ctx := context.Background() 18 + 19 + mod, err := model.MakeDB(":memory:") 20 + require.NoError(t, err) 21 + 22 + streamerDID := "did:plc:streamer" 23 + moderatorDID := "did:plc:moderator" 24 + issuerDID := "did:web:example.com" 25 + 26 + // Create a chat message 27 + message := &streamplace.ChatDefs_MessageView{ 28 + LexiconTypeID: "place.stream.chat.defs#messageView", 29 + Uri: "at://test/place.stream.chat.message/123", 30 + Cid: "test-cid", 31 + Author: &bsky.ActorDefs_ProfileViewBasic{ 32 + Did: moderatorDID, 33 + Handle: "moderator.test", 34 + }, 35 + IndexedAt: "2024-01-01T00:00:00Z", 36 + } 37 + 38 + t.Run("no badge when user is not a moderator", func(t *testing.T) { 39 + msg := *message // copy 40 + err := AddModBadgeIfApplicable(ctx, &msg, streamerDID, issuerDID, mod) 41 + require.NoError(t, err) 42 + require.Nil(t, msg.Badges, "should not have badges when user is not a moderator") 43 + }) 44 + 45 + t.Run("adds streamer badge when user is the streamer", func(t *testing.T) { 46 + msg := *message // copy 47 + msg.Author = &bsky.ActorDefs_ProfileViewBasic{ 48 + Did: streamerDID, 49 + Handle: "streamer.test", 50 + } 51 + err := AddModBadgeIfApplicable(ctx, &msg, streamerDID, issuerDID, mod) 52 + require.NoError(t, err) 53 + require.Len(t, msg.Badges, 1, "should have 1 badge when user is the streamer") 54 + require.Equal(t, "place.stream.badge.defs#streamer", msg.Badges[0].BadgeType) 55 + require.Equal(t, issuerDID, msg.Badges[0].Issuer) 56 + require.Equal(t, streamerDID, msg.Badges[0].Recipient) 57 + }) 58 + 59 + t.Run("adds mod badge when user has moderation permissions", func(t *testing.T) { 60 + // Grant moderation permissions to the moderator 61 + perm := &streamplace.ModerationPermission{ 62 + LexiconTypeID: "place.stream.moderation.permission", 63 + Moderator: moderatorDID, 64 + Permissions: []string{"ban", "hide"}, 65 + CreatedAt: time.Now().Format(util.ISO8601), 66 + } 67 + aturi, err := syntax.ParseATURI("at://" + streamerDID + "/place.stream.moderation.permission/test123") 68 + require.NoError(t, err) 69 + 70 + // Sync the permission to the model 71 + err = mod.CreateModerationDelegation(ctx, perm, aturi) 72 + require.NoError(t, err) 73 + 74 + msg := *message // copy 75 + err = AddModBadgeIfApplicable(ctx, &msg, streamerDID, issuerDID, mod) 76 + require.NoError(t, err) 77 + require.Len(t, msg.Badges, 1, "should have 1 badge when user is a moderator") 78 + require.Equal(t, "place.stream.badges.badge#mod", msg.Badges[0].BadgeType) 79 + require.Equal(t, issuerDID, msg.Badges[0].Issuer) 80 + require.Equal(t, moderatorDID, msg.Badges[0].Recipient) 81 + }) 82 + 83 + t.Run("prepends mod badge to existing badges", func(t *testing.T) { 84 + // Create message with existing user-settable badge 85 + msg := *message // copy 86 + msg.Badges = []*streamplace.BadgeDefs_BadgeView{ 87 + { 88 + BadgeType: "place.stream.badges.badge#vip", 89 + Issuer: "did:web:other.com", 90 + Recipient: moderatorDID, 91 + }, 92 + } 93 + 94 + err = AddModBadgeIfApplicable(ctx, &msg, streamerDID, issuerDID, mod) 95 + require.NoError(t, err) 96 + require.Len(t, msg.Badges, 2, "should have 2 badges") 97 + require.Equal(t, "place.stream.badges.badge#mod", msg.Badges[0].BadgeType, "mod badge should be first") 98 + require.Equal(t, "place.stream.badges.badge#vip", msg.Badges[1].BadgeType, "vip badge should be second") 99 + }) 100 + }
+8
pkg/atproto/sync.go
··· 150 150 log.Error(ctx, "failed to convert chat message to streamplace message view", "err", err) 151 151 return nil 152 152 } 153 + 154 + // Add mod badge if the author is a moderator 155 + issuerDID := fmt.Sprintf("did:web:%s", atsync.CLI.BroadcasterHost) 156 + err = AddModBadgeIfApplicable(ctx, scm, rec.Streamer, issuerDID, atsync.Model) 157 + if err != nil { 158 + log.Error(ctx, "failed to add mod badge", "err", err) 159 + } 160 + 153 161 go atsync.Bus.Publish(rec.Streamer, scm) 154 162 155 163 if !isUpdate && !isFirstSync {
+6
pkg/constants/constants.go
··· 15 15 var PLACE_STREAM_DEFAULT_METADATA = "place.stream.metadata.configuration" //nolint:all 16 16 var PLACE_STREAM_LIVE_RECOMMENDATIONS = "place.stream.live.recommendations" //nolint:all 17 17 18 + // Streamplace badge types 19 + const ( 20 + BadgeTypeMod = "place.stream.badge.defs#mod" 21 + BadgeTypeStreamer = "place.stream.badge.defs#streamer" 22 + ) 23 + 18 24 const DID_KEY_PREFIX = "did:key" //nolint:all 19 25 const ADDRESS_KEY_PREFIX = "0x" //nolint:all 20 26
+3
pkg/gen/gen.go
··· 36 36 streamplace.ModerationPermission{}, 37 37 streamplace.LiveTeleport{}, 38 38 streamplace.LiveRecommendations{}, 39 + streamplace.BadgeIssuance{}, 40 + streamplace.BadgeDisplay{}, 41 + streamplace.BadgeDisplay_BadgeSelection{}, 39 42 ); err != nil { 40 43 panic(err) 41 44 }
+18
pkg/streamplace/badgedefs.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.badge.defs 4 + 5 + package streamplace 6 + 7 + // BadgeDefs_BadgeView is a "badgeView" in the place.stream.badge.defs schema. 8 + // 9 + // View of a badge record, with fields resolved for display. If the DID in issuer is not the current streamplace node, the signature field shall be required. 10 + type BadgeDefs_BadgeView struct { 11 + BadgeType string `json:"badgeType" cborgen:"badgeType"` 12 + // issuer: DID of the badge issuer. 13 + Issuer string `json:"issuer" cborgen:"issuer"` 14 + // recipient: DID of the badge recipient. 15 + Recipient string `json:"recipient" cborgen:"recipient"` 16 + // signature: TODO: Cryptographic signature of the badge (of a place.stream.key). 17 + Signature *string `json:"signature,omitempty" cborgen:"signature,omitempty"` 18 + }
+28
pkg/streamplace/badgedisplay.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.badge.display 4 + 5 + package streamplace 6 + 7 + import ( 8 + lexutil "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + func init() { 12 + lexutil.RegisterType("place.stream.badge.display", &BadgeDisplay{}) 13 + } 14 + 15 + type BadgeDisplay struct { 16 + LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.badge.display"` 17 + // badges: Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable. 18 + Badges []*BadgeDisplay_BadgeSelection `json:"badges" cborgen:"badges"` 19 + } 20 + 21 + // BadgeDisplay_BadgeSelection is a "badgeSelection" in the place.stream.badge.display schema. 22 + // 23 + // A badge selected for display. May be a full badgeView from the server, or a token representing a badge type that the client can look up for display info. 24 + type BadgeDisplay_BadgeSelection struct { 25 + BadgeType string `json:"badgeType" cborgen:"badgeType"` 26 + // issuance: URI of the badge issuance record (place.stream.badge.issuance) that represents this badge. Required if badgeType is not recognized. 27 + Issuance *string `json:"issuance,omitempty" cborgen:"issuance,omitempty"` 28 + }
+22
pkg/streamplace/badgeissuance.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.badge.issuance 4 + 5 + package streamplace 6 + 7 + import ( 8 + lexutil "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + func init() { 12 + lexutil.RegisterType("place.stream.badge.issuance", &BadgeIssuance{}) 13 + } 14 + 15 + type BadgeIssuance struct { 16 + LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.badge.issuance"` 17 + BadgeType string `json:"badgeType" cborgen:"badgeType"` 18 + // recipient: DID of the badge recipient. 19 + Recipient string `json:"recipient" cborgen:"recipient"` 20 + // signature: TODO: Cryptographic signature of the badge (of a place.stream.key). 21 + Signature string `json:"signature" cborgen:"signature"` 22 + }
+527
pkg/streamplace/cbor_gen.go
··· 5966 5966 5967 5967 return nil 5968 5968 } 5969 + func (t *BadgeIssuance) MarshalCBOR(w io.Writer) error { 5970 + if t == nil { 5971 + _, err := w.Write(cbg.CborNull) 5972 + return err 5973 + } 5974 + 5975 + cw := cbg.NewCborWriter(w) 5976 + 5977 + if _, err := cw.Write([]byte{164}); err != nil { 5978 + return err 5979 + } 5980 + 5981 + // t.LexiconTypeID (string) (string) 5982 + if len("$type") > 1000000 { 5983 + return xerrors.Errorf("Value in field \"$type\" was too long") 5984 + } 5985 + 5986 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 5987 + return err 5988 + } 5989 + if _, err := cw.WriteString(string("$type")); err != nil { 5990 + return err 5991 + } 5992 + 5993 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("place.stream.badge.issuance"))); err != nil { 5994 + return err 5995 + } 5996 + if _, err := cw.WriteString(string("place.stream.badge.issuance")); err != nil { 5997 + return err 5998 + } 5999 + 6000 + // t.BadgeType (string) (string) 6001 + if len("badgeType") > 1000000 { 6002 + return xerrors.Errorf("Value in field \"badgeType\" was too long") 6003 + } 6004 + 6005 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("badgeType"))); err != nil { 6006 + return err 6007 + } 6008 + if _, err := cw.WriteString(string("badgeType")); err != nil { 6009 + return err 6010 + } 6011 + 6012 + if len(t.BadgeType) > 1000000 { 6013 + return xerrors.Errorf("Value in field t.BadgeType was too long") 6014 + } 6015 + 6016 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.BadgeType))); err != nil { 6017 + return err 6018 + } 6019 + if _, err := cw.WriteString(string(t.BadgeType)); err != nil { 6020 + return err 6021 + } 6022 + 6023 + // t.Recipient (string) (string) 6024 + if len("recipient") > 1000000 { 6025 + return xerrors.Errorf("Value in field \"recipient\" was too long") 6026 + } 6027 + 6028 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("recipient"))); err != nil { 6029 + return err 6030 + } 6031 + if _, err := cw.WriteString(string("recipient")); err != nil { 6032 + return err 6033 + } 6034 + 6035 + if len(t.Recipient) > 1000000 { 6036 + return xerrors.Errorf("Value in field t.Recipient was too long") 6037 + } 6038 + 6039 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Recipient))); err != nil { 6040 + return err 6041 + } 6042 + if _, err := cw.WriteString(string(t.Recipient)); err != nil { 6043 + return err 6044 + } 6045 + 6046 + // t.Signature (string) (string) 6047 + if len("signature") > 1000000 { 6048 + return xerrors.Errorf("Value in field \"signature\" was too long") 6049 + } 6050 + 6051 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("signature"))); err != nil { 6052 + return err 6053 + } 6054 + if _, err := cw.WriteString(string("signature")); err != nil { 6055 + return err 6056 + } 6057 + 6058 + if len(t.Signature) > 1000000 { 6059 + return xerrors.Errorf("Value in field t.Signature was too long") 6060 + } 6061 + 6062 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Signature))); err != nil { 6063 + return err 6064 + } 6065 + if _, err := cw.WriteString(string(t.Signature)); err != nil { 6066 + return err 6067 + } 6068 + return nil 6069 + } 6070 + 6071 + func (t *BadgeIssuance) UnmarshalCBOR(r io.Reader) (err error) { 6072 + *t = BadgeIssuance{} 6073 + 6074 + cr := cbg.NewCborReader(r) 6075 + 6076 + maj, extra, err := cr.ReadHeader() 6077 + if err != nil { 6078 + return err 6079 + } 6080 + defer func() { 6081 + if err == io.EOF { 6082 + err = io.ErrUnexpectedEOF 6083 + } 6084 + }() 6085 + 6086 + if maj != cbg.MajMap { 6087 + return fmt.Errorf("cbor input should be of type map") 6088 + } 6089 + 6090 + if extra > cbg.MaxLength { 6091 + return fmt.Errorf("BadgeIssuance: map struct too large (%d)", extra) 6092 + } 6093 + 6094 + n := extra 6095 + 6096 + nameBuf := make([]byte, 9) 6097 + for i := uint64(0); i < n; i++ { 6098 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 6099 + if err != nil { 6100 + return err 6101 + } 6102 + 6103 + if !ok { 6104 + // Field doesn't exist on this type, so ignore it 6105 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 6106 + return err 6107 + } 6108 + continue 6109 + } 6110 + 6111 + switch string(nameBuf[:nameLen]) { 6112 + // t.LexiconTypeID (string) (string) 6113 + case "$type": 6114 + 6115 + { 6116 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6117 + if err != nil { 6118 + return err 6119 + } 6120 + 6121 + t.LexiconTypeID = string(sval) 6122 + } 6123 + // t.BadgeType (string) (string) 6124 + case "badgeType": 6125 + 6126 + { 6127 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6128 + if err != nil { 6129 + return err 6130 + } 6131 + 6132 + t.BadgeType = string(sval) 6133 + } 6134 + // t.Recipient (string) (string) 6135 + case "recipient": 6136 + 6137 + { 6138 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6139 + if err != nil { 6140 + return err 6141 + } 6142 + 6143 + t.Recipient = string(sval) 6144 + } 6145 + // t.Signature (string) (string) 6146 + case "signature": 6147 + 6148 + { 6149 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6150 + if err != nil { 6151 + return err 6152 + } 6153 + 6154 + t.Signature = string(sval) 6155 + } 6156 + 6157 + default: 6158 + // Field doesn't exist on this type, so ignore it 6159 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 6160 + return err 6161 + } 6162 + } 6163 + } 6164 + 6165 + return nil 6166 + } 6167 + func (t *BadgeDisplay) MarshalCBOR(w io.Writer) error { 6168 + if t == nil { 6169 + _, err := w.Write(cbg.CborNull) 6170 + return err 6171 + } 6172 + 6173 + cw := cbg.NewCborWriter(w) 6174 + 6175 + if _, err := cw.Write([]byte{162}); err != nil { 6176 + return err 6177 + } 6178 + 6179 + // t.LexiconTypeID (string) (string) 6180 + if len("$type") > 1000000 { 6181 + return xerrors.Errorf("Value in field \"$type\" was too long") 6182 + } 6183 + 6184 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 6185 + return err 6186 + } 6187 + if _, err := cw.WriteString(string("$type")); err != nil { 6188 + return err 6189 + } 6190 + 6191 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("place.stream.badge.display"))); err != nil { 6192 + return err 6193 + } 6194 + if _, err := cw.WriteString(string("place.stream.badge.display")); err != nil { 6195 + return err 6196 + } 6197 + 6198 + // t.Badges ([]*streamplace.BadgeDisplay_BadgeSelection) (slice) 6199 + if len("badges") > 1000000 { 6200 + return xerrors.Errorf("Value in field \"badges\" was too long") 6201 + } 6202 + 6203 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("badges"))); err != nil { 6204 + return err 6205 + } 6206 + if _, err := cw.WriteString(string("badges")); err != nil { 6207 + return err 6208 + } 6209 + 6210 + if len(t.Badges) > 8192 { 6211 + return xerrors.Errorf("Slice value in field t.Badges was too long") 6212 + } 6213 + 6214 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Badges))); err != nil { 6215 + return err 6216 + } 6217 + for _, v := range t.Badges { 6218 + if err := v.MarshalCBOR(cw); err != nil { 6219 + return err 6220 + } 6221 + 6222 + } 6223 + return nil 6224 + } 6225 + 6226 + func (t *BadgeDisplay) UnmarshalCBOR(r io.Reader) (err error) { 6227 + *t = BadgeDisplay{} 6228 + 6229 + cr := cbg.NewCborReader(r) 6230 + 6231 + maj, extra, err := cr.ReadHeader() 6232 + if err != nil { 6233 + return err 6234 + } 6235 + defer func() { 6236 + if err == io.EOF { 6237 + err = io.ErrUnexpectedEOF 6238 + } 6239 + }() 6240 + 6241 + if maj != cbg.MajMap { 6242 + return fmt.Errorf("cbor input should be of type map") 6243 + } 6244 + 6245 + if extra > cbg.MaxLength { 6246 + return fmt.Errorf("BadgeDisplay: map struct too large (%d)", extra) 6247 + } 6248 + 6249 + n := extra 6250 + 6251 + nameBuf := make([]byte, 6) 6252 + for i := uint64(0); i < n; i++ { 6253 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 6254 + if err != nil { 6255 + return err 6256 + } 6257 + 6258 + if !ok { 6259 + // Field doesn't exist on this type, so ignore it 6260 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 6261 + return err 6262 + } 6263 + continue 6264 + } 6265 + 6266 + switch string(nameBuf[:nameLen]) { 6267 + // t.LexiconTypeID (string) (string) 6268 + case "$type": 6269 + 6270 + { 6271 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6272 + if err != nil { 6273 + return err 6274 + } 6275 + 6276 + t.LexiconTypeID = string(sval) 6277 + } 6278 + // t.Badges ([]*streamplace.BadgeDisplay_BadgeSelection) (slice) 6279 + case "badges": 6280 + 6281 + maj, extra, err = cr.ReadHeader() 6282 + if err != nil { 6283 + return err 6284 + } 6285 + 6286 + if extra > 8192 { 6287 + return fmt.Errorf("t.Badges: array too large (%d)", extra) 6288 + } 6289 + 6290 + if maj != cbg.MajArray { 6291 + return fmt.Errorf("expected cbor array") 6292 + } 6293 + 6294 + if extra > 0 { 6295 + t.Badges = make([]*BadgeDisplay_BadgeSelection, extra) 6296 + } 6297 + 6298 + for i := 0; i < int(extra); i++ { 6299 + { 6300 + var maj byte 6301 + var extra uint64 6302 + var err error 6303 + _ = maj 6304 + _ = extra 6305 + _ = err 6306 + 6307 + { 6308 + 6309 + b, err := cr.ReadByte() 6310 + if err != nil { 6311 + return err 6312 + } 6313 + if b != cbg.CborNull[0] { 6314 + if err := cr.UnreadByte(); err != nil { 6315 + return err 6316 + } 6317 + t.Badges[i] = new(BadgeDisplay_BadgeSelection) 6318 + if err := t.Badges[i].UnmarshalCBOR(cr); err != nil { 6319 + return xerrors.Errorf("unmarshaling t.Badges[i] pointer: %w", err) 6320 + } 6321 + } 6322 + 6323 + } 6324 + 6325 + } 6326 + } 6327 + 6328 + default: 6329 + // Field doesn't exist on this type, so ignore it 6330 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 6331 + return err 6332 + } 6333 + } 6334 + } 6335 + 6336 + return nil 6337 + } 6338 + func (t *BadgeDisplay_BadgeSelection) MarshalCBOR(w io.Writer) error { 6339 + if t == nil { 6340 + _, err := w.Write(cbg.CborNull) 6341 + return err 6342 + } 6343 + 6344 + cw := cbg.NewCborWriter(w) 6345 + fieldCount := 2 6346 + 6347 + if t.Issuance == nil { 6348 + fieldCount-- 6349 + } 6350 + 6351 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 6352 + return err 6353 + } 6354 + 6355 + // t.Issuance (string) (string) 6356 + if t.Issuance != nil { 6357 + 6358 + if len("issuance") > 1000000 { 6359 + return xerrors.Errorf("Value in field \"issuance\" was too long") 6360 + } 6361 + 6362 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("issuance"))); err != nil { 6363 + return err 6364 + } 6365 + if _, err := cw.WriteString(string("issuance")); err != nil { 6366 + return err 6367 + } 6368 + 6369 + if t.Issuance == nil { 6370 + if _, err := cw.Write(cbg.CborNull); err != nil { 6371 + return err 6372 + } 6373 + } else { 6374 + if len(*t.Issuance) > 1000000 { 6375 + return xerrors.Errorf("Value in field t.Issuance was too long") 6376 + } 6377 + 6378 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Issuance))); err != nil { 6379 + return err 6380 + } 6381 + if _, err := cw.WriteString(string(*t.Issuance)); err != nil { 6382 + return err 6383 + } 6384 + } 6385 + } 6386 + 6387 + // t.BadgeType (string) (string) 6388 + if len("badgeType") > 1000000 { 6389 + return xerrors.Errorf("Value in field \"badgeType\" was too long") 6390 + } 6391 + 6392 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("badgeType"))); err != nil { 6393 + return err 6394 + } 6395 + if _, err := cw.WriteString(string("badgeType")); err != nil { 6396 + return err 6397 + } 6398 + 6399 + if len(t.BadgeType) > 1000000 { 6400 + return xerrors.Errorf("Value in field t.BadgeType was too long") 6401 + } 6402 + 6403 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.BadgeType))); err != nil { 6404 + return err 6405 + } 6406 + if _, err := cw.WriteString(string(t.BadgeType)); err != nil { 6407 + return err 6408 + } 6409 + return nil 6410 + } 6411 + 6412 + func (t *BadgeDisplay_BadgeSelection) UnmarshalCBOR(r io.Reader) (err error) { 6413 + *t = BadgeDisplay_BadgeSelection{} 6414 + 6415 + cr := cbg.NewCborReader(r) 6416 + 6417 + maj, extra, err := cr.ReadHeader() 6418 + if err != nil { 6419 + return err 6420 + } 6421 + defer func() { 6422 + if err == io.EOF { 6423 + err = io.ErrUnexpectedEOF 6424 + } 6425 + }() 6426 + 6427 + if maj != cbg.MajMap { 6428 + return fmt.Errorf("cbor input should be of type map") 6429 + } 6430 + 6431 + if extra > cbg.MaxLength { 6432 + return fmt.Errorf("BadgeDisplay_BadgeSelection: map struct too large (%d)", extra) 6433 + } 6434 + 6435 + n := extra 6436 + 6437 + nameBuf := make([]byte, 9) 6438 + for i := uint64(0); i < n; i++ { 6439 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 6440 + if err != nil { 6441 + return err 6442 + } 6443 + 6444 + if !ok { 6445 + // Field doesn't exist on this type, so ignore it 6446 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 6447 + return err 6448 + } 6449 + continue 6450 + } 6451 + 6452 + switch string(nameBuf[:nameLen]) { 6453 + // t.Issuance (string) (string) 6454 + case "issuance": 6455 + 6456 + { 6457 + b, err := cr.ReadByte() 6458 + if err != nil { 6459 + return err 6460 + } 6461 + if b != cbg.CborNull[0] { 6462 + if err := cr.UnreadByte(); err != nil { 6463 + return err 6464 + } 6465 + 6466 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6467 + if err != nil { 6468 + return err 6469 + } 6470 + 6471 + t.Issuance = (*string)(&sval) 6472 + } 6473 + } 6474 + // t.BadgeType (string) (string) 6475 + case "badgeType": 6476 + 6477 + { 6478 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6479 + if err != nil { 6480 + return err 6481 + } 6482 + 6483 + t.BadgeType = string(sval) 6484 + } 6485 + 6486 + default: 6487 + // Field doesn't exist on this type, so ignore it 6488 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 6489 + return err 6490 + } 6491 + } 6492 + } 6493 + 6494 + return nil 6495 + }
+4 -2
pkg/streamplace/chatdefs.go
··· 16 16 type ChatDefs_MessageView struct { 17 17 LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.chat.defs#messageView"` 18 18 Author *appbsky.ActorDefs_ProfileViewBasic `json:"author" cborgen:"author"` 19 - ChatProfile *ChatProfile `json:"chatProfile,omitempty" cborgen:"chatProfile,omitempty"` 20 - Cid string `json:"cid" cborgen:"cid"` 19 + // badges: Up to 3 badge tokens to display with the message. First badge is server-controlled, remaining badges are user-settable. Tokens are looked up in badges.json for display info. 20 + Badges []*BadgeDefs_BadgeView `json:"badges,omitempty" cborgen:"badges,omitempty"` 21 + ChatProfile *ChatProfile `json:"chatProfile,omitempty" cborgen:"chatProfile,omitempty"` 22 + Cid string `json:"cid" cborgen:"cid"` 21 23 // deleted: If true, this message has been deleted or labeled and should be cleared from the cache 22 24 Deleted *bool `json:"deleted,omitempty" cborgen:"deleted,omitempty"` 23 25 IndexedAt string `json:"indexedAt" cborgen:"indexedAt"`
+1 -15
pnpm-lock.yaml
··· 42 42 prettier: 43 43 specifier: 3.4.2 44 44 version: 3.4.2 45 - prettier-plugin-curly: 46 - specifier: ^0.4.1 47 - version: 0.4.1(prettier@3.4.2) 48 45 typescript: 49 46 specifier: ^5.8.3 50 47 version: 5.8.3 ··· 7822 7819 glob@8.1.0: 7823 7820 resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} 7824 7821 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 7822 + deprecated: Glob versions prior to v9 are no longer supported 7826 7823 7827 7824 glob@9.3.5: 7828 7825 resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} 7829 7826 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 7827 7832 7828 global-agent@3.0.0: 7833 7829 resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} ··· 10645 10641 prelude-ls@1.2.1: 10646 10642 resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} 10647 10643 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 10644 10655 10645 prettier-plugin-organize-imports@4.1.0: 10656 10646 resolution: {integrity: sha512-5aWRdCgv645xaa58X8lOxzZoiHAldAPChljr/MT0crXVOWTZ+Svl4hIWlz+niYSlO6ikE5UXkN1JrRvIP2ut0A==} ··· 26943 26933 tunnel-agent: 0.6.0 26944 26934 26945 26935 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 26936 26951 26937 prettier-plugin-organize-imports@4.1.0(prettier@3.4.2)(typescript@5.8.3): 26952 26938 dependencies: