diff --git a/js/app/components/mobile/player.tsx b/js/app/components/mobile/player.tsx
index 5ccb1a0e4..cd1beab13 100644
--- a/js/app/components/mobile/player.tsx
+++ b/js/app/components/mobile/player.tsx
@@ -511,7 +511,17 @@ export function PlayerInner(
)
)}
- {!props.showUnavailable && }
+ {props.mode !== "vod" && !props.showUnavailable && (
+
+ )}
+ {props.mode === "vod" && (
+
+
+
+
+ )}
x.url);
+ if (!src) return BBB_HLS_URL;
+ if (src.startsWith("http://") || src.startsWith("https://")) return src;
+ return `${serverUrl}/api/playback/${src}/hls/index.m3u8`;
+}
+
+export default function VideoScreen({
+ route,
+}: {
+ route?: { params?: { src?: string } };
+}) {
+ const src = route?.params?.src;
+ const url = usePlaybackUrl(src);
+
+ return (
+
+
+
+
+ {src && (
+
+
+ {src}
+
+
+ )}
+
+ );
+}
diff --git a/js/components/src/components/mobile-player/player.tsx b/js/components/src/components/mobile-player/player.tsx
index edd6372a4..96ac673f5 100644
--- a/js/components/src/components/mobile-player/player.tsx
+++ b/js/components/src/components/mobile-player/player.tsx
@@ -28,6 +28,7 @@ export function Player(
const setReportingURL = usePlayerStore((x) => x.setReportingURL);
const setEmbedded = usePlayerStore((x) => x.setEmbedded);
+ const setMode = usePlayerStore((x) => x.setMode);
const reportModalOpen = usePlayerStore((x) => x.reportModalOpen);
const setReportModalOpen = usePlayerStore((x) => x.setReportModalOpen);
@@ -58,6 +59,10 @@ export function Player(
setEmbedded(props.embedded ?? false);
}, [props.embedded]);
+ useEffect(() => {
+ setMode(props.mode ?? "live");
+ }, [props.mode]);
+
// Will call back every few seconds to send health updates
usePlayerStatus();
diff --git a/js/components/src/components/mobile-player/props.tsx b/js/components/src/components/mobile-player/props.tsx
index 7b4c5e430..6b00e9353 100644
--- a/js/components/src/components/mobile-player/props.tsx
+++ b/js/components/src/components/mobile-player/props.tsx
@@ -1,8 +1,10 @@
+import { PlayerMode } from "../../player-store/player-state";
+
export type PlayerProps = {
name: string;
playerId?: string;
src: string;
- mode?: "live" | "vod";
+ mode?: PlayerMode;
muted: boolean;
telemetry: boolean;
fullscreen: boolean;
diff --git a/js/components/src/components/mobile-player/ui/index.ts b/js/components/src/components/mobile-player/ui/index.ts
index e30e98da2..ac94bddfc 100644
--- a/js/components/src/components/mobile-player/ui/index.ts
+++ b/js/components/src/components/mobile-player/ui/index.ts
@@ -3,9 +3,11 @@ export * from "./autoplay-button";
export * from "./countdown";
export * from "./input";
export * from "./metrics";
+export * from "./seek-bar";
export * from "./streamer-context-menu";
export * from "./streamer-loading-overlay";
export * from "./viewer-context-menu";
export * from "./viewer-count";
export * from "./viewer-loading-overlay";
export * from "./viewers";
+export * from "./vod-controls";
diff --git a/js/components/src/components/mobile-player/ui/seek-bar.tsx b/js/components/src/components/mobile-player/ui/seek-bar.tsx
new file mode 100644
index 000000000..eb4f8801a
--- /dev/null
+++ b/js/components/src/components/mobile-player/ui/seek-bar.tsx
@@ -0,0 +1,192 @@
+import { useCallback, useRef, useState } from "react";
+import Animated, {
+ useAnimatedStyle,
+ useSharedValue,
+ withSpring,
+} from "react-native-reanimated";
+import { usePlayerStore } from "../../../player-store";
+import { Slider } from "../../ui";
+import { Text, View } from "../../ui/index";
+
+function formatTime(seconds: number): string {
+ const s = Math.floor(seconds);
+ const h = Math.floor(s / 3600);
+ const m = Math.floor((s % 3600) / 60);
+ const sec = s % 60;
+ const pad = (n: number) => String(n).padStart(2, "0");
+ if (h > 0) return `${h}:${pad(m)}:${pad(sec)}`;
+ return `${m}:${pad(sec)}`;
+}
+
+export function SeekBar() {
+ const mode = usePlayerStore((x) => x.mode);
+ const playTime = usePlayerStore((x) => x.playTime);
+ const duration = usePlayerStore((x) => x.duration);
+ const bufferedEnd = usePlayerStore((x) => x.bufferedEnd);
+ const seekTo = usePlayerStore((x) => x.seekTo);
+
+ const seekingRef = useRef(false);
+ const [seekValue, setSeekValue] = useState(0);
+
+ const thumbHovered = useSharedValue(0);
+ const thumbAnimStyle = useAnimatedStyle(() => ({
+ boxShadow: `0 2px 10px rgba(0, 0, 0, ${withSpring(thumbHovered.value === 1 ? 1 : 0.18)})`,
+ transform: [
+ { scaleY: withSpring(thumbHovered.value === 1 ? 1 : 0.18) },
+ {
+ scaleX: withSpring(thumbHovered.value === 1 ? 1 : 0.35),
+ },
+ ],
+ }));
+
+ const handleValueChange = useCallback((vals: number[]) => {
+ seekingRef.current = true;
+ setSeekValue(vals[0]);
+ }, []);
+
+ const handlePointerUp = useCallback(() => {
+ if (seekingRef.current) {
+ seekTo(seekValue);
+ seekingRef.current = false;
+ }
+ }, [seekValue, seekTo]);
+
+ if (mode !== "vod" || duration <= 0) return null;
+
+ const displayTime = seekingRef.current ? seekValue : playTime;
+
+ return (
+
+
+
+
+ {
+ thumbHovered.value = 1;
+ }}
+ // @ts-ignore — web-only pointer events
+ onPointerLeave={() => {
+ thumbHovered.value = 0;
+ }}
+ />
+ {/* full track background */}
+
+ {/* buffered range */}
+
+
+
+
+
+
+
+
+
+
+
+
+ {formatTime(displayTime)}
+
+
+ {formatTime(duration)}
+
+
+
+ );
+}
diff --git a/js/components/src/components/mobile-player/ui/vod-controls.tsx b/js/components/src/components/mobile-player/ui/vod-controls.tsx
new file mode 100644
index 000000000..fc448d867
--- /dev/null
+++ b/js/components/src/components/mobile-player/ui/vod-controls.tsx
@@ -0,0 +1,102 @@
+import { Gauge, Pause, Play } from "lucide-react-native";
+import { Pressable } from "react-native";
+import { useLivestreamStore } from "../../../livestream-store";
+import { PlayerStatus, usePlayerStore } from "../../../player-store";
+
+import {
+ DropdownMenu,
+ DropdownMenuGroup,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuTrigger,
+ ResponsiveDropdownMenuContent,
+ Text,
+ useTheme,
+ View,
+} from "../../ui";
+
+export function VodControls() {
+ const mode = usePlayerStore((x) => x.mode);
+ const status = usePlayerStore((x) => x.status);
+ const videoRef = usePlayerStore((x) => x.videoRef);
+ const quality = usePlayerStore((x) => x.selectedRendition);
+ const setQuality = usePlayerStore((x) => x.setSelectedRendition);
+ const liveRenditions = useLivestreamStore((x) => x.renditions);
+ const vodLevels = usePlayerStore((x) => x.vodLevels);
+ const renditions = mode === "vod" ? vodLevels : liveRenditions;
+ const th = useTheme();
+
+ if (mode !== "vod") return null;
+
+ const isPlaying = status === PlayerStatus.PLAYING;
+
+ const togglePlayPause = () => {
+ if (
+ videoRef &&
+ typeof videoRef === "object" &&
+ "current" in videoRef &&
+ videoRef.current
+ ) {
+ if (videoRef.current.paused) {
+ videoRef.current.play();
+ } else {
+ videoRef.current.pause();
+ }
+ }
+ };
+
+ return (
+
+
+ {isPlaying ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {renditions.length > 0 && (
+
+
+
+
+
+
+
+
+ {mode === "vod" ? "Auto" : "Source"}
+
+ {renditions.map((r) => (
+
+ {r.name === "audio" ? "Audio Only" : r.name}
+
+ ))}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/js/components/src/components/mobile-player/video-retry.tsx b/js/components/src/components/mobile-player/video-retry.tsx
index 2f6eed99b..a7e461f0c 100644
--- a/js/components/src/components/mobile-player/video-retry.tsx
+++ b/js/components/src/components/mobile-player/video-retry.tsx
@@ -13,7 +13,10 @@ export default function VideoRetry(props: { children: React.ReactNode }) {
setLastChange(Date.now());
}, [status]);
+ const mode = usePlayerStore((x) => x.mode);
+
useEffect(() => {
+ if (mode === "vod") return;
if (!playing) {
const handle = setInterval(() => {
setTick(Date.now());
@@ -41,7 +44,7 @@ export default function VideoRetry(props: { children: React.ReactNode }) {
retryTimeoutRef.current = null;
}
};
- }, [!playing]);
+ }, [!playing, mode]);
useEffect(() => {
if (stalledFor5sec) {
diff --git a/js/components/src/components/mobile-player/video.tsx b/js/components/src/components/mobile-player/video.tsx
index c91e2ab82..1deffafc0 100644
--- a/js/components/src/components/mobile-player/video.tsx
+++ b/js/components/src/components/mobile-player/video.tsx
@@ -149,6 +149,9 @@ const VideoElement = forwardRef<
const setStatus = usePlayerStore((x) => x.setStatus);
const setUserInteraction = usePlayerStore((x) => x.setUserInteraction);
const setVideoRef = usePlayerStore((x) => x.setVideoRef);
+ const setPlayTime = usePlayerStore((x) => x.setPlayTime);
+ const setDuration = usePlayerStore((x) => x.setDuration);
+ const setBufferedEnd = usePlayerStore((x) => x.setBufferedEnd);
const event = (evType) => (e) => {
console.log(evType);
@@ -297,6 +300,19 @@ const VideoElement = forwardRef<
onSeeking={event("seeking")}
onStalled={event("stalled")}
onSuspend={event("suspend")}
+ onTimeUpdate={(e) => {
+ setPlayTime((e.target as HTMLVideoElement).currentTime);
+ }}
+ onDurationChange={(e) => {
+ const d = (e.target as HTMLVideoElement).duration;
+ if (isFinite(d)) setDuration(d);
+ }}
+ onProgress={(e) => {
+ const el = e.target as HTMLVideoElement;
+ if (el.buffered.length > 0) {
+ setBufferedEnd(el.buffered.end(el.buffered.length - 1));
+ }
+ }}
onVolumeChange={event("volumechange")}
onWaiting={event("waiting")}
style={{
@@ -323,6 +339,10 @@ export function ProgressiveWebMPlayer(props: VideoProps) {
export function HLSPlayer(props: VideoProps) {
const localRef = useRef(null);
+ const hlsRef = useRef(null);
+ const setVodLevels = usePlayerStore((x) => x.setVodLevels);
+ const selectedRendition = usePlayerStore((x) => x.selectedRendition);
+ const mode = usePlayerStore((x) => x.mode);
// other players set some status on start, HLS doesn't, so
// do this to make sure we we reset off of "error" state
@@ -337,6 +357,7 @@ export function HLSPlayer(props: VideoProps) {
}
if (Hls.isSupported()) {
var hls = new Hls({ maxAudioFramesDrift: 20 });
+ hlsRef.current = hls;
hls.loadSource(props.url);
try {
hls.attachMedia(localRef.current);
@@ -350,9 +371,21 @@ export function HLSPlayer(props: VideoProps) {
return;
}
localRef.current.play();
+ if (mode === "vod" && hls.levels.length > 1) {
+ setVodLevels(
+ hls.levels.map((l) => ({
+ name:
+ l.height > 0
+ ? `${l.height}p`
+ : `${Math.round(l.bitrate / 1000)}k`,
+ })),
+ );
+ }
});
return () => {
hls.stopLoad();
+ hlsRef.current = null;
+ setVodLevels([]);
};
} else if (localRef.current.canPlayType("application/vnd.apple.mpegurl")) {
localRef.current.src = props.url;
@@ -365,6 +398,21 @@ export function HLSPlayer(props: VideoProps) {
}
}, [props.url]);
+ useEffect(() => {
+ const hls = hlsRef.current;
+ if (!hls || mode !== "vod") return;
+ if (selectedRendition === "source" || selectedRendition === "auto") {
+ hls.currentLevel = -1;
+ return;
+ }
+ const idx = hls.levels.findIndex((l) => {
+ const name =
+ l.height > 0 ? `${l.height}p` : `${Math.round(l.bitrate / 1000)}k`;
+ return name === selectedRendition;
+ });
+ if (idx !== -1) hls.currentLevel = idx;
+ }, [selectedRendition, mode]);
+
return ;
}
diff --git a/js/components/src/player-store/player-state.tsx b/js/components/src/player-store/player-state.tsx
index fa48bc219..e01557b50 100644
--- a/js/components/src/player-store/player-state.tsx
+++ b/js/components/src/player-store/player-state.tsx
@@ -8,6 +8,8 @@ export enum PlayerProtocol {
PROGRESSIVE_WEBM = "progressive-webm",
}
+export type PlayerMode = "live" | "vod";
+
export enum PlayerStatus {
START = "start",
PLAYING = "playing",
@@ -27,8 +29,18 @@ export enum IngestMediaSource {
export interface PlayerState {
id: string;
+ mode: PlayerMode;
+ setMode: (mode: PlayerMode) => void;
+ duration: number;
+ setDuration: (duration: number) => void;
+ bufferedEnd: number;
+ setBufferedEnd: (bufferedEnd: number) => void;
selectedRendition: string;
setSelectedRendition: (rendition: string) => void;
+
+ /** Quality levels parsed from HLS manifest (VOD only) */
+ vodLevels: Array<{ name: string }>;
+ setVodLevels: (levels: Array<{ name: string }>) => void;
protocol: PlayerProtocol;
setProtocol: (protocol: PlayerProtocol) => void;
@@ -84,7 +96,9 @@ export interface PlayerState {
/** Function to set the current playback time */
setPlayTime: (playTime: number) => void;
- /** Flag indicating if player is in offline state */
+ /** Seek the video element to a specific time */
+ seekTo: (time: number) => void;
+
/** Reference to the video element for direct manipulation (used for PiP) */
videoRef:
| React.MutableRefObject
diff --git a/js/components/src/player-store/player-store.tsx b/js/components/src/player-store/player-store.tsx
index 9bf244deb..46fd58fa7 100644
--- a/js/components/src/player-store/player-store.tsx
+++ b/js/components/src/player-store/player-store.tsx
@@ -21,6 +21,15 @@ export type PlayerStore = StoreApi;
export const makePlayerStore = (id?: string): StoreApi => {
const store = createStore()((set) => ({
id: id || Math.random().toString(36).slice(8),
+ mode: "live",
+ setMode: (mode) => set(() => ({ mode })),
+ duration: 0,
+ setDuration: (duration) => set(() => ({ duration })),
+ bufferedEnd: 0,
+ setBufferedEnd: (bufferedEnd) => set(() => ({ bufferedEnd })),
+ vodLevels: [],
+ setVodLevels: (vodLevels) => set(() => ({ vodLevels })),
+
selectedRendition: "source",
setSelectedRendition: (rendition: string) =>
set((state) => {
@@ -91,6 +100,14 @@ export const makePlayerStore = (id?: string): StoreApi => {
playTime: 0,
setPlayTime: (playTime: number) => set(() => ({ playTime })),
+ seekTo: (time: number) =>
+ set((state) => {
+ const ref = state.videoRef;
+ if (ref && typeof ref === "object" && "current" in ref && ref.current) {
+ ref.current.currentTime = time;
+ }
+ return { playTime: time };
+ }),
videoRef: undefined,
setVideoRef: (
--
2.51.2
From 4c109593773569857359ddcbc58bcae0c27922cf Mon Sep 17 00:00:00 2001
From: "Natalie B." <22222885+espeon@users.noreply.github.com>
Date: Tue, 17 Mar 2026 17:05:30 -0500
Subject: [PATCH 02/47] track VOD renditions, refine mobile UI
---
.../mobile/desktop-ui/bottom-controls.tsx | 111 +++--
js/app/components/mobile/player.tsx | 97 +---
js/app/components/mobile/ui.tsx | 15 +
.../components/mobile-player/ui/seek-bar.tsx | 31 +-
.../mobile-player/ui/viewer-context-menu.tsx | 455 +++++++-----------
.../mobile-player/ui/vod-controls.tsx | 17 +-
.../mobile-player/video-async.native.tsx | 71 ++-
.../src/components/mobile-player/video.tsx | 11 +
.../src/player-store/player-state.tsx | 7 +
.../src/player-store/player-store.tsx | 17 +
10 files changed, 390 insertions(+), 442 deletions(-)
diff --git a/js/app/components/mobile/desktop-ui/bottom-controls.tsx b/js/app/components/mobile/desktop-ui/bottom-controls.tsx
index 9351659ab..1ac29fa35 100644
--- a/js/app/components/mobile/desktop-ui/bottom-controls.tsx
+++ b/js/app/components/mobile/desktop-ui/bottom-controls.tsx
@@ -1,6 +1,8 @@
import {
Button,
+ PlayerStatus,
PlayerUI,
+ Text,
View,
useDanmuEnabled,
useDanmuUnlocked,
@@ -14,7 +16,9 @@ import {
ChevronRight,
Fullscreen,
Minimize,
+ Pause,
PictureInPicture2,
+ Play,
} from "lucide-react-native";
import { Platform, Pressable } from "react-native";
import { useIsSidebarCollapsed } from "store/hooks";
@@ -116,6 +120,16 @@ function CollapseChatButton({
);
}
+function formatTime(seconds: number): string {
+ const s = Math.floor(seconds);
+ const h = Math.floor(s / 3600);
+ const m = Math.floor((s % 3600) / 60);
+ const sec = s % 60;
+ const pad = (n: number) => String(n).padStart(2, "0");
+ if (h > 0) return `${h}:${pad(m)}:${pad(sec)}`;
+ return `${m}:${pad(sec)}`;
+}
+
export function BottomControlBar({
ingest,
pipSupported,
@@ -125,41 +139,76 @@ export function BottomControlBar({
showChat,
setShowChat,
}: BottomControlBarProps) {
- let { theme } = useTheme();
- const fullscreen = usePlayerStore((state) => state.fullscreen);
- const setFullscreen = usePlayerStore((state) => state.setFullscreen);
- const danmuUnlocked = useDanmuUnlocked();
- const danmuEnabled = useDanmuEnabled();
- const setDanmuEnabled = useSetDanmuEnabled();
+ const th = useTheme();
const sidebarCollapsed = useIsSidebarCollapsed();
+ const playbackMode = usePlayerStore((x) => x.mode);
+ const togglePlayPause = usePlayerStore((x) => x.togglePlayPause);
+
+ const playTime = usePlayerStore((x) => x.playTime);
+ const duration = usePlayerStore((x) => x.duration);
+
+ const status = usePlayerStore((x) => x.status);
+ const PlayPause = status === PlayerStatus.PLAYING ? Pause : Play;
return (
-
-
-
-
+
+
+
+
+ {playbackMode === "vod" && (
+
+
+
+ )}
+
+ {playbackMode === "vod" && (
+
+
+ {formatTime(playTime)}
+
+ /
+
+ {formatTime(duration)}
+
+
+ )}
+
-
- {pipSupported && (
-
- )}
-
- {ingest === null && (
-
- )}
-
- {setShowChat && (
-
- )}
+
+ {pipSupported && (
+
+ )}
+
+ {ingest === null && (
+
+ )}
+
+ {setShowChat && (
+
+ )}
+
);
diff --git a/js/app/components/mobile/player.tsx b/js/app/components/mobile/player.tsx
index cd1beab13..7622679b8 100644
--- a/js/app/components/mobile/player.tsx
+++ b/js/app/components/mobile/player.tsx
@@ -31,7 +31,6 @@ import {
} from "react-native";
import Reanimated, {
useAnimatedStyle,
- useDerivedValue,
useSharedValue,
withTiming,
} from "react-native-reanimated";
@@ -81,14 +80,8 @@ function PlayerWithProvider(
const { top: safeTop } = useSafeAreaInsets();
const segDims = useSegmentDimensions();
const isPortrait = screenHeight > screenWidth;
- const portraitFadeOpacity = useSharedValue(1);
- const portraitVideoTranslateY = useDerivedValue(
- () => (portraitFadeOpacity.value - 1) * 20,
- );
- const portraitVideoStyle = useAnimatedStyle(() => ({
- transform: [{ translateY: (portraitFadeOpacity.value - 1) * 20 }],
- }));
const isPortraitLandscapeCase =
+ Platform.OS !== "web" &&
isPortrait &&
segDims.width > segDims.height &&
!shouldShowChatSidePanel &&
@@ -251,25 +244,19 @@ function PlayerWithProvider(
flex: 1,
width: "100%",
height: "100%",
- paddingTop:
- isPortraitLandscapeCase && Platform.OS !== "web"
- ? 54
- : undefined,
+ paddingTop: isPortraitLandscapeCase ? 54 : undefined,
},
]}
>
-
-
+
{isPortraitLandscapeCase ? (
<>
-
+
{!showUnavailable && (
-
+
)}
>
) : shouldShowChatSidePanel ? (
) : (
!showUnavailable &&
@@ -309,53 +287,6 @@ function PlayerWithProvider(
);
}
-function MobileUiOverlay({
- showChat,
- setShowChat,
- isPortraitLandscapeCase,
- mode,
-}: {
- showChat: boolean;
- setShowChat: (show: boolean) => void;
- isPortraitLandscapeCase: boolean;
- mode?: string;
-}) {
- const { width, height } = usePlayerDimensions();
- const aspectRatio = width > 0 && height > 0 ? width / height : 16 / 9;
- const isLandscape = aspectRatio > 1;
- const showFullDesktopMode = aspectRatio > 1 && width > 1200;
- const fullscreen = usePlayerStore((x) => x.fullscreen);
- const { isPlayerRatioGreater: segIsLandscape } = useSegmentDimensions();
-
- if (
- isPortraitLandscapeCase ||
- mode === "vod" ||
- showFullDesktopMode ||
- fullscreen ||
- (!isLandscape && !segIsLandscape)
- ) {
- return null;
- }
-
- return (
-
-
-
- );
-}
-
export function PlayerInner(
props: Partial & {
showChat: boolean;
@@ -503,7 +434,7 @@ export function PlayerInner(
{showFullDesktopMode || fullscreen ? (
) : (
- (isLandscape || !!props.ingest) && (
+ (isLandscape || props.mode === "vod") && (
)}
- {props.mode === "vod" && (
-
-
-
-
- )}
state.mode);
const muteWasForced = usePlayerStore((state) => state.muteWasForced);
const setMuteWasForced = usePlayerStore((state) => state.setMuteWasForced);
const [playerIsReady, setPlayerIsReady] = useState(false);
@@ -259,6 +260,20 @@ export function MobileUi({
/>
)}
+ {mode === "vod" && (
+
+
+
+
+ )}
+
String(n).padStart(2, "0");
- if (h > 0) return `${h}:${pad(m)}:${pad(sec)}`;
- return `${m}:${pad(sec)}`;
-}
+import { View } from "../../ui/index";
export function SeekBar() {
const mode = usePlayerStore((x) => x.mode);
@@ -30,11 +20,9 @@ export function SeekBar() {
const thumbHovered = useSharedValue(0);
const thumbAnimStyle = useAnimatedStyle(() => ({
- boxShadow: `0 2px 10px rgba(0, 0, 0, ${withSpring(thumbHovered.value === 1 ? 1 : 0.18)})`,
transform: [
- { scaleY: withSpring(thumbHovered.value === 1 ? 1 : 0.18) },
{
- scaleX: withSpring(thumbHovered.value === 1 ? 1 : 0.35),
+ scale: withSpring(thumbHovered.value === 1 ? 1 : 0.18),
},
],
}));
@@ -69,7 +57,7 @@ export function SeekBar() {
style={{
flex: 1,
height: 90,
- paddingBottom: 24,
+ paddingBottom: 10,
}}
>
-
-
- {formatTime(displayTime)}
-
-
- {formatTime(duration)}
-
-
);
}
diff --git a/js/components/src/components/mobile-player/ui/viewer-context-menu.tsx b/js/components/src/components/mobile-player/ui/viewer-context-menu.tsx
index ff223f638..007c926cd 100644
--- a/js/components/src/components/mobile-player/ui/viewer-context-menu.tsx
+++ b/js/components/src/components/mobile-player/ui/viewer-context-menu.tsx
@@ -1,7 +1,3 @@
-import {
- AppBskyActorDefs,
- ComAtprotoModerationCreateReport,
-} from "@atproto/api";
import { useRootContext } from "@rn-primitives/dropdown-menu";
import { Image } from "expo-image";
import { Cog } from "lucide-react-native";
@@ -15,15 +11,11 @@ import Animated, {
import {
ContentRights,
ContentWarnings,
- UpdateStreamTitleDialog,
formatHandle,
formatHandleWithAt,
useAvatars,
- useCanModerate,
- useLivestream,
useLivestreamInfo,
useStreamplaceStore,
- useUpdateLivestreamRecord,
zero,
} from "../../..";
import { useLivestreamStore } from "../../../livestream-store";
@@ -48,10 +40,6 @@ import {
useTheme,
} from "../../ui";
-type ReportSubject =
- | ComAtprotoModerationCreateReport.InputSchema["subject"]
- | null;
-
export function ContextMenu({
dropdownPortalContainer,
}: {
@@ -60,7 +48,11 @@ export function ContextMenu({
const th = useTheme();
const quality = usePlayerStore((x) => x.selectedRendition);
const setQuality = usePlayerStore((x) => x.setSelectedRendition);
- const qualities = useLivestreamStore((x) => x.renditions);
+ const mode = usePlayerStore((x) => x.mode);
+ const vodLevels = usePlayerStore((x) => x.vodLevels);
+ const playingVODRendition = usePlayerStore((x) => x.playingVODRendition);
+ const liveRenditions = useLivestreamStore((x) => x.renditions);
+ const qualities = mode === "vod" ? vodLevels : liveRenditions;
const protocol = usePlayerStore((x) => x.protocol);
const setProtocol = usePlayerStore((x) => x.setProtocol);
@@ -111,12 +103,6 @@ export function ContextMenu({
const segment = useLivestreamStore((x) => x.segment);
const [isOpen, setIsOpen] = useState(false);
- const [showUpdateTitleDialog, setShowUpdateTitleDialog] = useState(false);
-
- const modPermissions = useCanModerate(profile?.did);
- const { updateLivestream, isLoading: isUpdateTitleLoading } =
- useUpdateLivestreamRecord();
- const livestreamRecord = useLivestream();
// Get content rights from the latest segment
const contentRights = segment?.contentRights;
@@ -152,273 +138,198 @@ export function ContextMenu({
// rerender when dropdown portal container changes so we swap portals 'seamlessly'
return (
- <>
-
-
-
-
-
-
-
- {Platform.OS !== "web" && (
-
-
- {profile?.did && avatars[profile?.did]?.avatar && (
-
+
+
+
+
+
+
+ {Platform.OS !== "web" && (
+
+
+ {profile?.did && avatars[profile?.did]?.avatar && (
+
+ )}
+
+
+ {
+ if (profile?.handle) {
+ const url = `https://bsky.app/profile/${formatHandle(profile)}`;
+ Linking.openURL(url);
+ }
}}
- style={{ width: 42, height: 42, borderRadius: 999 }}
- contentFit="cover"
- />
- )}
-
-
- {
- if (profile?.handle) {
- const url = `https://bsky.app/profile/${formatHandle(profile)}`;
- Linking.openURL(url);
- }
- }}
- >
- {profile && formatHandleWithAt(profile)}
-
- {/*{did && profile && (
+ {profile && formatHandleWithAt(profile)}
+
+ {/*{did && profile && (
)}*/}
-
-
- {ls?.record.title || "Stream Title"}
-
-
-
- {
- try {
- if (graphManager.isFollowing) {
- await graphManager.unfollow();
- } else {
- await graphManager.follow();
- }
- } catch (err) {
- console.error("Follow/unfollow error:", err);
- }
- }}
- >
- {graphManager.isLoading
- ? "Loading..."
- : graphManager.isFollowing
- ? "Unfollow"
- : "Follow"}
+ {ls?.record.title || "Stream Title"}
-
-
- {
- if (profile?.handle) {
- const url = `https://bsky.app/profile/${formatHandle(profile)}`;
- Linking.openURL(url);
+
+
+
+ {
+ try {
+ if (graphManager.isFollowing) {
+ await graphManager.unfollow();
+ } else {
+ await graphManager.follow();
}
- }}
+ } catch (err) {
+ console.error("Follow/unfollow error:", err);
+ }
+ }}
+ >
+
- View Profile on Bluesky
-
-
- )}
-
- {Platform.OS !== "web" && modPermissions.canManageLivestream && (
-
-
-
- )}
-
-
-
-
-
- Quality
-
- {quality === "source"
- ? `Source${resolutionDisplay ? " " + resolutionDisplay + "\n" : ", "}`
- : quality === "audio"
- ? `Audio Only\n`
- : quality}
- {lowLatency ? "Low Latency" : ""}
-
-
-
-
-
-
-
- Source {resolutionDisplay}
-
- {qualities.map((r) => (
-
-
- {r.name === "audio" ? "Audio Only" : r.name}
-
-
- ))}
-
-
-
- setLowLatency(!lowLatency)}
- >
- Low Latency
-
-
-
-
-
-
-
- setShowDebugInfo(!debugInfo)}
+ {graphManager.isLoading
+ ? "Loading..."
+ : graphManager.isFollowing
+ ? "Unfollow"
+ : "Follow"}
+
+
+
+ {
+ if (profile?.handle) {
+ const url = `https://bsky.app/profile/${formatHandle(profile)}`;
+ Linking.openURL(url);
+ }
+ }}
>
- Show Debug Info
-
-
-
-
-
+ View Profile on Bluesky
+
-
- {contentWarnings && contentWarnings.length > 0 && (
-
-
- Stream may contain
+ )}
+
+
+
+
+
+ Quality
+
+ {quality === "source"
+ ? mode === "vod"
+ ? `Auto${playingVODRendition ? ` (${playingVODRendition})` : ""}\n`
+ : `Source${resolutionDisplay ? " " + resolutionDisplay + "\n" : ", "}`
+ : quality === "audio"
+ ? `Audio Only\n`
+ : quality}
+ {mode !== "vod" && lowLatency ? "Low Latency" : ""}
-
- )}
- {contentRights && Object.keys(contentRights).length > 0 && (
-
- )}
-
-
-
- {showUpdateTitleDialog && (
- setShowUpdateTitleDialog(false)}
- />
- )}
- >
- );
-}
-
-function UpdateStreamTitleItem({
- setShowUpdateTitleDialog,
- isUpdateTitleLoading,
- livestream,
-}: {
- setShowUpdateTitleDialog: (show: boolean) => void;
- isUpdateTitleLoading: boolean;
- livestream: any;
-}) {
- const { onOpenChange } = useRootContext();
- return (
- {
- onOpenChange?.(false);
- setShowUpdateTitleDialog(true);
- }}
- disabled={isUpdateTitleLoading || !livestream}
- >
-
- {isUpdateTitleLoading ? "Updating..." : "Update stream title"}
-
-
- );
-}
-
-function ReportUserItem({
- profile,
- setReportModalOpen,
- setReportSubject,
-}: {
- profile: AppBskyActorDefs.ProfileViewBasic | null;
- setReportModalOpen: (open: boolean) => void;
- setReportSubject: (subject: ReportSubject) => void;
-}) {
- const { onOpenChange } = useRootContext();
- return (
- {
- if (!profile?.did) return;
- onOpenChange?.(false);
- setReportModalOpen(true);
- setReportSubject({
- $type: "com.atproto.admin.defs#repoRef",
- did: profile.did,
- });
- }}
- disabled={!profile?.did}
- >
- Report User...
-
+
+
+
+
+
+ Source {resolutionDisplay}
+
+ {qualities.map((r) => (
+
+ {r.name === "audio" ? "Audio Only" : r.name}
+
+ ))}
+
+
+ {mode !== "vod" && (
+ <>
+
+ setLowLatency(!lowLatency)}
+ >
+ Low Latency
+
+
+
+ >
+ )}
+
+
+
+
+ setShowDebugInfo(!debugInfo)}
+ >
+ Show Debug Info
+
+
+
+
+
+
+ {contentWarnings && contentWarnings.length > 0 && (
+
+
+ Stream may contain
+
+
+
+ )}
+ {contentRights && Object.keys(contentRights).length > 0 && (
+
+ )}
+
+
+
);
}
diff --git a/js/components/src/components/mobile-player/ui/vod-controls.tsx b/js/components/src/components/mobile-player/ui/vod-controls.tsx
index fc448d867..97388ee57 100644
--- a/js/components/src/components/mobile-player/ui/vod-controls.tsx
+++ b/js/components/src/components/mobile-player/ui/vod-controls.tsx
@@ -18,7 +18,7 @@ import {
export function VodControls() {
const mode = usePlayerStore((x) => x.mode);
const status = usePlayerStore((x) => x.status);
- const videoRef = usePlayerStore((x) => x.videoRef);
+ const togglePlayPause = usePlayerStore((x) => x.togglePlayPause);
const quality = usePlayerStore((x) => x.selectedRendition);
const setQuality = usePlayerStore((x) => x.setSelectedRendition);
const liveRenditions = useLivestreamStore((x) => x.renditions);
@@ -30,21 +30,6 @@ export function VodControls() {
const isPlaying = status === PlayerStatus.PLAYING;
- const togglePlayPause = () => {
- if (
- videoRef &&
- typeof videoRef === "object" &&
- "current" in videoRef &&
- videoRef.current
- ) {
- if (videoRef.current.paused) {
- videoRef.current.play();
- } else {
- videoRef.current.pause();
- }
- }
- };
-
return (
x.protocol);
+ const inProto = usePlayerStore((x) => x.protocol);
+ const selectedRendition = usePlayerStore((x) => x.selectedRendition);
+ const src = usePlayerStore((x) => x.src);
const ingest = usePlayerStore((x) => x.ingestConnectionState) != null;
+ const { protocol } = srcToUrl({ src, selectedRendition }, inProto);
return (
@@ -68,6 +71,7 @@ export function NativeVideo(props?: {
}) {
const videoRef = useRef(null);
const protocol = usePlayerStore((x) => x.protocol);
+ const mode = usePlayerStore((x) => x.mode);
const selectedRendition = usePlayerStore((x) => x.selectedRendition);
const src = usePlayerStore((x) => x.src);
@@ -82,6 +86,14 @@ export function NativeVideo(props?: {
const setPlayerWidth = usePlayerStore((x) => x.setPlayerWidth);
const setPlayerHeight = usePlayerStore((x) => x.setPlayerHeight);
+ const setPlayTime = usePlayerStore((x) => x.setPlayTime);
+ const setDuration = usePlayerStore((x) => x.setDuration);
+ const setBufferedEnd = usePlayerStore((x) => x.setBufferedEnd);
+ const playTime = usePlayerStore((x) => x.playTime);
+ const setTogglePlayPause = usePlayerStore((x) => x.setTogglePlayPause);
+ const status = usePlayerStore((x) => x.status);
+ const statusRef = useRef(status);
+ const lastReportedTimeRef = useRef(0);
// State for live dimensions
const [dimensions, setDimensions] = useState<{
@@ -106,15 +118,14 @@ export function NativeVideo(props?: {
}, [setStatus]);
const player = useVideoPlayer(url, (player) => {
- player.addListener("playingChange", (newIsPlaying) => {
- console.log("playingChange", newIsPlaying);
- if (newIsPlaying) {
+ player.addListener("playingChange", ({ isPlaying }) => {
+ if (isPlaying) {
setStatus(PlayerStatus.PLAYING);
} else {
setStatus(PlayerStatus.WAITING);
}
});
- player.loop = true;
+ player.loop = mode !== "vod";
player.muted = muted;
player.play();
});
@@ -127,6 +138,51 @@ export function NativeVideo(props?: {
player.volume = volume;
}, [volume, player]);
+ useEffect(() => {
+ statusRef.current = status;
+ }, [status]);
+
+ useEffect(() => {
+ player.loop = mode !== "vod";
+ }, [player, mode]);
+
+ useEffect(() => {
+ setTogglePlayPause(() => {
+ if (statusRef.current === PlayerStatus.PLAYING) {
+ player.pause();
+ } else {
+ player.play();
+ }
+ });
+ return () => setTogglePlayPause(() => {});
+ }, [player, setTogglePlayPause]);
+
+ useEffect(() => {
+ if (mode !== "vod") return;
+ const interval = setInterval(() => {
+ const currentTime = player.currentTime;
+ const duration = player.duration;
+ lastReportedTimeRef.current = currentTime;
+ setPlayTime(currentTime);
+ if (isFinite(duration) && duration > 0) {
+ setDuration(duration);
+ }
+ const buffered = (player as any).bufferedPosition;
+ if (typeof buffered === "number" && buffered > 0) {
+ setBufferedEnd(buffered);
+ }
+ }, 250);
+ return () => clearInterval(interval);
+ }, [mode, player, setPlayTime, setDuration, setBufferedEnd]);
+
+ useEffect(() => {
+ if (mode !== "vod") return;
+ const diff = Math.abs(playTime - lastReportedTimeRef.current);
+ if (diff > 1.0) {
+ player.seekBy(playTime - player.currentTime);
+ }
+ }, [playTime, mode, player]);
+
useEffect(() => {
const subs = (
[
@@ -146,9 +202,8 @@ export function NativeVideo(props?: {
});
subs.push(
- player.addListener("playingChange", (newIsPlaying) => {
- console.log("playingChange", newIsPlaying);
- if (newIsPlaying) {
+ player.addListener("playingChange", ({ isPlaying }) => {
+ if (isPlaying) {
setStatus(PlayerStatus.PLAYING);
} else {
setStatus(PlayerStatus.WAITING);
diff --git a/js/components/src/components/mobile-player/video.tsx b/js/components/src/components/mobile-player/video.tsx
index 1deffafc0..768d3b4c6 100644
--- a/js/components/src/components/mobile-player/video.tsx
+++ b/js/components/src/components/mobile-player/video.tsx
@@ -341,6 +341,9 @@ export function HLSPlayer(props: VideoProps) {
const localRef = useRef(null);
const hlsRef = useRef(null);
const setVodLevels = usePlayerStore((x) => x.setVodLevels);
+ const setPlayingVODRendition = usePlayerStore(
+ (x) => x.setPlayingVODRendition,
+ );
const selectedRendition = usePlayerStore((x) => x.selectedRendition);
const mode = usePlayerStore((x) => x.mode);
@@ -382,10 +385,18 @@ export function HLSPlayer(props: VideoProps) {
);
}
});
+ hls.on(Hls.Events.LEVEL_SWITCHED, (_, data) => {
+ const l = hls.levels[data.level];
+ if (!l) return;
+ const name =
+ l.height > 0 ? `${l.height}p` : `${Math.round(l.bitrate / 1000)}k`;
+ setPlayingVODRendition(name);
+ });
return () => {
hls.stopLoad();
hlsRef.current = null;
setVodLevels([]);
+ setPlayingVODRendition(null);
};
} else if (localRef.current.canPlayType("application/vnd.apple.mpegurl")) {
localRef.current.src = props.url;
diff --git a/js/components/src/player-store/player-state.tsx b/js/components/src/player-store/player-state.tsx
index e01557b50..f824fea3c 100644
--- a/js/components/src/player-store/player-state.tsx
+++ b/js/components/src/player-store/player-state.tsx
@@ -41,6 +41,10 @@ export interface PlayerState {
/** Quality levels parsed from HLS manifest (VOD only) */
vodLevels: Array<{ name: string }>;
setVodLevels: (levels: Array<{ name: string }>) => void;
+
+ /** Actual rendition name currently playing when in auto/source mode (web HLS only) */
+ playingVODRendition: string | null;
+ setPlayingVODRendition: (name: string | null) => void;
protocol: PlayerProtocol;
setProtocol: (protocol: PlayerProtocol) => void;
@@ -119,6 +123,9 @@ export interface PlayerState {
/** Function to set the Picture-in-Picture action */
setPipAction: (action: (() => void) | undefined) => void;
+ togglePlayPause: () => void;
+ setTogglePlayPause: (fn: () => void) => void;
+
/** Player element width (CSS value or number) */
playerWidth?: string | number;
/** Function to set the player width */
diff --git a/js/components/src/player-store/player-store.tsx b/js/components/src/player-store/player-store.tsx
index 46fd58fa7..0d6aafc8b 100644
--- a/js/components/src/player-store/player-store.tsx
+++ b/js/components/src/player-store/player-store.tsx
@@ -29,6 +29,9 @@ export const makePlayerStore = (id?: string): StoreApi => {
setBufferedEnd: (bufferedEnd) => set(() => ({ bufferedEnd })),
vodLevels: [],
setVodLevels: (vodLevels) => set(() => ({ vodLevels })),
+ playingVODRendition: null,
+ setPlayingVODRendition: (playingVODRendition) =>
+ set(() => ({ playingVODRendition })),
selectedRendition: "source",
setSelectedRendition: (rendition: string) =>
@@ -126,6 +129,20 @@ export const makePlayerStore = (id?: string): StoreApi => {
setPipAction: (action: (() => void) | undefined) =>
set(() => ({ pipAction: action })),
+ togglePlayPause: () =>
+ set((state) => {
+ const ref = state.videoRef;
+ if (ref && typeof ref === "object" && "current" in ref && ref.current) {
+ if (ref.current.paused) {
+ ref.current.play();
+ } else {
+ ref.current.pause();
+ }
+ }
+ return {};
+ }),
+ setTogglePlayPause: (fn) => set(() => ({ togglePlayPause: fn })),
+
// Player element width/height setters for global sync
playerWidth: undefined,
setPlayerWidth: (playerWidth: number) => set(() => ({ playerWidth })),
--
2.51.2
From b5c645f7a45dce310f288eb7b8695f90147a0d45 Mon Sep 17 00:00:00 2001
From: Natalie Bridgers
Date: Mon, 18 May 2026 15:33:04 -0500
Subject: [PATCH 03/47] readd demo url back, url query param
---
js/app/components/mobile/player.tsx | 1 +
js/app/src/linking-config.ts | 2 ++
js/app/src/navigation-helper.tsx | 1 +
js/app/src/navigation-types.ts | 1 +
js/app/src/screens/video.tsx | 11 ++++++++++-
js/app/src/shell.tsx | 6 ++++++
6 files changed, 21 insertions(+), 1 deletion(-)
diff --git a/js/app/components/mobile/player.tsx b/js/app/components/mobile/player.tsx
index 7622679b8..e09f5022e 100644
--- a/js/app/components/mobile/player.tsx
+++ b/js/app/components/mobile/player.tsx
@@ -276,6 +276,7 @@ function PlayerWithProvider(
) : (
!showUnavailable &&
diff --git a/js/app/src/linking-config.ts b/js/app/src/linking-config.ts
index 5b06561be..858e3c8d1 100644
--- a/js/app/src/linking-config.ts
+++ b/js/app/src/linking-config.ts
@@ -128,6 +128,7 @@ export const SCREEN_PATHS = {
PopoutInfoWidget: "widgets/info",
PopoutMultistream: "widgets/multistream",
PopoutLivestream: "widgets/livestream",
+ VodPlayerDemo: "vod-player-demo",
} as const;
/**
@@ -232,6 +233,7 @@ export const streamplaceLinkingOptions: LinkingOptions
+
{/* Utility/embed screens */}
Date: Tue, 19 May 2026 22:29:16 -0500
Subject: [PATCH 04/47] default to hls
---
.../src/components/mobile-player/shared.tsx | 12 ++++--------
1 file changed, 4 insertions(+), 8 deletions(-)
diff --git a/js/components/src/components/mobile-player/shared.tsx b/js/components/src/components/mobile-player/shared.tsx
index 15e2aca26..0c0fd575b 100644
--- a/js/components/src/components/mobile-player/shared.tsx
+++ b/js/components/src/components/mobile-player/shared.tsx
@@ -24,14 +24,10 @@ export function srcToUrl(
if (props.src.startsWith("http://") || props.src.startsWith("https://")) {
const segments = props.src.split(/[./]/);
const suffix = segments[segments.length - 1];
- if (protocolSuffixes[suffix]) {
- return {
- url: props.src,
- protocol: protocolSuffixes[suffix],
- };
- } else {
- throw new Error(`unknown playback protocol: ${suffix}`);
- }
+ return {
+ url: props.src,
+ protocol: protocolSuffixes[suffix] ?? PlayerProtocol.HLS,
+ };
}
let outUrl: string;
if (protocol === PlayerProtocol.HLS) {
--
2.51.2
From a3cdb70ed5ee4a637703365a41e9a76b8c713eb6 Mon Sep 17 00:00:00 2001
From: Natalie Bridgers
Date: Wed, 20 May 2026 13:03:02 -0500
Subject: [PATCH 05/47] you can click behind the play button
---
js/app/components/mobile/ui.tsx | 1 +
.../components/mobile-player/ui/seek-bar.tsx | 11 +++-
.../ui/viewer-loading-overlay.tsx | 61 +++++++++++++------
3 files changed, 52 insertions(+), 21 deletions(-)
diff --git a/js/app/components/mobile/ui.tsx b/js/app/components/mobile/ui.tsx
index 5c921bb52..81b1a24f5 100644
--- a/js/app/components/mobile/ui.tsx
+++ b/js/app/components/mobile/ui.tsx
@@ -168,6 +168,7 @@ export function MobileUi({
x.bufferedEnd);
const seekTo = usePlayerStore((x) => x.seekTo);
+ const videoRef = usePlayerStore((x) => x.videoRef);
const seekingRef = useRef(false);
const [seekValue, setSeekValue] = useState(0);
@@ -36,8 +37,16 @@ export function SeekBar() {
if (seekingRef.current) {
seekTo(seekValue);
seekingRef.current = false;
+ if (
+ videoRef &&
+ typeof videoRef === "object" &&
+ "current" in videoRef &&
+ videoRef.current
+ ) {
+ videoRef.current.play().catch(() => {});
+ }
}
- }, [seekValue, seekTo]);
+ }, [seekValue, seekTo, videoRef]);
if (mode !== "vod" || duration <= 0) return null;
diff --git a/js/components/src/components/mobile-player/ui/viewer-loading-overlay.tsx b/js/components/src/components/mobile-player/ui/viewer-loading-overlay.tsx
index 26c99b2b8..a6d9b07d3 100644
--- a/js/components/src/components/mobile-player/ui/viewer-loading-overlay.tsx
+++ b/js/components/src/components/mobile-player/ui/viewer-loading-overlay.tsx
@@ -1,5 +1,6 @@
import { Play } from "lucide-react-native";
import { useEffect } from "react";
+import { Pressable } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
@@ -15,6 +16,7 @@ import {
export function ViewerLoadingOverlay() {
const status = usePlayerStore((x) => x.status);
+ const togglePlayPause = usePlayerStore((x) => x.togglePlayPause);
const { theme, zero: zt } = useTheme();
const opacity = useSharedValue(0);
@@ -40,27 +42,46 @@ export function ViewerLoadingOverlay() {
return null; // No overlay when stopped
}
- let spinner = ;
- if (status === PlayerStatus.PAUSE) {
- spinner = ;
- }
+ const isPaused = status === PlayerStatus.PAUSE;
return (
-
- {spinner}
-
+ <>
+
+ {!isPaused && }
+
+ {isPaused && (
+
+
+
+ )}
+ >
);
}
--
2.51.2
From a9215d8caf29f9d9a5ef9ba748a0c4d279ceab80 Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Tue, 26 May 2026 17:04:55 -0700
Subject: [PATCH 06/47] Scaffold videoStore and VideoProvider
Add a per-video zustand store and provider mirroring the
livestream-store / livestream-provider split. VideoProvider fetches
place.stream.video metadata via place.stream.media.getVideo (using the
possibly-unauthed node agent so VOD loads for logged-out viewers) and
hydrates it into the store. Exposes useVideoStore plus useVideo /
useVideoLoading / useVideoError convenience hooks.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
js/components/src/index.tsx | 2 +
js/components/src/video-provider/fetch.tsx | 47 +++++++++++++++++
js/components/src/video-provider/index.tsx | 51 +++++++++++++++++++
js/components/src/video-store/context.tsx | 8 +++
js/components/src/video-store/index.tsx | 4 ++
js/components/src/video-store/video-state.tsx | 19 +++++++
js/components/src/video-store/video-store.tsx | 39 ++++++++++++++
7 files changed, 170 insertions(+)
create mode 100644 js/components/src/video-provider/fetch.tsx
create mode 100644 js/components/src/video-provider/index.tsx
create mode 100644 js/components/src/video-store/context.tsx
create mode 100644 js/components/src/video-store/index.tsx
create mode 100644 js/components/src/video-store/video-state.tsx
create mode 100644 js/components/src/video-store/video-store.tsx
diff --git a/js/components/src/index.tsx b/js/components/src/index.tsx
index cb1ca9221..55599cdd3 100644
--- a/js/components/src/index.tsx
+++ b/js/components/src/index.tsx
@@ -6,6 +6,8 @@ export * from "./livestream-store";
export * from "./player-store";
export * from "./streamplace-provider";
export * from "./streamplace-store";
+export * from "./video-provider";
+export * from "./video-store";
export { ErrorBoundary } from "./components/error-boundary";
diff --git a/js/components/src/video-provider/fetch.tsx b/js/components/src/video-provider/fetch.tsx
new file mode 100644
index 000000000..bef5eea04
--- /dev/null
+++ b/js/components/src/video-provider/fetch.tsx
@@ -0,0 +1,47 @@
+import { useEffect, useRef } from "react";
+import { VideoViewHydrated } from "streamplace";
+import { usePossiblyUnauthedPDSAgent } from "../streamplace-store";
+import { getVideoStoreFromContext } from "../video-store";
+
+// Fetches the place.stream.video metadata for the given AT-URI and pushes it
+// into the surrounding VideoProvider's store. Refetches whenever the uri or
+// the active agent changes. This is the VOD analogue of the livestream
+// websocket consumer: instead of a long-lived socket, VOD metadata is a
+// one-shot read that we hydrate into the store.
+export function useVideoFetch(aturi: string) {
+ const store = getVideoStoreFromContext();
+ const agent = usePossiblyUnauthedPDSAgent();
+ const abortRef = useRef(null);
+
+ useEffect(() => {
+ if (!agent || !aturi) {
+ return;
+ }
+
+ // cancel any in-flight request from a previous uri/agent
+ abortRef.current?.abort();
+ const abort = new AbortController();
+ abortRef.current = abort;
+
+ const { setAturi, setVideo, setLoading, setError } = store.getState();
+ setAturi(aturi);
+ setLoading(true);
+ setError(null);
+
+ agent.place.stream.media
+ .getVideo({ uri: aturi }, { signal: abort.signal })
+ .then((res) => {
+ if (abort.signal.aborted) return;
+ setVideo(res.data as unknown as VideoViewHydrated);
+ setLoading(false);
+ })
+ .catch((e) => {
+ if (abort.signal.aborted) return;
+ console.error("error fetching video", e);
+ setError(e?.message ?? "failed to load video");
+ setLoading(false);
+ });
+
+ return () => abort.abort();
+ }, [agent, aturi, store]);
+}
diff --git a/js/components/src/video-provider/index.tsx b/js/components/src/video-provider/index.tsx
new file mode 100644
index 000000000..843b07169
--- /dev/null
+++ b/js/components/src/video-provider/index.tsx
@@ -0,0 +1,51 @@
+import React, { useContext, useRef } from "react";
+import { makeVideoStore } from "../video-store";
+import { VideoContext } from "../video-store/context";
+import { useVideoFetch } from "./fetch";
+
+export function VideoProvider({
+ children,
+ aturi,
+ ignoreOuterContext = false,
+}: {
+ children: React.ReactNode;
+ aturi: string;
+ ignoreOuterContext?: boolean;
+}) {
+ const context = useContext(VideoContext);
+ const store = useRef(makeVideoStore({ aturi })).current;
+ if (context) {
+ // this is ok, there's use cases for having one in another
+ // like having a player component that's independently usable
+ // but can also be embedded within an entire video page
+ if (!ignoreOuterContext) {
+ return <>{children}>;
+ }
+ }
+ (window as any).videoStore = store;
+ return (
+
+ {children}
+
+ );
+}
+
+export function VideoFetcher({ aturi }: { aturi: string }) {
+ useVideoFetch(aturi);
+ return <>>;
+}
+
+export function VideoPoller({
+ children,
+ aturi,
+}: {
+ children: React.ReactNode;
+ aturi: string;
+}) {
+ return (
+ <>
+
+ {children}
+ >
+ );
+}
diff --git a/js/components/src/video-store/context.tsx b/js/components/src/video-store/context.tsx
new file mode 100644
index 000000000..e608f5105
--- /dev/null
+++ b/js/components/src/video-store/context.tsx
@@ -0,0 +1,8 @@
+import { createContext } from "react";
+import { VideoStore } from "./video-store";
+
+type VideoContextType = {
+ store: VideoStore;
+};
+
+export const VideoContext = createContext(null);
diff --git a/js/components/src/video-store/index.tsx b/js/components/src/video-store/index.tsx
new file mode 100644
index 000000000..37841bc95
--- /dev/null
+++ b/js/components/src/video-store/index.tsx
@@ -0,0 +1,4 @@
+// barrel file :)
+export * from "./context";
+export * from "./video-state";
+export * from "./video-store";
diff --git a/js/components/src/video-store/video-state.tsx b/js/components/src/video-store/video-state.tsx
new file mode 100644
index 000000000..29fd82f05
--- /dev/null
+++ b/js/components/src/video-store/video-state.tsx
@@ -0,0 +1,19 @@
+import { VideoViewHydrated } from "streamplace";
+
+export interface VideoState {
+ // AT-URI of the place.stream.video record this store represents.
+ aturi: string;
+ setAturi: (aturi: string) => void;
+
+ // Hydrated VOD metadata, or null until the first getVideo fetch resolves.
+ video: VideoViewHydrated | null;
+ setVideo: (video: VideoViewHydrated | null) => void;
+
+ // True while a getVideo request is in flight.
+ loading: boolean;
+ setLoading: (loading: boolean) => void;
+
+ // Last fetch error message, or null when the last fetch succeeded.
+ error: string | null;
+ setError: (error: string | null) => void;
+}
diff --git a/js/components/src/video-store/video-store.tsx b/js/components/src/video-store/video-store.tsx
new file mode 100644
index 000000000..5a8a3cd9d
--- /dev/null
+++ b/js/components/src/video-store/video-store.tsx
@@ -0,0 +1,39 @@
+import { useContext } from "react";
+import { createStore, StoreApi, useStore } from "zustand";
+import { VideoContext } from "./context";
+import { VideoState } from "./video-state";
+
+export type VideoStore = StoreApi;
+
+export const makeVideoStore = ({ aturi }: { aturi: string }): VideoStore => {
+ return createStore()((set) => ({
+ aturi,
+ setAturi: (aturi) => set({ aturi }),
+ video: null,
+ setVideo: (video) => set({ video }),
+ loading: false,
+ setLoading: (loading) => set({ loading }),
+ error: null,
+ setError: (error) => set({ error }),
+ }));
+};
+
+export function getVideoStoreFromContext(): VideoStore {
+ const context = useContext(VideoContext);
+ if (!context) {
+ throw new Error("useVideoStore must be used within a VideoProvider");
+ }
+ return context.store;
+}
+
+export function useVideoStore(selector: (state: VideoState) => U): U {
+ const store = getVideoStoreFromContext();
+ return useStore(store, selector);
+}
+
+/* Convenience selectors/hooks */
+export const useVideo = () => useVideoStore((x) => x.video);
+
+export const useVideoLoading = () => useVideoStore((x) => x.loading);
+
+export const useVideoError = () => useVideoStore((x) => x.error);
--
2.51.2
From 06559e05d76a0de752ae1fea4c1ccc542ea8d9a6 Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Tue, 26 May 2026 17:55:21 -0700
Subject: [PATCH 07/47] wire up videoprovider stuff
---
js/app/components/mobile/bottom-metadata.tsx | 37 +-
js/app/components/mobile/player.tsx | 93 +-
js/app/src/linking-config.ts | 2 +-
js/app/src/screens/video.tsx | 30 +-
js/components/package.json | 1 +
.../src/components/mobile-player/shared.tsx | 14 +
js/components/src/hooks/index.ts | 1 +
js/components/src/hooks/useTitle.ts | 21 +
.../src/livestream-store/livestream-store.tsx | 16 +
js/components/src/video-store/video-store.tsx | 16 +
js/streamplace/src/useful-types.ts | 5 +
pkg/spxrpc/place_stream_playback_getvideo.go | 29 +-
pnpm-lock.yaml | 1231 +++++++++++++----
13 files changed, 1173 insertions(+), 323 deletions(-)
create mode 100644 js/components/src/hooks/useTitle.ts
diff --git a/js/app/components/mobile/bottom-metadata.tsx b/js/app/components/mobile/bottom-metadata.tsx
index f26bc6047..2ca2699ab 100644
--- a/js/app/components/mobile/bottom-metadata.tsx
+++ b/js/app/components/mobile/bottom-metadata.tsx
@@ -14,7 +14,9 @@ import {
useDID,
useLivestreamInfo,
useLivestreamStore,
+ usePlayerStore,
useTheme,
+ useTitle,
zero,
} from "@streamplace/components";
import AQLink from "components/aqlink";
@@ -86,6 +88,7 @@ export function BottomMetadata({
const avatars = useAvatars(profile?.did ? [profile?.did] : []);
const ls = useLivestreamStore((x) => x.livestream);
const segment = useLivestreamStore((x) => x.segment);
+ const mode = usePlayerStore((x) => x.mode);
const did = useDID();
const contentWarnings =
@@ -188,7 +191,7 @@ export function BottomMetadata({
{/* Title */}
- {ls?.record.title || "Stream Title"}
+ {useTitle()}
{/* Activity + tags */}
@@ -234,21 +237,23 @@ export function BottomMetadata({
-
-
-
+ {mode === "live" && (
+
+
+
+ )}
diff --git a/js/app/components/mobile/player.tsx b/js/app/components/mobile/player.tsx
index e09f5022e..997f3b22d 100644
--- a/js/app/components/mobile/player.tsx
+++ b/js/app/components/mobile/player.tsx
@@ -16,12 +16,13 @@ import {
usePlayerStore,
useSegment,
useSegmentDimensions,
+ VideoProvider,
View,
} from "@streamplace/components";
import { gap, h, pt, w } from "@streamplace/components/src/lib/theme/atoms";
import { useLiveUser } from "hooks/useLiveUser";
import { useSidebarControl } from "hooks/useSidebarControl";
-import { ArrowLeft, ArrowRight } from "lucide-react-native";
+import { ArrowLeft } from "lucide-react-native";
import { ComponentRef, useEffect, useRef, useState } from "react";
import {
Platform,
@@ -55,16 +56,27 @@ export function Player(
onTeleport?: (targetHandle: string, targetDID: string) => void;
},
) {
- return (
+ const inner = (
-
-
-
-
-
-
+
+
+
+
);
+ if (props.mode === "vod") {
+ return (
+
+ {inner}
+
+ );
+ }
+ if (props.mode === "live") {
+ return (
+ {inner}
+ );
+ }
+ throw new Error(`Unknown mode: ${props.mode}`);
}
function PlayerWithProvider(
@@ -73,7 +85,10 @@ function PlayerWithProvider(
onTeleport?: (targetHandle: string, targetDID: string) => void;
},
) {
- const [showChat, setShowChat] = useState(true);
+ let [showChat, setShowChat] = useState(props.mode === "live");
+ if (props.mode === "vod") {
+ showChat = false;
+ }
const { shouldShowChatSidePanel, chatPanelWidth } = useResponsiveLayout();
const chatVisible = shouldShowChatSidePanel && showChat;
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
@@ -157,6 +172,30 @@ function PlayerWithProvider(
const livestream = useLivestream();
const localLivestreamURI = useLivestreamStore((x) => x.localLivestreamURI);
+ let chatSection: React.ReactNode = null;
+ if (props.mode === "vod") {
+ chatSection = null;
+ } else if (isPortraitLandscapeCase) {
+ chatSection = (
+ <>
+
+ {!showUnavailable && (
+
+ )}
+ >
+ );
+ } else if (shouldShowChatSidePanel) {
+ chatSection = (
+
+ );
+ } else if (!showUnavailable) {
+ chatSection = ;
+ }
+
if (isStreamingElsewhere) {
return (
@@ -197,24 +236,7 @@ function PlayerWithProvider(
Back
- {userProfile?.did && (
-
- )}
+ {chatSection}
);
@@ -265,22 +287,7 @@ function PlayerWithProvider(
showUnavailable={showUnavailable}
/>
- {isPortraitLandscapeCase ? (
- <>
-
- {!showUnavailable && (
-
- )}
- >
- ) : shouldShowChatSidePanel ? (
-
- ) : (
- !showUnavailable &&
- )}
+ {chatSection}
diff --git a/js/app/src/linking-config.ts b/js/app/src/linking-config.ts
index 858e3c8d1..c8a680181 100644
--- a/js/app/src/linking-config.ts
+++ b/js/app/src/linking-config.ts
@@ -128,7 +128,7 @@ export const SCREEN_PATHS = {
PopoutInfoWidget: "widgets/info",
PopoutMultistream: "widgets/multistream",
PopoutLivestream: "widgets/livestream",
- VodPlayerDemo: "vod-player-demo",
+ VodPlayerDemo: ":user/video/:tid",
} as const;
/**
diff --git a/js/app/src/screens/video.tsx b/js/app/src/screens/video.tsx
index bff14d446..dca7ed4c8 100644
--- a/js/app/src/screens/video.tsx
+++ b/js/app/src/screens/video.tsx
@@ -1,44 +1,36 @@
-import { Text, useStreamplaceStore, View, zero } from "@streamplace/components";
-import { Platform } from "react-native";
+import { useStreamplaceStore, View, zero } from "@streamplace/components";
+import { Redirect } from "components/aqlink";
import { Player } from "../../components/mobile/player";
const BBB_HLS_URL = "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8";
-function usePlaybackUrl(src?: string): string {
+function usePlaybackUrl(user: string, tid: string): string {
const serverUrl = useStreamplaceStore((x) => x.url);
- if (!src) return BBB_HLS_URL;
- if (src.startsWith("http://") || src.startsWith("https://")) return src;
- return `${serverUrl}/api/playback/${src}/hls/index.m3u8`;
-}
-
-function useQuerySrc(routeSrc?: string): string | undefined {
- if (Platform.OS === "web") {
- const qUrl = new URLSearchParams(window.location.search).get("url");
- if (qUrl) return qUrl;
- }
- return routeSrc;
+ return `at://${user}/place.stream.video/${tid}`;
}
export default function VideoScreen({
route,
}: {
- route?: { params?: { src?: string } };
+ route?: { params?: { user?: string; tid?: string } };
}) {
- const src = useQuerySrc(route?.params?.src);
- const url = usePlaybackUrl(src);
+ if (!route?.params?.user || !route?.params?.tid) {
+ return ;
+ }
+ const url = usePlaybackUrl(route.params.user, route.params.tid);
return (
- {src && (
+ {/*{src && (
{src}
- )}
+ )}*/}
);
}
diff --git a/js/components/package.json b/js/components/package.json
index 0f543e82c..9f20f50d8 100644
--- a/js/components/package.json
+++ b/js/components/package.json
@@ -28,6 +28,7 @@
"dependencies": {
"@atproto/api": "^0.19.3",
"@atproto/crypto": "^0.4.5",
+ "@atproto/syntax": "^0.6.1",
"@emoji-mart/react": "^1.1.1",
"@fluent/bundle": "^0.19.1",
"@fluent/langneg": "^0.7.0",
diff --git a/js/components/src/components/mobile-player/shared.tsx b/js/components/src/components/mobile-player/shared.tsx
index 0c0fd575b..972b0c5e0 100644
--- a/js/components/src/components/mobile-player/shared.tsx
+++ b/js/components/src/components/mobile-player/shared.tsx
@@ -1,3 +1,4 @@
+import { AtUri } from "@atproto/syntax";
import { useMemo } from "react";
import { PlayerProtocol } from "../../player-store/player-state";
import { useStreamplaceStore } from "../../streamplace-store";
@@ -21,6 +22,19 @@ export function srcToUrl(
} {
const url = useStreamplaceStore((x) => x.url);
return useMemo(() => {
+ if (props.src.startsWith("at://")) {
+ const aturi = new AtUri(props.src);
+ if (aturi.collection === "place.stream.video") {
+ return {
+ url: `${url}/xrpc/place.stream.playback.getVideoPlaylist?uri=${props.src}`,
+ protocol: PlayerProtocol.HLS,
+ };
+ } else {
+ throw new Error(
+ `this player doesn't know how to play ${aturi.collection} records (${props.src})`,
+ );
+ }
+ }
if (props.src.startsWith("http://") || props.src.startsWith("https://")) {
const segments = props.src.split(/[./]/);
const suffix = segments[segments.length - 1];
diff --git a/js/components/src/hooks/index.ts b/js/components/src/hooks/index.ts
index 17621c262..b436308ad 100644
--- a/js/components/src/hooks/index.ts
+++ b/js/components/src/hooks/index.ts
@@ -11,3 +11,4 @@ export * from "./usePlayerDimensions";
export * from "./usePointerDevice";
export * from "./useSegmentDimensions";
export * from "./useSegmentTiming";
+export * from "./useTitle";
diff --git a/js/components/src/hooks/useTitle.ts b/js/components/src/hooks/useTitle.ts
new file mode 100644
index 000000000..8d847f0ba
--- /dev/null
+++ b/js/components/src/hooks/useTitle.ts
@@ -0,0 +1,21 @@
+import { useLivestreamStoreOptional } from "../livestream-store";
+import { usePlayerStore } from "../player-store";
+import { useVideoStoreOptional } from "../video-store";
+
+// Surfaces the human-readable title for whatever the player is currently
+// showing, transparently switching between the live stream record and the
+// VOD record based on the player's mode. This lets player UI stay generic:
+// `const title = useTitle()` works the same in live and vod contexts.
+//
+// Reads from the *optional* store hooks so it's safe to call even when only
+// one of the providers (LivestreamProvider / VideoProvider) is mounted.
+// Returns undefined when no title is available yet (e.g. metadata still
+// loading) so callers can supply their own fallback.
+export function useTitle(): string | undefined {
+ const mode = usePlayerStore((x) => x.mode);
+ const liveTitle = useLivestreamStoreOptional(
+ (x) => x.livestream?.record.title,
+ );
+ const vodTitle = useVideoStoreOptional((x) => x.video?.record.title);
+ return mode === "vod" ? vodTitle : liveTitle;
+}
diff --git a/js/components/src/livestream-store/livestream-store.tsx b/js/components/src/livestream-store/livestream-store.tsx
index ed8e935c0..f29306b11 100644
--- a/js/components/src/livestream-store/livestream-store.tsx
+++ b/js/components/src/livestream-store/livestream-store.tsx
@@ -54,6 +54,22 @@ export function useLivestreamStore(
return useStore(store, selector);
}
+// A shared, never-populated store used as a fallback so the optional hook
+// below can call useStore unconditionally even when no LivestreamProvider is
+// mounted (e.g. when the player is in "vod" mode).
+const emptyLivestreamStore = makeLivestreamStore();
+
+// Like useLivestreamStore, but reads against an empty store instead of
+// throwing when there's no LivestreamProvider in the tree. Useful for
+// mode-generic hooks (see useTitle) that may run outside a livestream context.
+export function useLivestreamStoreOptional(
+ selector: (state: LivestreamState) => U,
+): U {
+ const context = useContext(LivestreamContext);
+ const store = context?.store ?? emptyLivestreamStore;
+ return useStore(store, selector);
+}
+
export const useHandleWebsocketMessages = () => {
const store = getStoreFromContext();
return (messages: any[]) => {
diff --git a/js/components/src/video-store/video-store.tsx b/js/components/src/video-store/video-store.tsx
index 5a8a3cd9d..995552021 100644
--- a/js/components/src/video-store/video-store.tsx
+++ b/js/components/src/video-store/video-store.tsx
@@ -31,6 +31,22 @@ export function useVideoStore(selector: (state: VideoState) => U): U {
return useStore(store, selector);
}
+// A shared, never-populated store used as a fallback so the optional hook
+// below can call useStore unconditionally even when no VideoProvider is
+// mounted (e.g. when the player is in "live" mode).
+const emptyVideoStore = makeVideoStore({ aturi: "" });
+
+// Like useVideoStore, but reads against an empty store instead of throwing
+// when there's no VideoProvider in the tree. Useful for mode-generic hooks
+// (see useTitle) that may run outside a video context.
+export function useVideoStoreOptional(
+ selector: (state: VideoState) => U,
+): U {
+ const context = useContext(VideoContext);
+ const store = context?.store ?? emptyVideoStore;
+ return useStore(store, selector);
+}
+
/* Convenience selectors/hooks */
export const useVideo = () => useVideoStore((x) => x.video);
diff --git a/js/streamplace/src/useful-types.ts b/js/streamplace/src/useful-types.ts
index 8ddf81c44..230bf200d 100644
--- a/js/streamplace/src/useful-types.ts
+++ b/js/streamplace/src/useful-types.ts
@@ -3,6 +3,7 @@ import {
PlaceStreamChatMessage,
PlaceStreamChatPinnedRecord,
PlaceStreamLivestream,
+ PlaceStreamMediaGetVideo,
} from "./lexicons";
export interface LivestreamViewHydrated
@@ -19,3 +20,7 @@ export interface PinnedRecordViewHydrated
extends PlaceStreamChatDefs.PinnedRecordView {
record: PlaceStreamChatPinnedRecord.Record;
}
+
+export interface VideoViewHydrated extends PlaceStreamMediaGetVideo.VideoView {
+ record: PlaceStreamLivestream.Record;
+}
diff --git a/pkg/spxrpc/place_stream_playback_getvideo.go b/pkg/spxrpc/place_stream_playback_getvideo.go
index 38403b0b0..881abd1fc 100644
--- a/pkg/spxrpc/place_stream_playback_getvideo.go
+++ b/pkg/spxrpc/place_stream_playback_getvideo.go
@@ -273,12 +273,29 @@ func (s *Server) HandleGetVideoPlaylist(c echo.Context) error {
return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("UnsupportedCollection: %s", aturi.Collection()))
}
- // The authority must be a DID, not a handle: media playlists embed
- // it in every getVideoBlob URL for egress accounting, and we want
- // attribution keyed on the stable identifier.
- ownerDID, err := aturi.Authority().AsDID()
- if err != nil {
- return echo.NewHTTPError(http.StatusBadRequest, "uri authority must be a DID: "+err.Error())
+ var ownerDID syntax.DID
+ if aturi.Authority().IsHandle() {
+ handle, err := aturi.Authority().AsHandle()
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, "failed to convert handle: "+err.Error())
+ }
+ res, err := s.resolveStreamer(ctx, handle.String())
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, "failed to resolve streamer: "+err.Error())
+ }
+ ownerDID, err = syntax.ParseDID(res)
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, "resolved streamer must be a valid DID: "+err.Error())
+ }
+ aturi, err = syntax.ParseATURI(fmt.Sprintf("at://%s/%s", ownerDID.String(), aturi.Path()))
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, "failed to parse AT-URI: "+err.Error())
+ }
+ } else {
+ ownerDID, err = aturi.Authority().AsDID()
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, "uri authority must be a DID: "+err.Error())
+ }
}
if s.playbackStore == nil {
return echo.NewHTTPError(http.StatusServiceUnavailable, "playback store not configured")
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2cfb36ee6..e89e6637c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -399,9 +399,12 @@ importers:
'@atproto/crypto':
specifier: ^0.4.5
version: 0.4.5
+ '@atproto/syntax':
+ specifier: ^0.6.1
+ version: 0.6.1
'@emoji-mart/react':
specifier: ^1.1.1
- version: 1.1.1(emoji-mart@5.6.0)(react@19.2.0)
+ version: 1.1.1(emoji-mart@5.6.0)(react@19.2.4)
'@fluent/bundle':
specifier: ^0.19.1
version: 0.19.1
@@ -410,43 +413,43 @@ importers:
version: 0.7.0
'@fluent/react':
specifier: ^0.15.2
- version: 0.15.2(@fluent/bundle@0.19.1)(react@19.2.0)
+ version: 0.15.2(@fluent/bundle@0.19.1)(react@19.2.4)
'@gorhom/bottom-sheet':
specifier: ^5.2.8
- version: 5.2.8(@types/react@19.2.14)(react-native-gesture-handler@2.30.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native-reanimated@4.2.2(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
+ version: 5.2.8(@types/react@19.2.14)(react-native-gesture-handler@2.30.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native-reanimated@4.2.2(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
'@radix-ui/react-dropdown-menu':
specifier: ^2.1.16
- version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
'@rn-primitives/dropdown-menu':
specifier: ^1.2.0
- version: 1.2.0(@rn-primitives/portal@1.3.0(@types/react@19.2.14)(immer@10.1.1)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0)))(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
+ version: 1.2.0(@rn-primitives/portal@1.3.0(@types/react@19.2.14)(immer@10.1.1)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
'@rn-primitives/portal':
specifier: ^1.3.0
- version: 1.3.0(@types/react@19.2.14)(immer@10.1.1)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0))
+ version: 1.3.0(@types/react@19.2.14)(immer@10.1.1)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
'@rn-primitives/slider':
specifier: ^1.2.0
- version: 1.2.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
+ version: 1.2.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
class-variance-authority:
specifier: ^0.6.1
version: 0.6.1
expo-image:
specifier: ^55.0.6
- version: 55.0.6(expo@55.0.6)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
+ version: 55.0.6(expo@55.0.6)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
expo-keep-awake:
specifier: ^55.0.4
- version: 55.0.4(expo@55.0.6)(react@19.2.0)
+ version: 55.0.4(expo@55.0.6)(react@19.2.4)
expo-localization:
specifier: '*'
version: 55.0.8(expo@55.0.6)(react@19.2.0)
expo-screen-orientation:
specifier: ^55.0.8
- version: 55.0.8(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))
+ version: 55.0.8(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))
expo-sqlite:
specifier: ^55.0.10
- version: 55.0.10(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
+ version: 55.0.10(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
expo-video:
specifier: ^55.0.10
- version: 55.0.10(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
+ version: 55.0.10(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
graphemer:
specifier: ^1.4.0
version: 1.4.0
@@ -473,34 +476,34 @@ importers:
version: 2.0.1
lucide-react-native:
specifier: ^0.562.0
- version: 0.562.0(react-native-svg@15.15.3(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
+ version: 0.562.0(react-native-svg@15.15.3(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
react:
specifier: '*'
- version: 19.2.0
+ version: 19.2.4
react-i18next:
specifier: ^15.7.3
- version: 15.7.4(i18next@25.8.18(typescript@5.9.3))(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(typescript@5.9.3)
+ version: 15.7.4(i18next@25.8.18(typescript@5.9.3))(react-dom@19.2.0(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)
react-native:
specifier: ^0.83.2
- version: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
+ version: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
react-native-edge-to-edge:
specifier: ^1.6.2
- version: 1.8.1(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
+ version: 1.8.1(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
react-native-gesture-handler:
specifier: ^2.30.0
- version: 2.30.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
+ version: 2.30.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
react-native-reanimated:
specifier: ~4.2.1
- version: 4.2.2(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
+ version: 4.2.2(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
react-native-safe-area-context:
specifier: ^5.7.0
- version: 5.7.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
+ version: 5.7.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
react-native-svg:
specifier: ^15.15.3
- version: 15.15.3(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
+ version: 15.15.3(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
react-native-webrtc:
specifier: git+https://github.com/streamplace/react-native-webrtc.git#74fa32266e3a2fee180f5e01bb8753af2a92d9d3
- version: https://codeload.github.com/streamplace/react-native-webrtc/tar.gz/74fa32266e3a2fee180f5e01bb8753af2a92d9d3(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))
+ version: https://codeload.github.com/streamplace/react-native-webrtc/tar.gz/74fa32266e3a2fee180f5e01bb8753af2a92d9d3(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))
react-use-websocket:
specifier: ^4.13.0
version: 4.13.0
@@ -512,7 +515,7 @@ importers:
version: 2.47.2(bufferutil@4.0.8)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)
zustand:
specifier: ^5.0.5
- version: 5.0.11(@types/react@19.2.14)(immer@10.1.1)(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0))
+ version: 5.0.11(@types/react@19.2.14)(immer@10.1.1)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
devDependencies:
'@fluent/syntax':
specifier: ^0.19.0
@@ -525,7 +528,7 @@ importers:
version: 2.15.0
i18next-cli:
specifier: ^1.32.0
- version: 1.49.7(@swc/helpers@0.5.17)(@types/node@25.5.0)(i18next@25.8.18(typescript@5.9.3))(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(typescript@5.9.3)
+ version: 1.49.7(@swc/helpers@0.5.17)(@types/node@25.5.0)(i18next@25.8.18(typescript@5.9.3))(react-dom@19.2.0(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(typescript@5.9.3)
nodemon:
specifier: ^3.1.10
version: 3.1.14
@@ -963,6 +966,10 @@ packages:
'@atproto/syntax@0.5.0':
resolution: {integrity: sha512-UA2DSpGdOQzUQ4gi5SH+NEJz/YR3a3Fg3y2oh+xETDSiTRmA4VhHRCojhXAVsBxUT6EnItw190C/KN+DWW90kw==}
+ '@atproto/syntax@0.6.1':
+ resolution: {integrity: sha512-kA4dQDoMPpWCH8N0Q4KoSq024u5MkVfDVa8DdhyLjGA72z/khbOf1jXKPv7NIL2oEc9aj7geKELdvqyf4ogopA==}
+ engines: {node: '>=22'}
+
'@atproto/ws-client@0.0.4':
resolution: {integrity: sha512-dox1XIymuC7/ZRhUqKezIGgooZS45C6vHCfu0PnWjfvsLCK2kAlnvX4IBkA/WpcoijDhQ9ejChnFbo/sLmgvAg==}
engines: {node: '>=18.7.0'}
@@ -15794,6 +15801,11 @@ snapshots:
dependencies:
tslib: 2.8.1
+ '@atproto/syntax@0.6.1':
+ dependencies:
+ iso-datestring-validator: 2.2.2
+ tslib: 2.8.1
+
'@atproto/ws-client@0.0.4(bufferutil@4.0.8)(utf-8-validate@5.0.10)':
dependencies:
'@atproto/common': 0.5.14
@@ -17628,7 +17640,7 @@ snapshots:
'@config-plugins/react-native-webrtc@10.0.0(expo@55.0.6)':
dependencies:
- expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@5.0.10)
+ expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.3.3)(utf-8-validate@5.0.10)
'@croct/json5-parser@0.2.2':
dependencies:
@@ -18198,10 +18210,10 @@ snapshots:
tslib: 2.8.1
optional: true
- '@emoji-mart/react@1.1.1(emoji-mart@5.6.0)(react@19.2.0)':
+ '@emoji-mart/react@1.1.1(emoji-mart@5.6.0)(react@19.2.4)':
dependencies:
emoji-mart: 5.6.0
- react: 19.2.0
+ react: 19.2.4
'@esbuild/aix-ppc64@0.25.12':
optional: true
@@ -18635,6 +18647,156 @@ snapshots:
- typescript
- utf-8-validate
+ '@expo/cli@55.0.16(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(expo-constants@55.0.7(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(typescript@5.9.3))(expo-font@55.0.4(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(expo@55.0.6)(react-dom@19.2.0(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)':
+ dependencies:
+ '@expo/code-signing-certificates': 0.0.6
+ '@expo/config': 55.0.8(typescript@5.9.3)
+ '@expo/config-plugins': 55.0.6
+ '@expo/devcert': 1.2.1
+ '@expo/env': 2.1.1
+ '@expo/image-utils': 0.8.12
+ '@expo/json-file': 10.0.12
+ '@expo/log-box': 55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ '@expo/metro': 54.2.0(bufferutil@4.0.8)(utf-8-validate@5.0.10)
+ '@expo/metro-config': 55.0.9(bufferutil@4.0.8)(expo@55.0.6)(typescript@5.9.3)(utf-8-validate@5.0.10)
+ '@expo/osascript': 2.4.2
+ '@expo/package-manager': 1.10.3
+ '@expo/plist': 0.5.2
+ '@expo/prebuild-config': 55.0.8(expo@55.0.6)(typescript@5.9.3)
+ '@expo/require-utils': 55.0.2(typescript@5.9.3)
+ '@expo/router-server': 55.0.10(@expo/metro-runtime@55.0.6)(expo-constants@55.0.7(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(typescript@5.9.3))(expo-font@55.0.4(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(expo-server@55.0.6)(expo@55.0.6)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
+ '@expo/schema-utils': 55.0.2
+ '@expo/spawn-async': 1.7.2
+ '@expo/ws-tunnel': 1.0.6
+ '@expo/xcpretty': 4.4.1
+ '@react-native/dev-middleware': 0.83.2(bufferutil@4.0.8)(utf-8-validate@5.0.10)
+ accepts: 1.3.8
+ arg: 5.0.2
+ better-opn: 3.0.2
+ bplist-creator: 0.1.0
+ bplist-parser: 0.3.2
+ chalk: 4.1.2
+ ci-info: 3.9.0
+ compression: 1.8.1
+ connect: 3.7.0
+ debug: 4.4.3
+ dnssd-advertise: 1.1.3
+ expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)
+ expo-server: 55.0.6
+ fetch-nodeshim: 0.4.9
+ getenv: 2.0.0
+ glob: 13.0.6
+ lan-network: 0.2.0
+ multitars: 0.2.4
+ node-forge: 1.3.3
+ npm-package-arg: 11.0.3
+ ora: 3.4.0
+ picomatch: 4.0.3
+ pretty-format: 29.7.0
+ progress: 2.0.3
+ prompts: 2.4.2
+ resolve-from: 5.0.0
+ semver: 7.7.4
+ send: 0.19.2
+ slugify: 1.6.6
+ source-map-support: 0.5.21
+ stacktrace-parser: 0.1.11
+ structured-headers: 0.4.1
+ terminal-link: 2.1.1
+ toqr: 0.1.1
+ wrap-ansi: 7.0.0
+ ws: 8.19.0(bufferutil@4.0.8)(utf-8-validate@5.0.10)
+ zod: 3.25.76
+ optionalDependencies:
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+ transitivePeerDependencies:
+ - '@expo/dom-webview'
+ - '@expo/metro-runtime'
+ - bufferutil
+ - expo-constants
+ - expo-font
+ - react
+ - react-dom
+ - react-server-dom-webpack
+ - supports-color
+ - typescript
+ - utf-8-validate
+
+ '@expo/cli@55.0.16(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(expo-constants@55.0.7(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(typescript@5.3.3))(expo-font@55.0.4(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(expo@55.0.6)(react-dom@19.2.0(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.3.3)(utf-8-validate@5.0.10)':
+ dependencies:
+ '@expo/code-signing-certificates': 0.0.6
+ '@expo/config': 55.0.8(typescript@5.3.3)
+ '@expo/config-plugins': 55.0.6
+ '@expo/devcert': 1.2.1
+ '@expo/env': 2.1.1
+ '@expo/image-utils': 0.8.12
+ '@expo/json-file': 10.0.12
+ '@expo/log-box': 55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ '@expo/metro': 54.2.0(bufferutil@4.0.8)(utf-8-validate@5.0.10)
+ '@expo/metro-config': 55.0.9(bufferutil@4.0.8)(expo@55.0.6)(typescript@5.3.3)(utf-8-validate@5.0.10)
+ '@expo/osascript': 2.4.2
+ '@expo/package-manager': 1.10.3
+ '@expo/plist': 0.5.2
+ '@expo/prebuild-config': 55.0.8(expo@55.0.6)(typescript@5.3.3)
+ '@expo/require-utils': 55.0.2(typescript@5.3.3)
+ '@expo/router-server': 55.0.10(@expo/metro-runtime@55.0.6)(expo-constants@55.0.7(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(typescript@5.3.3))(expo-font@55.0.4(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(expo-server@55.0.6)(expo@55.0.6)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
+ '@expo/schema-utils': 55.0.2
+ '@expo/spawn-async': 1.7.2
+ '@expo/ws-tunnel': 1.0.6
+ '@expo/xcpretty': 4.4.1
+ '@react-native/dev-middleware': 0.83.2(bufferutil@4.0.8)(utf-8-validate@5.0.10)
+ accepts: 1.3.8
+ arg: 5.0.2
+ better-opn: 3.0.2
+ bplist-creator: 0.1.0
+ bplist-parser: 0.3.2
+ chalk: 4.1.2
+ ci-info: 3.9.0
+ compression: 1.8.1
+ connect: 3.7.0
+ debug: 4.4.3
+ dnssd-advertise: 1.1.3
+ expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.3.3)(utf-8-validate@5.0.10)
+ expo-server: 55.0.6
+ fetch-nodeshim: 0.4.9
+ getenv: 2.0.0
+ glob: 13.0.6
+ lan-network: 0.2.0
+ multitars: 0.2.4
+ node-forge: 1.3.3
+ npm-package-arg: 11.0.3
+ ora: 3.4.0
+ picomatch: 4.0.3
+ pretty-format: 29.7.0
+ progress: 2.0.3
+ prompts: 2.4.2
+ resolve-from: 5.0.0
+ semver: 7.7.4
+ send: 0.19.2
+ slugify: 1.6.6
+ source-map-support: 0.5.21
+ stacktrace-parser: 0.1.11
+ structured-headers: 0.4.1
+ terminal-link: 2.1.1
+ toqr: 0.1.1
+ wrap-ansi: 7.0.0
+ ws: 8.19.0(bufferutil@4.0.8)(utf-8-validate@5.0.10)
+ zod: 3.25.76
+ optionalDependencies:
+ react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+ transitivePeerDependencies:
+ - '@expo/dom-webview'
+ - '@expo/metro-runtime'
+ - bufferutil
+ - expo-constants
+ - expo-font
+ - react
+ - react-dom
+ - react-server-dom-webpack
+ - supports-color
+ - typescript
+ - utf-8-validate
+
'@expo/code-signing-certificates@0.0.6':
dependencies:
node-forge: 1.3.3
@@ -18659,6 +18821,23 @@ snapshots:
'@expo/config-types@55.0.5': {}
+ '@expo/config@55.0.8(typescript@5.3.3)':
+ dependencies:
+ '@expo/config-plugins': 55.0.6
+ '@expo/config-types': 55.0.5
+ '@expo/json-file': 10.0.12
+ '@expo/require-utils': 55.0.2(typescript@5.3.3)
+ deepmerge: 4.3.1
+ getenv: 2.0.0
+ glob: 13.0.6
+ resolve-from: 5.0.0
+ resolve-workspace-root: 2.0.0
+ semver: 7.7.1
+ slugify: 1.6.6
+ transitivePeerDependencies:
+ - supports-color
+ - typescript
+
'@expo/config@55.0.8(typescript@5.9.3)':
dependencies:
'@expo/config-plugins': 55.0.6
@@ -18690,12 +18869,38 @@ snapshots:
react: 19.2.0
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
+ '@expo/devtools@55.0.2(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
+ dependencies:
+ chalk: 4.1.2
+ optionalDependencies:
+ react: 19.2.4
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+
+ '@expo/devtools@55.0.2(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
+ dependencies:
+ chalk: 4.1.2
+ optionalDependencies:
+ react: 19.2.4
+ react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+
'@expo/dom-webview@55.0.3(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)':
dependencies:
expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@5.0.10)
react: 19.2.0
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
+ '@expo/dom-webview@55.0.3(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
+ dependencies:
+ expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)
+ react: 19.2.4
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+
+ '@expo/dom-webview@55.0.3(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
+ dependencies:
+ expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.3.3)(utf-8-validate@5.0.10)
+ react: 19.2.4
+ react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+
'@expo/env@2.1.1':
dependencies:
chalk: 4.1.2
@@ -18735,6 +18940,14 @@ snapshots:
'@babel/code-frame': 7.29.0
json5: 2.2.3
+ '@expo/local-build-cache-provider@55.0.6(typescript@5.3.3)':
+ dependencies:
+ '@expo/config': 55.0.8(typescript@5.3.3)
+ chalk: 4.1.2
+ transitivePeerDependencies:
+ - supports-color
+ - typescript
+
'@expo/local-build-cache-provider@55.0.6(typescript@5.9.3)':
dependencies:
'@expo/config': 55.0.8(typescript@5.9.3)
@@ -18752,6 +18965,53 @@ snapshots:
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
stacktrace-parser: 0.1.10
+ '@expo/log-box@55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
+ dependencies:
+ '@expo/dom-webview': 55.0.3(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ anser: 1.4.10
+ expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)
+ react: 19.2.4
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+ stacktrace-parser: 0.1.10
+
+ '@expo/log-box@55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
+ dependencies:
+ '@expo/dom-webview': 55.0.3(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ anser: 1.4.10
+ expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.3.3)(utf-8-validate@5.0.10)
+ react: 19.2.4
+ react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+ stacktrace-parser: 0.1.10
+
+ '@expo/metro-config@55.0.9(bufferutil@4.0.8)(expo@55.0.6)(typescript@5.3.3)(utf-8-validate@5.0.10)':
+ dependencies:
+ '@babel/code-frame': 7.29.0
+ '@babel/core': 7.29.0
+ '@babel/generator': 7.29.1
+ '@expo/config': 55.0.8(typescript@5.3.3)
+ '@expo/env': 2.1.1
+ '@expo/json-file': 10.0.12
+ '@expo/metro': 54.2.0(bufferutil@4.0.8)(utf-8-validate@5.0.10)
+ '@expo/spawn-async': 1.7.2
+ browserslist: 4.28.1
+ chalk: 4.1.2
+ debug: 4.4.3
+ getenv: 2.0.0
+ glob: 13.0.6
+ hermes-parser: 0.32.1
+ jsc-safe-url: 0.2.4
+ lightningcss: 1.31.1
+ picomatch: 4.0.3
+ postcss: 8.4.47
+ resolve-from: 5.0.0
+ optionalDependencies:
+ expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.3.3)(utf-8-validate@5.0.10)
+ transitivePeerDependencies:
+ - bufferutil
+ - supports-color
+ - typescript
+ - utf-8-validate
+
'@expo/metro-config@55.0.9(bufferutil@4.0.8)(expo@55.0.6)(typescript@5.9.3)(utf-8-validate@5.0.10)':
dependencies:
'@babel/code-frame': 7.29.0
@@ -18774,7 +19034,7 @@ snapshots:
postcss: 8.4.47
resolve-from: 5.0.0
optionalDependencies:
- expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@5.0.10)
+ expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)
transitivePeerDependencies:
- bufferutil
- supports-color
@@ -18796,6 +19056,38 @@ snapshots:
transitivePeerDependencies:
- '@expo/dom-webview'
+ '@expo/metro-runtime@55.0.6(@expo/dom-webview@55.0.3)(expo@55.0.6)(react-dom@19.2.0(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
+ dependencies:
+ '@expo/log-box': 55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ anser: 1.4.10
+ expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)
+ pretty-format: 29.7.0
+ react: 19.2.4
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+ stacktrace-parser: 0.1.10
+ whatwg-fetch: 3.6.20
+ optionalDependencies:
+ react-dom: 19.2.0(react@19.2.4)
+ transitivePeerDependencies:
+ - '@expo/dom-webview'
+ optional: true
+
+ '@expo/metro-runtime@55.0.6(@expo/dom-webview@55.0.3)(expo@55.0.6)(react-dom@19.2.0(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
+ dependencies:
+ '@expo/log-box': 55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ anser: 1.4.10
+ expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.3.3)(utf-8-validate@5.0.10)
+ pretty-format: 29.7.0
+ react: 19.2.4
+ react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+ stacktrace-parser: 0.1.10
+ whatwg-fetch: 3.6.20
+ optionalDependencies:
+ react-dom: 19.2.0(react@19.2.4)
+ transitivePeerDependencies:
+ - '@expo/dom-webview'
+ optional: true
+
'@expo/metro@54.2.0(bufferutil@4.0.8)(utf-8-validate@5.0.10)':
dependencies:
metro: 0.83.3(bufferutil@4.0.8)(utf-8-validate@5.0.10)
@@ -18836,6 +19128,23 @@ snapshots:
base64-js: 1.5.1
xmlbuilder: 15.1.1
+ '@expo/prebuild-config@55.0.8(expo@55.0.6)(typescript@5.3.3)':
+ dependencies:
+ '@expo/config': 55.0.8(typescript@5.3.3)
+ '@expo/config-plugins': 55.0.6
+ '@expo/config-types': 55.0.5
+ '@expo/image-utils': 0.8.12
+ '@expo/json-file': 10.0.12
+ '@react-native/normalize-colors': 0.83.2
+ debug: 4.4.3
+ expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.3.3)(utf-8-validate@5.0.10)
+ resolve-from: 5.0.0
+ semver: 7.7.1
+ xml2js: 0.6.0
+ transitivePeerDependencies:
+ - supports-color
+ - typescript
+
'@expo/prebuild-config@55.0.8(expo@55.0.6)(typescript@5.9.3)':
dependencies:
'@expo/config': 55.0.8(typescript@5.9.3)
@@ -18845,7 +19154,7 @@ snapshots:
'@expo/json-file': 10.0.12
'@react-native/normalize-colors': 0.83.2
debug: 4.4.3
- expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@5.0.10)
+ expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)
resolve-from: 5.0.0
semver: 7.7.1
xml2js: 0.6.0
@@ -18853,6 +19162,16 @@ snapshots:
- supports-color
- typescript
+ '@expo/require-utils@55.0.2(typescript@5.3.3)':
+ dependencies:
+ '@babel/code-frame': 7.29.0
+ '@babel/core': 7.29.0
+ '@babel/plugin-transform-modules-commonjs': 7.27.1(@babel/core@7.29.0)
+ optionalDependencies:
+ typescript: 5.3.3
+ transitivePeerDependencies:
+ - supports-color
+
'@expo/require-utils@55.0.2(typescript@5.9.3)':
dependencies:
'@babel/code-frame': 7.29.0
@@ -18877,6 +19196,34 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@expo/router-server@55.0.10(@expo/metro-runtime@55.0.6)(expo-constants@55.0.7(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(typescript@5.9.3))(expo-font@55.0.4(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(expo-server@55.0.6)(expo@55.0.6)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)':
+ dependencies:
+ debug: 4.4.3
+ expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)
+ expo-constants: 55.0.7(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(typescript@5.9.3)
+ expo-font: 55.0.4(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ expo-server: 55.0.6
+ react: 19.2.4
+ optionalDependencies:
+ '@expo/metro-runtime': 55.0.6(@expo/dom-webview@55.0.3)(expo@55.0.6)(react-dom@19.2.0(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ react-dom: 19.2.0(react@19.2.4)
+ transitivePeerDependencies:
+ - supports-color
+
+ '@expo/router-server@55.0.10(@expo/metro-runtime@55.0.6)(expo-constants@55.0.7(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(typescript@5.3.3))(expo-font@55.0.4(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(expo-server@55.0.6)(expo@55.0.6)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)':
+ dependencies:
+ debug: 4.4.3
+ expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.3.3)(utf-8-validate@5.0.10)
+ expo-constants: 55.0.7(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(typescript@5.3.3)
+ expo-font: 55.0.4(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ expo-server: 55.0.6
+ react: 19.2.4
+ optionalDependencies:
+ '@expo/metro-runtime': 55.0.6(@expo/dom-webview@55.0.3)(expo@55.0.6)(react-dom@19.2.0(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ react-dom: 19.2.0(react@19.2.4)
+ transitivePeerDependencies:
+ - supports-color
+
'@expo/schema-utils@55.0.2': {}
'@expo/sdk-runtime-versions@1.0.0': {}
@@ -18893,6 +19240,18 @@ snapshots:
react: 19.2.0
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
+ '@expo/vector-icons@15.1.1(expo-font@55.0.4(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
+ dependencies:
+ expo-font: 55.0.4(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ react: 19.2.4
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+
+ '@expo/vector-icons@15.1.1(expo-font@55.0.4(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
+ dependencies:
+ expo-font: 55.0.4(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ react: 19.2.4
+ react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+
'@expo/ws-tunnel@1.0.6': {}
'@expo/xcpretty@4.4.1':
@@ -19298,11 +19657,11 @@ snapshots:
'@floating-ui/core': 1.6.4
'@floating-ui/utils': 0.2.9
- '@floating-ui/react-dom@2.1.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ '@floating-ui/react-dom@2.1.3(react-dom@19.2.0(react@19.2.4))(react@19.2.4)':
dependencies:
'@floating-ui/dom': 1.6.7
- react: 19.2.0
- react-dom: 19.2.0(react@19.2.0)
+ react: 19.2.4
+ react-dom: 19.2.0(react@19.2.4)
'@floating-ui/utils@0.2.9': {}
@@ -19319,6 +19678,13 @@ snapshots:
cached-iterable: 0.3.0
react: 19.2.0
+ '@fluent/react@0.15.2(@fluent/bundle@0.19.1)(react@19.2.4)':
+ dependencies:
+ '@fluent/bundle': 0.19.1
+ '@fluent/sequence': 0.8.0(@fluent/bundle@0.19.1)
+ cached-iterable: 0.3.0
+ react: 19.2.4
+
'@fluent/sequence@0.8.0(@fluent/bundle@0.19.1)':
dependencies:
'@fluent/bundle': 0.19.1
@@ -19399,22 +19765,22 @@ snapshots:
- supports-color
optional: true
- '@gorhom/bottom-sheet@5.2.8(@types/react@19.2.14)(react-native-gesture-handler@2.30.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native-reanimated@4.2.2(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)':
+ '@gorhom/bottom-sheet@5.2.8(@types/react@19.2.14)(react-native-gesture-handler@2.30.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native-reanimated@4.2.2(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
dependencies:
- '@gorhom/portal': 1.0.14(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
+ '@gorhom/portal': 1.0.14(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
invariant: 2.2.4
- react: 19.2.0
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
- react-native-gesture-handler: 2.30.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
- react-native-reanimated: 4.2.2(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
+ react: 19.2.4
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+ react-native-gesture-handler: 2.30.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ react-native-reanimated: 4.2.2(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
- '@gorhom/portal@1.0.14(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)':
+ '@gorhom/portal@1.0.14(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
dependencies:
nanoid: 3.3.11
- react: 19.2.0
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
+ react: 19.2.4
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
'@grpc/grpc-js@1.10.10':
dependencies:
@@ -20488,264 +20854,264 @@ snapshots:
'@radix-ui/primitive@1.1.3': {}
- '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)':
dependencies:
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
- react: 19.2.0
- react-dom: 19.2.0(react@19.2.0)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
+ react: 19.2.4
+ react-dom: 19.2.0(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)':
dependencies:
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
- '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.0)
- react: 19.2.0
- react-dom: 19.2.0(react@19.2.0)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.4
+ react-dom: 19.2.0(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.0)':
+ '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.4)':
dependencies:
- react: 19.2.0
+ react: 19.2.4
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.0)':
+ '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.4)':
dependencies:
- react: 19.2.0
+ react: 19.2.4
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.0)':
+ '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.4)':
dependencies:
- react: 19.2.0
+ react: 19.2.4
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.0)
- '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.0)
- react: 19.2.0
- react-dom: 19.2.0(react@19.2.0)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.4
+ react-dom: 19.2.0(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.0)
- '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.0)
- react: 19.2.0
- react-dom: 19.2.0(react@19.2.0)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.4
+ react-dom: 19.2.0(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.0)':
+ '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.4)':
dependencies:
- react: 19.2.0
+ react: 19.2.4
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)':
dependencies:
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.0)
- react: 19.2.0
- react-dom: 19.2.0(react@19.2.0)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.4
+ react-dom: 19.2.0(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.0)':
+ '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.4)':
dependencies:
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0)
- react: 19.2.0
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.4
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.0)
- '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
- '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.0)
- '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.0)
- '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
- '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
- '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
- '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.0)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.0)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
+ '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
aria-hidden: 1.2.4
- react: 19.2.0
- react-dom: 19.2.0(react@19.2.0)
- react-remove-scroll: 2.7.1(@types/react@19.2.14)(react@19.2.0)
+ react: 19.2.4
+ react-dom: 19.2.0(react@19.2.4)
+ react-remove-scroll: 2.7.1(@types/react@19.2.14)(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
- dependencies:
- '@floating-ui/react-dom': 2.1.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
- '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.0)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0)
- '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.0)
- '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.0)
+ '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)':
+ dependencies:
+ '@floating-ui/react-dom': 2.1.3(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
+ '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/rect': 1.1.1
- react: 19.2.0
- react-dom: 19.2.0(react@19.2.0)
+ react: 19.2.4
+ react-dom: 19.2.0(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)':
dependencies:
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0)
- react: 19.2.0
- react-dom: 19.2.0(react@19.2.0)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.4
+ react-dom: 19.2.0(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)':
dependencies:
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0)
- react: 19.2.0
- react-dom: 19.2.0(react@19.2.0)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.4
+ react-dom: 19.2.0(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)':
dependencies:
- '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.0)
- react: 19.2.0
- react-dom: 19.2.0(react@19.2.0)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.4
+ react-dom: 19.2.0(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.0)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.0)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.0)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.0)
- react: 19.2.0
- react-dom: 19.2.0(react@19.2.0)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.4
+ react-dom: 19.2.0(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-slider@1.3.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ '@radix-ui/react-slider@1.3.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/number': 1.1.1
'@radix-ui/primitive': 1.1.2
- '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.0)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.0)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0)
- '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.0)
- '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.0)
- react: 19.2.0
- react-dom: 19.2.0(react@19.2.0)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.4
+ react-dom: 19.2.0(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.0)':
+ '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.4)':
dependencies:
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0)
- react: 19.2.0
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.4
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.0)':
+ '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.4)':
dependencies:
- react: 19.2.0
+ react: 19.2.4
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.0)':
+ '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.4)':
dependencies:
- '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.0)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0)
- react: 19.2.0
+ '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.4
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.0)':
+ '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.4)':
dependencies:
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0)
- react: 19.2.0
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.4
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.0)':
+ '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.4)':
dependencies:
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.0)
- react: 19.2.0
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.4
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.0)':
+ '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.4)':
dependencies:
- react: 19.2.0
+ react: 19.2.4
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.0)':
+ '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.4)':
dependencies:
- react: 19.2.0
+ react: 19.2.4
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.0)':
+ '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.4)':
dependencies:
'@radix-ui/rect': 1.1.1
- react: 19.2.0
+ react: 19.2.4
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.0)':
+ '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.4)':
dependencies:
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0)
- react: 19.2.0
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.4
optionalDependencies:
'@types/react': 19.2.14
@@ -20952,6 +21318,15 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
+ '@react-native/virtualized-lists@0.83.2(@types/react@19.2.14)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
+ dependencies:
+ invariant: 2.2.4
+ nullthrows: 1.1.1
+ react: 19.2.4
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+ optionalDependencies:
+ '@types/react': 19.2.14
+
'@react-native/virtualized-lists@0.83.4(@types/react@19.2.14)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
dependencies:
invariant: 2.2.4
@@ -21108,77 +21483,77 @@ snapshots:
'@reforged/maker-types@2.1.0': {}
- '@rn-primitives/dropdown-menu@1.2.0(@rn-primitives/portal@1.3.0(@types/react@19.2.14)(immer@10.1.1)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0)))(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)':
+ '@rn-primitives/dropdown-menu@1.2.0(@rn-primitives/portal@1.3.0(@types/react@19.2.14)(immer@10.1.1)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
dependencies:
- '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
- '@rn-primitives/hooks': 1.3.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
- '@rn-primitives/portal': 1.3.0(@types/react@19.2.14)(immer@10.1.1)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0))
- '@rn-primitives/slot': 1.2.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
- '@rn-primitives/types': 1.2.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
- '@rn-primitives/utils': 1.2.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
- react: 19.2.0
+ '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
+ '@rn-primitives/hooks': 1.3.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ '@rn-primitives/portal': 1.3.0(@types/react@19.2.14)(immer@10.1.1)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
+ '@rn-primitives/slot': 1.2.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ '@rn-primitives/types': 1.2.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ '@rn-primitives/utils': 1.2.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ react: 19.2.4
optionalDependencies:
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
- react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+ react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
transitivePeerDependencies:
- '@types/react'
- '@types/react-dom'
- react-dom
- '@rn-primitives/hooks@1.3.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)':
+ '@rn-primitives/hooks@1.3.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
dependencies:
- '@rn-primitives/types': 1.2.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
- react: 19.2.0
+ '@rn-primitives/types': 1.2.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ react: 19.2.4
optionalDependencies:
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
- react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+ react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
- '@rn-primitives/portal@1.3.0(@types/react@19.2.14)(immer@10.1.1)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0))':
+ '@rn-primitives/portal@1.3.0(@types/react@19.2.14)(immer@10.1.1)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))':
dependencies:
- react: 19.2.0
- zustand: 5.0.11(@types/react@19.2.14)(immer@10.1.1)(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0))
+ react: 19.2.4
+ zustand: 5.0.11(@types/react@19.2.14)(immer@10.1.1)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
optionalDependencies:
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
- react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+ react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
transitivePeerDependencies:
- '@types/react'
- immer
- use-sync-external-store
- '@rn-primitives/slider@1.2.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)':
+ '@rn-primitives/slider@1.2.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
dependencies:
- '@radix-ui/react-slider': 1.3.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
- '@rn-primitives/slot': 1.2.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
- '@rn-primitives/types': 1.2.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
- react: 19.2.0
+ '@radix-ui/react-slider': 1.3.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
+ '@rn-primitives/slot': 1.2.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ '@rn-primitives/types': 1.2.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ react: 19.2.4
optionalDependencies:
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
- react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+ react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
transitivePeerDependencies:
- '@types/react'
- '@types/react-dom'
- react-dom
- '@rn-primitives/slot@1.2.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)':
+ '@rn-primitives/slot@1.2.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
dependencies:
- react: 19.2.0
+ react: 19.2.4
optionalDependencies:
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
- react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+ react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
- '@rn-primitives/types@1.2.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)':
+ '@rn-primitives/types@1.2.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
dependencies:
- react: 19.2.0
+ react: 19.2.4
optionalDependencies:
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
- react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+ react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
- '@rn-primitives/utils@1.2.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)':
+ '@rn-primitives/utils@1.2.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
dependencies:
- react: 19.2.0
+ react: 19.2.4
optionalDependencies:
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
- react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+ react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
'@rollup/pluginutils@5.3.0(rollup@4.59.0)':
dependencies:
@@ -23514,7 +23889,7 @@ snapshots:
resolve-from: 5.0.0
optionalDependencies:
'@babel/runtime': 7.28.6
- expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@5.0.10)
+ expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)
transitivePeerDependencies:
- '@babel/core'
- supports-color
@@ -25569,6 +25944,28 @@ snapshots:
- supports-color
- typescript
+ expo-asset@55.0.8(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3):
+ dependencies:
+ '@expo/image-utils': 0.8.12
+ expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)
+ expo-constants: 55.0.7(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(typescript@5.9.3)
+ react: 19.2.4
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+ transitivePeerDependencies:
+ - supports-color
+ - typescript
+
+ expo-asset@55.0.8(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.3.3):
+ dependencies:
+ '@expo/image-utils': 0.8.12
+ expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.3.3)(utf-8-validate@5.0.10)
+ expo-constants: 55.0.7(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(typescript@5.3.3)
+ react: 19.2.4
+ react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+ transitivePeerDependencies:
+ - supports-color
+ - typescript
+
expo-build-properties@55.0.9(expo@55.0.6):
dependencies:
'@expo/schema-utils': 55.0.2
@@ -25586,6 +25983,26 @@ snapshots:
- supports-color
- typescript
+ expo-constants@55.0.7(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(typescript@5.9.3):
+ dependencies:
+ '@expo/config': 55.0.8(typescript@5.9.3)
+ '@expo/env': 2.1.1
+ expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+ transitivePeerDependencies:
+ - supports-color
+ - typescript
+
+ expo-constants@55.0.7(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(typescript@5.3.3):
+ dependencies:
+ '@expo/config': 55.0.8(typescript@5.3.3)
+ '@expo/env': 2.1.1
+ expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.3.3)(utf-8-validate@5.0.10)
+ react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+ transitivePeerDependencies:
+ - supports-color
+ - typescript
+
expo-crypto@55.0.9(expo@55.0.6):
dependencies:
expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@5.0.10)
@@ -25632,6 +26049,16 @@ snapshots:
expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@5.0.10)
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
+ expo-file-system@55.0.10(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)):
+ dependencies:
+ expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+
+ expo-file-system@55.0.10(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)):
+ dependencies:
+ expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.3.3)(utf-8-validate@5.0.10)
+ react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+
expo-font@55.0.4(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0):
dependencies:
expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@5.0.10)
@@ -25639,6 +26066,20 @@ snapshots:
react: 19.2.0
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
+ expo-font@55.0.4(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4):
+ dependencies:
+ expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)
+ fontfaceobserver: 2.3.0
+ react: 19.2.4
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+
+ expo-font@55.0.4(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4):
+ dependencies:
+ expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.3.3)(utf-8-validate@5.0.10)
+ fontfaceobserver: 2.3.0
+ react: 19.2.4
+ react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+
expo-image@55.0.6(expo@55.0.6)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0):
dependencies:
expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@5.0.10)
@@ -25648,6 +26089,15 @@ snapshots:
optionalDependencies:
react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ expo-image@55.0.6(expo@55.0.6)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4):
+ dependencies:
+ expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)
+ react: 19.2.4
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+ sf-symbols-typescript: 2.2.0
+ optionalDependencies:
+ react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
+
expo-json-utils@55.0.0: {}
expo-keep-awake@55.0.4(expo@55.0.6)(react@19.2.0):
@@ -25655,6 +26105,11 @@ snapshots:
expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@5.0.10)
react: 19.2.0
+ expo-keep-awake@55.0.4(expo@55.0.6)(react@19.2.4):
+ dependencies:
+ expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)
+ react: 19.2.4
+
expo-linking@55.0.7(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(typescript@5.9.3):
dependencies:
expo-constants: 55.0.7(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(typescript@5.9.3)
@@ -25681,6 +26136,16 @@ snapshots:
- supports-color
- typescript
+ expo-modules-autolinking@55.0.9(typescript@5.3.3):
+ dependencies:
+ '@expo/require-utils': 55.0.2(typescript@5.3.3)
+ '@expo/spawn-async': 1.7.2
+ chalk: 4.1.2
+ commander: 7.2.0
+ transitivePeerDependencies:
+ - supports-color
+ - typescript
+
expo-modules-autolinking@55.0.9(typescript@5.9.3):
dependencies:
'@expo/require-utils': 55.0.2(typescript@5.9.3)
@@ -25697,6 +26162,18 @@ snapshots:
react: 19.2.0
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
+ expo-modules-core@55.0.15(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4):
+ dependencies:
+ invariant: 2.2.4
+ react: 19.2.4
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+
+ expo-modules-core@55.0.15(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4):
+ dependencies:
+ invariant: 2.2.4
+ react: 19.2.4
+ react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+
expo-notifications@55.0.12(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(typescript@5.9.3):
dependencies:
'@expo/image-utils': 0.8.12
@@ -25716,6 +26193,11 @@ snapshots:
expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@5.0.10)
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
+ expo-screen-orientation@55.0.8(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)):
+ dependencies:
+ expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+
expo-server@55.0.6: {}
expo-splash-screen@55.0.10(expo@55.0.6)(typescript@5.9.3):
@@ -25733,6 +26215,13 @@ snapshots:
react: 19.2.0
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
+ expo-sqlite@55.0.10(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4):
+ dependencies:
+ await-lock: 2.2.2
+ expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)
+ react: 19.2.4
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+
expo-structured-headers@55.0.0: {}
expo-system-ui@55.0.9(expo@55.0.6)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)):
@@ -25779,6 +26268,12 @@ snapshots:
react: 19.2.0
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
+ expo-video@55.0.10(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4):
+ dependencies:
+ expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)
+ react: 19.2.4
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+
expo-web-browser@15.0.10(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)):
dependencies:
expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@5.0.10)
@@ -25831,6 +26326,90 @@ snapshots:
- typescript
- utf-8-validate
+ expo@55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10):
+ dependencies:
+ '@babel/runtime': 7.28.6
+ '@expo/cli': 55.0.16(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(expo-constants@55.0.7(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(typescript@5.9.3))(expo-font@55.0.4(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(expo@55.0.6)(react-dom@19.2.0(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)
+ '@expo/config': 55.0.8(typescript@5.9.3)
+ '@expo/config-plugins': 55.0.6
+ '@expo/devtools': 55.0.2(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ '@expo/fingerprint': 0.16.6
+ '@expo/local-build-cache-provider': 55.0.6(typescript@5.9.3)
+ '@expo/log-box': 55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ '@expo/metro': 54.2.0(bufferutil@4.0.8)(utf-8-validate@5.0.10)
+ '@expo/metro-config': 55.0.9(bufferutil@4.0.8)(expo@55.0.6)(typescript@5.9.3)(utf-8-validate@5.0.10)
+ '@expo/vector-icons': 15.1.1(expo-font@55.0.4(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ '@ungap/structured-clone': 1.3.0
+ babel-preset-expo: 55.0.11(@babel/core@7.29.0)(@babel/runtime@7.28.6)(expo@55.0.6)(react-refresh@0.14.2)
+ expo-asset: 55.0.8(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)
+ expo-constants: 55.0.7(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(typescript@5.9.3)
+ expo-file-system: 55.0.10(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))
+ expo-font: 55.0.4(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ expo-keep-awake: 55.0.4(expo@55.0.6)(react@19.2.4)
+ expo-modules-autolinking: 55.0.9(typescript@5.9.3)
+ expo-modules-core: 55.0.15(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ pretty-format: 29.7.0
+ react: 19.2.4
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+ react-refresh: 0.14.2
+ whatwg-url-minimum: 0.1.1
+ optionalDependencies:
+ '@expo/dom-webview': 55.0.3(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ '@expo/metro-runtime': 55.0.6(@expo/dom-webview@55.0.3)(expo@55.0.6)(react-dom@19.2.0(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ react-native-webview: 13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ transitivePeerDependencies:
+ - '@babel/core'
+ - bufferutil
+ - expo-router
+ - expo-widgets
+ - react-dom
+ - react-server-dom-webpack
+ - supports-color
+ - typescript
+ - utf-8-validate
+
+ expo@55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.3.3)(utf-8-validate@5.0.10):
+ dependencies:
+ '@babel/runtime': 7.28.6
+ '@expo/cli': 55.0.16(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(expo-constants@55.0.7(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(typescript@5.3.3))(expo-font@55.0.4(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(expo@55.0.6)(react-dom@19.2.0(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.3.3)(utf-8-validate@5.0.10)
+ '@expo/config': 55.0.8(typescript@5.3.3)
+ '@expo/config-plugins': 55.0.6
+ '@expo/devtools': 55.0.2(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ '@expo/fingerprint': 0.16.6
+ '@expo/local-build-cache-provider': 55.0.6(typescript@5.3.3)
+ '@expo/log-box': 55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ '@expo/metro': 54.2.0(bufferutil@4.0.8)(utf-8-validate@5.0.10)
+ '@expo/metro-config': 55.0.9(bufferutil@4.0.8)(expo@55.0.6)(typescript@5.3.3)(utf-8-validate@5.0.10)
+ '@expo/vector-icons': 15.1.1(expo-font@55.0.4(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ '@ungap/structured-clone': 1.3.0
+ babel-preset-expo: 55.0.11(@babel/core@7.29.0)(@babel/runtime@7.28.6)(expo@55.0.6)(react-refresh@0.14.2)
+ expo-asset: 55.0.8(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.3.3)
+ expo-constants: 55.0.7(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(typescript@5.3.3)
+ expo-file-system: 55.0.10(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))
+ expo-font: 55.0.4(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ expo-keep-awake: 55.0.4(expo@55.0.6)(react@19.2.4)
+ expo-modules-autolinking: 55.0.9(typescript@5.3.3)
+ expo-modules-core: 55.0.15(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ pretty-format: 29.7.0
+ react: 19.2.4
+ react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+ react-refresh: 0.14.2
+ whatwg-url-minimum: 0.1.1
+ optionalDependencies:
+ '@expo/dom-webview': 55.0.3(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ '@expo/metro-runtime': 55.0.6(@expo/dom-webview@55.0.3)(expo@55.0.6)(react-dom@19.2.0(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ react-native-webview: 13.16.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ transitivePeerDependencies:
+ - '@babel/core'
+ - bufferutil
+ - expo-router
+ - expo-widgets
+ - react-dom
+ - react-server-dom-webpack
+ - supports-color
+ - typescript
+ - utf-8-validate
+
exponential-backoff@3.1.1: {}
exponential-backoff@3.1.3: {}
@@ -27259,7 +27838,7 @@ snapshots:
dependencies:
'@babel/runtime': 7.28.6
- i18next-cli@1.49.7(@swc/helpers@0.5.17)(@types/node@25.5.0)(i18next@25.8.18(typescript@5.9.3))(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(typescript@5.9.3):
+ i18next-cli@1.49.7(@swc/helpers@0.5.17)(@types/node@25.5.0)(i18next@25.8.18(typescript@5.9.3))(react-dom@19.2.0(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(typescript@5.9.3):
dependencies:
'@croct/json5-parser': 0.2.2
'@swc/core': 1.15.18(@swc/helpers@0.5.17)
@@ -27275,7 +27854,7 @@ snapshots:
minimatch: 10.2.4
ora: 9.3.0
react: 19.2.4
- react-i18next: 16.5.8(i18next@25.8.18(typescript@5.9.3))(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)
+ react-i18next: 16.5.8(i18next@25.8.18(typescript@5.9.3))(react-dom@19.2.0(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)
yaml: 2.8.2
transitivePeerDependencies:
- '@swc/helpers'
@@ -28544,6 +29123,12 @@ snapshots:
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
react-native-svg: 15.15.3(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
+ lucide-react-native@0.562.0(react-native-svg@15.15.3(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4):
+ dependencies:
+ react: 19.2.4
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+ react-native-svg: 15.15.3(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+
macos-alias@0.2.11:
dependencies:
nan: 2.20.0
@@ -31078,6 +31663,11 @@ snapshots:
react: 19.2.0
scheduler: 0.27.0
+ react-dom@19.2.0(react@19.2.4):
+ dependencies:
+ react: 19.2.4
+ scheduler: 0.27.0
+
react-freeze@1.0.4(react@19.2.0):
dependencies:
react: 19.2.0
@@ -31093,7 +31683,18 @@ snapshots:
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
typescript: 5.9.3
- react-i18next@16.5.8(i18next@25.8.18(typescript@5.9.3))(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3):
+ react-i18next@15.7.4(i18next@25.8.18(typescript@5.9.3))(react-dom@19.2.0(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3):
+ dependencies:
+ '@babel/runtime': 7.28.6
+ html-parse-stringify: 3.0.1
+ i18next: 25.8.18(typescript@5.9.3)
+ react: 19.2.4
+ optionalDependencies:
+ react-dom: 19.2.0(react@19.2.4)
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+ typescript: 5.9.3
+
+ react-i18next@16.5.8(i18next@25.8.18(typescript@5.9.3))(react-dom@19.2.0(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3):
dependencies:
'@babel/runtime': 7.28.6
html-parse-stringify: 3.0.1
@@ -31101,8 +31702,8 @@ snapshots:
react: 19.2.4
use-sync-external-store: 1.6.0(react@19.2.4)
optionalDependencies:
- react-dom: 19.2.0(react@19.2.0)
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
+ react-dom: 19.2.0(react@19.2.4)
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
typescript: 5.9.3
react-is@16.13.1: {}
@@ -31125,6 +31726,11 @@ snapshots:
react: 19.2.0
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
+ react-native-edge-to-edge@1.8.1(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4):
+ dependencies:
+ react: 19.2.4
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+
react-native-fit-image@1.5.5:
dependencies:
prop-types: 15.8.1
@@ -31137,6 +31743,14 @@ snapshots:
react: 19.2.0
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
+ react-native-gesture-handler@2.30.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4):
+ dependencies:
+ '@egjs/hammerjs': 2.0.17
+ hoist-non-react-statics: 3.3.2
+ invariant: 2.2.4
+ react: 19.2.4
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+
react-native-haptic-feedback@2.3.3(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)):
dependencies:
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
@@ -31147,6 +31761,11 @@ snapshots:
react: 19.2.0
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
+ react-native-is-edge-to-edge@1.2.1(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4):
+ dependencies:
+ react: 19.2.4
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+
react-native-localize@3.7.0(@expo/config-plugins@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0):
dependencies:
react: 19.2.0
@@ -31176,11 +31795,24 @@ snapshots:
react-native-worklets: 0.7.4(@babel/core@7.29.0)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
semver: 7.7.3
+ react-native-reanimated@4.2.2(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4):
+ dependencies:
+ react: 19.2.4
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+ react-native-is-edge-to-edge: 1.2.1(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ react-native-worklets: 0.7.4(@babel/core@7.29.0)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ semver: 7.7.3
+
react-native-safe-area-context@5.7.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0):
dependencies:
react: 19.2.0
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
+ react-native-safe-area-context@5.7.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4):
+ dependencies:
+ react: 19.2.4
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+
react-native-screens@4.24.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0):
dependencies:
react: 19.2.0
@@ -31205,6 +31837,14 @@ snapshots:
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
warn-once: 0.1.1
+ react-native-svg@15.15.3(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4):
+ dependencies:
+ css-select: 5.1.0
+ css-tree: 1.1.3
+ react: 19.2.4
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+ warn-once: 0.1.1
+
react-native-url-polyfill@3.0.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)):
dependencies:
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
@@ -31225,6 +31865,22 @@ snapshots:
transitivePeerDependencies:
- encoding
+ react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4):
+ dependencies:
+ '@babel/runtime': 7.28.6
+ '@react-native/normalize-colors': 0.74.85
+ fbjs: 3.0.5(encoding@0.1.13)
+ inline-style-prefixer: 7.0.1
+ memoize-one: 6.0.0
+ nullthrows: 1.1.1
+ postcss-value-parser: 4.2.0
+ react: 19.2.4
+ react-dom: 19.2.0(react@19.2.4)
+ styleq: 0.1.3
+ transitivePeerDependencies:
+ - encoding
+ optional: true
+
react-native-webrtc@https://codeload.github.com/streamplace/react-native-webrtc/tar.gz/74fa32266e3a2fee180f5e01bb8753af2a92d9d3(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)):
dependencies:
base64-js: 1.5.1
@@ -31234,6 +31890,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ react-native-webrtc@https://codeload.github.com/streamplace/react-native-webrtc/tar.gz/74fa32266e3a2fee180f5e01bb8753af2a92d9d3(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)):
+ dependencies:
+ base64-js: 1.5.1
+ debug: 4.3.4
+ event-target-shim: 6.0.2
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+ transitivePeerDependencies:
+ - supports-color
+
react-native-webrtc@https://codeload.github.com/streamplace/react-native-webrtc/tar.gz/74fa32266e3a2fee180f5e01bb8753af2a92d9d3(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)):
dependencies:
base64-js: 1.5.1
@@ -31250,6 +31915,22 @@ snapshots:
react: 19.2.0
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
+ react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4):
+ dependencies:
+ escape-string-regexp: 4.0.0
+ invariant: 2.2.4
+ react: 19.2.4
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+ optional: true
+
+ react-native-webview@13.16.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4):
+ dependencies:
+ escape-string-regexp: 4.0.0
+ invariant: 2.2.4
+ react: 19.2.4
+ react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+ optional: true
+
react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0):
dependencies:
'@babel/core': 7.29.0
@@ -31269,6 +31950,25 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4):
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0)
+ '@babel/plugin-transform-class-properties': 7.27.1(@babel/core@7.29.0)
+ '@babel/plugin-transform-classes': 7.28.4(@babel/core@7.29.0)
+ '@babel/plugin-transform-nullish-coalescing-operator': 7.27.1(@babel/core@7.29.0)
+ '@babel/plugin-transform-optional-chaining': 7.27.1(@babel/core@7.29.0)
+ '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.0)
+ '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.29.0)
+ '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.0)
+ '@babel/preset-typescript': 7.27.1(@babel/core@7.29.0)
+ convert-source-map: 2.0.0
+ react: 19.2.4
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+ semver: 7.7.3
+ transitivePeerDependencies:
+ - supports-color
+
react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10):
dependencies:
'@jest/create-cache-key-function': 29.7.0
@@ -31317,6 +32017,54 @@ snapshots:
- supports-color
- utf-8-validate
+ react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10):
+ dependencies:
+ '@jest/create-cache-key-function': 29.7.0
+ '@react-native/assets-registry': 0.83.2
+ '@react-native/codegen': 0.83.2(@babel/core@7.29.0)
+ '@react-native/community-cli-plugin': 0.83.2(bufferutil@4.0.8)(utf-8-validate@5.0.10)
+ '@react-native/gradle-plugin': 0.83.2
+ '@react-native/js-polyfills': 0.83.2
+ '@react-native/normalize-colors': 0.83.2
+ '@react-native/virtualized-lists': 0.83.2(@types/react@19.2.14)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ abort-controller: 3.0.0
+ anser: 1.4.10
+ ansi-regex: 5.0.1
+ babel-jest: 29.7.0(@babel/core@7.29.0)
+ babel-plugin-syntax-hermes-parser: 0.32.0
+ base64-js: 1.5.1
+ commander: 12.1.0
+ flow-enums-runtime: 0.0.6
+ glob: 7.2.3
+ hermes-compiler: 0.14.1
+ invariant: 2.2.4
+ jest-environment-node: 29.7.0
+ memoize-one: 5.2.1
+ metro-runtime: 0.83.5
+ metro-source-map: 0.83.5
+ nullthrows: 1.1.1
+ pretty-format: 29.7.0
+ promise: 8.3.0
+ react: 19.2.4
+ react-devtools-core: 6.1.5(bufferutil@4.0.8)(utf-8-validate@5.0.10)
+ react-refresh: 0.14.2
+ regenerator-runtime: 0.13.11
+ scheduler: 0.27.0
+ semver: 7.7.1
+ stacktrace-parser: 0.1.10
+ whatwg-fetch: 3.6.20
+ ws: 7.5.10(bufferutil@4.0.8)(utf-8-validate@5.0.10)
+ yargs: 17.7.2
+ optionalDependencies:
+ '@types/react': 19.2.14
+ transitivePeerDependencies:
+ - '@babel/core'
+ - '@react-native-community/cli'
+ - '@react-native/metro-config'
+ - bufferutil
+ - supports-color
+ - utf-8-validate
+
react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10):
dependencies:
'@jest/create-cache-key-function': 29.7.0
@@ -31369,29 +32117,29 @@ snapshots:
react-refresh@0.4.3: {}
- react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.0):
+ react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4):
dependencies:
- react: 19.2.0
- react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.0)
+ react: 19.2.4
+ react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4)
tslib: 2.8.1
optionalDependencies:
'@types/react': 19.2.14
- react-remove-scroll@2.7.1(@types/react@19.2.14)(react@19.2.0):
+ react-remove-scroll@2.7.1(@types/react@19.2.14)(react@19.2.4):
dependencies:
- react: 19.2.0
- react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.0)
- react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.0)
+ react: 19.2.4
+ react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.4)
+ react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4)
tslib: 2.8.1
- use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.0)
- use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.0)
+ use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.4)
+ use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
- react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.0):
+ react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.4):
dependencies:
get-nonce: 1.0.1
- react: 19.2.0
+ react: 19.2.4
tslib: 2.8.1
optionalDependencies:
'@types/react': 19.2.14
@@ -33517,9 +34265,9 @@ snapshots:
url-template@3.1.1: {}
- use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.0):
+ use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.4):
dependencies:
- react: 19.2.0
+ react: 19.2.4
tslib: 2.8.1
optionalDependencies:
'@types/react': 19.2.14
@@ -33528,10 +34276,10 @@ snapshots:
dependencies:
react: 19.2.0
- use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.0):
+ use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.4):
dependencies:
detect-node-es: 1.1.0
- react: 19.2.0
+ react: 19.2.4
tslib: 2.8.1
optionalDependencies:
'@types/react': 19.2.14
@@ -34227,4 +34975,11 @@ snapshots:
react: 19.2.0
use-sync-external-store: 1.6.0(react@19.2.0)
+ zustand@5.0.11(@types/react@19.2.14)(immer@10.1.1)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)):
+ optionalDependencies:
+ '@types/react': 19.2.14
+ immer: 10.1.1
+ react: 19.2.4
+ use-sync-external-store: 1.6.0(react@19.2.4)
+
zwitch@2.0.4: {}
--
2.51.2
From c6476962dc4709f719ca96f98e8f249e5298f0e3 Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Tue, 26 May 2026 18:58:55 -0700
Subject: [PATCH 08/47] vod: wire up a lot of player stuff
---
js/app/components/mobile/bottom-metadata.tsx | 9 +-
.../mobile/desktop-ui/top-controls.tsx | 12 +-
js/app/components/mobile/ui.tsx | 17 +--
.../mobile-player/ui/viewer-context-menu.tsx | 16 +--
.../components/mobile-player/ui/viewers.tsx | 25 ++++-
js/components/src/hooks/index.ts | 3 +
js/components/src/hooks/useAuthor.ts | 19 ++++
js/components/src/hooks/useAvatar.ts | 18 +++
js/components/src/hooks/useViews.ts | 16 +++
pkg/api/api.go | 43 ++++++++
pkg/linking/linking.go | 104 ++++++++++++++++++
pkg/linking/linking_test.go | 42 +++++++
pkg/spxrpc/place_stream_media_getvideo.go | 6 +-
pkg/spxrpc/place_stream_playback_getvideo.go | 30 +----
pkg/spxrpc/resolution_helpers.go | 37 +++++++
15 files changed, 330 insertions(+), 67 deletions(-)
create mode 100644 js/components/src/hooks/useAuthor.ts
create mode 100644 js/components/src/hooks/useAvatar.ts
create mode 100644 js/components/src/hooks/useViews.ts
create mode 100644 pkg/spxrpc/resolution_helpers.go
diff --git a/js/app/components/mobile/bottom-metadata.tsx b/js/app/components/mobile/bottom-metadata.tsx
index 2ca2699ab..6f1d4e76e 100644
--- a/js/app/components/mobile/bottom-metadata.tsx
+++ b/js/app/components/mobile/bottom-metadata.tsx
@@ -10,9 +10,9 @@ import {
PlayerUI,
ShareSheet,
Text,
- useAvatars,
+ useAuthor,
+ useAvatar,
useDID,
- useLivestreamInfo,
useLivestreamStore,
usePlayerStore,
useTheme,
@@ -84,8 +84,7 @@ export function BottomMetadata({
setShowChat: (show: boolean) => void;
showChat: boolean;
}) {
- const { profile } = useLivestreamInfo();
- const avatars = useAvatars(profile?.did ? [profile?.did] : []);
+ const profile = useAuthor();
const ls = useLivestreamStore((x) => x.livestream);
const segment = useLivestreamStore((x) => x.segment);
const mode = usePlayerStore((x) => x.mode);
@@ -96,7 +95,7 @@ export function BottomMetadata({
const contentRights = segment?.contentRights;
const { theme } = useTheme();
- const avatarUri = profile?.did ? avatars[profile.did]?.avatar : undefined;
+ const avatarUri = useAvatar();
const activity = ls?.record.activity as
| ((ActivityGame | ActivityLabel) & { $type?: string })
| undefined;
diff --git a/js/app/components/mobile/desktop-ui/top-controls.tsx b/js/app/components/mobile/desktop-ui/top-controls.tsx
index d96b49ce6..f5ff6659a 100644
--- a/js/app/components/mobile/desktop-ui/top-controls.tsx
+++ b/js/app/components/mobile/desktop-ui/top-controls.tsx
@@ -4,9 +4,9 @@ import {
PlayerUI,
Text,
View,
- useAvatars,
+ useAuthor,
+ useAvatar,
useCameraToggle,
- useLivestreamInfo,
useLivestreamStore,
zero,
} from "@streamplace/components";
@@ -41,9 +41,9 @@ export function TopControlBar({
embedded = false,
}: TopControlBarProps) {
const navigation = useNavigation();
- const { profile } = useLivestreamInfo();
+ const profile = useAuthor();
const { doSetIngestCamera } = useCameraToggle();
- const avatars = useAvatars(profile?.did && embedded ? [profile?.did] : []);
+ const avatar = useAvatar();
const { width } = useWindowDimensions();
const isTinyScreen = width < 450;
const isSmallScreen = width < 600;
@@ -87,8 +87,8 @@ export function TopControlBar({
>
state.mode);
const muteWasForced = usePlayerStore((state) => state.muteWasForced);
@@ -196,8 +195,6 @@ export function MobileUi({
>
void;
muteWasForced: boolean;
setMuteWasForced: (forced: boolean) => void;
}) {
+ const profile = useAuthor();
+ const avatar = useAvatar();
// Get content warnings from segment
const segment = useLivestreamStore((x) => x.segment);
const contentWarnings =
@@ -361,9 +356,7 @@ function LeftControlsPanel({
0 ? fps : ""})`
: "(Original Quality)";
- const { profile } = useLivestreamInfo();
+ const profile = useAuthor();
- const avatars = useAvatars(profile?.did ? [profile?.did] : []);
- const ls = useLivestreamStore((x) => x.livestream);
+ const avatar = useAvatar();
const segment = useLivestreamStore((x) => x.segment);
const [isOpen, setIsOpen] = useState(false);
@@ -159,11 +159,11 @@ export function ContextMenu({
{ flex: 1, minWidth: 0 },
]}
>
- {profile?.did && avatars[profile?.did]?.avatar && (
+ {avatar && (
- {ls?.record.title || "Stream Title"}
+ {useTitle()}
diff --git a/js/components/src/components/mobile-player/ui/viewers.tsx b/js/components/src/components/mobile-player/ui/viewers.tsx
index aea7b81de..28f129f9b 100644
--- a/js/components/src/components/mobile-player/ui/viewers.tsx
+++ b/js/components/src/components/mobile-player/ui/viewers.tsx
@@ -1,15 +1,28 @@
import { Eye } from "lucide-react-native";
+import { useViews } from "../../..";
import * as atoms from "../../../lib/theme/atoms";
-import { useViewers } from "../../../livestream-store";
+import { usePlayerStore } from "../../../player-store";
import { View } from "../../ui";
import ViewerCount from "./viewer-count";
+// red reads as "live"; anything else (VOD total views) shouldn't
+const LIVE_COLOR = "#fd5050";
+const VIEWS_COLOR = "#fff";
+
export function Viewers() {
- const viewers = useViewers();
- return ;
+ const views = useViews();
+ const mode = usePlayerStore((x) => x.mode);
+ const color = mode === "live" ? LIVE_COLOR : VIEWS_COLOR;
+ return ;
}
-export function DehydratedViewers({ viewers }: { viewers: number }) {
+export function DehydratedViewers({
+ viewers,
+ color = LIVE_COLOR,
+}: {
+ viewers: number;
+ color?: string;
+}) {
return (
-
-
+
+
);
}
diff --git a/js/components/src/hooks/index.ts b/js/components/src/hooks/index.ts
index b436308ad..0b54ae01f 100644
--- a/js/components/src/hooks/index.ts
+++ b/js/components/src/hooks/index.ts
@@ -1,5 +1,7 @@
// barrel file :)
export * from "./useAQState";
+export * from "./useAuthor";
+export * from "./useAvatar";
export * from "./useAvatars";
export * from "./useCameraToggle";
export * from "./useDocumentTitle";
@@ -12,3 +14,4 @@ export * from "./usePointerDevice";
export * from "./useSegmentDimensions";
export * from "./useSegmentTiming";
export * from "./useTitle";
+export * from "./useViews";
diff --git a/js/components/src/hooks/useAuthor.ts b/js/components/src/hooks/useAuthor.ts
new file mode 100644
index 000000000..c5b96df91
--- /dev/null
+++ b/js/components/src/hooks/useAuthor.ts
@@ -0,0 +1,19 @@
+import { AppBskyActorDefs } from "@atproto/api";
+import { useLivestreamStoreOptional } from "../livestream-store";
+import { usePlayerStore } from "../player-store";
+import { useVideoStoreOptional } from "../video-store";
+
+// Surfaces the profile of whoever created what the player is currently
+// showing — the streamer in live mode, the uploader in vod mode — switching
+// on the player's mode. Like useTitle, this keeps player UI generic:
+// `const author = useAuthor()` works the same in live and vod contexts.
+//
+// Reads from the *optional* store hooks so it's safe to call even when only
+// one of the providers (LivestreamProvider / VideoProvider) is mounted.
+// Returns null until the relevant metadata has loaded.
+export function useAuthor(): AppBskyActorDefs.ProfileViewBasic | null {
+ const mode = usePlayerStore((x) => x.mode);
+ const liveProfile = useLivestreamStoreOptional((x) => x.profile);
+ const vodAuthor = useVideoStoreOptional((x) => x.video?.author ?? null);
+ return mode === "vod" ? vodAuthor : liveProfile;
+}
diff --git a/js/components/src/hooks/useAvatar.ts b/js/components/src/hooks/useAvatar.ts
new file mode 100644
index 000000000..daa6cb88b
--- /dev/null
+++ b/js/components/src/hooks/useAvatar.ts
@@ -0,0 +1,18 @@
+import { useMemo } from "react";
+import { useAuthor } from "./useAuthor";
+import { useAvatars } from "./useAvatars";
+
+// Surfaces the author's avatar URL for whatever the player is currently
+// showing, switching on the player's mode via useAuthor. In both modes we
+// prefer the hydrated ProfileViewDetailed avatar (fetched through the profile
+// cache) and fall back to whatever the basic profile/author view carried.
+// Returns undefined when no avatar is known yet.
+export function useAvatar(): string | undefined {
+ const author = useAuthor();
+ const did = author?.did;
+ // memoized so useAvatars' effect doesn't refire on every render
+ const dids = useMemo(() => (did ? [did] : []), [did]);
+ const detailed = useAvatars(dids);
+ if (!did) return undefined;
+ return detailed[did]?.avatar ?? author?.avatar;
+}
diff --git a/js/components/src/hooks/useViews.ts b/js/components/src/hooks/useViews.ts
new file mode 100644
index 000000000..d6d22d086
--- /dev/null
+++ b/js/components/src/hooks/useViews.ts
@@ -0,0 +1,16 @@
+import { useLivestreamStoreOptional } from "../livestream-store";
+import { usePlayerStore } from "../player-store";
+import { useVideoStoreOptional } from "../video-store";
+
+// Surfaces the view count for whatever the player is currently showing,
+// switching on the player's mode: the live concurrent viewer count in live
+// mode, or the VOD's total accumulated view count (from VideoView.viewCounts)
+// in vod mode. Returns null when the count isn't known yet.
+export function useViews(): number | null {
+ const mode = usePlayerStore((x) => x.mode);
+ const liveViewers = useLivestreamStoreOptional((x) => x.viewers);
+ const vodViews = useVideoStoreOptional(
+ (x) => x.video?.viewCounts?.count ?? null,
+ );
+ return mode === "vod" ? vodViews : liveViewers;
+}
diff --git a/pkg/api/api.go b/pkg/api/api.go
index 80ce35982..bd43ab1ce 100644
--- a/pkg/api/api.go
+++ b/pkg/api/api.go
@@ -387,6 +387,18 @@ func (a *StreamplaceAPI) NotFoundLinkingHandler(ctx context.Context, linker *lin
}
req.URL.Host = req.Host
req.URL.Scheme = proto
+
+ // VOD link cards live at //video/. Everything else with a
+ // slash in it falls through to static-file / default-card handling.
+ parts := strings.Split(strings.Trim(req.URL.Path, "/"), "/")
+ if len(parts) == 3 && parts[1] == "video" {
+ if a.writeVideoCard(ctx, w, req, linker, parts[0], parts[2]) {
+ return
+ }
+ defaultHandler.ServeHTTP(w, req)
+ return
+ }
+
maybeHandle := strings.TrimPrefix(req.URL.Path, "/")
// quick check for things that aren't valid handles/dids
if strings.ContainsAny(maybeHandle, "/_") {
@@ -424,6 +436,37 @@ func (a *StreamplaceAPI) NotFoundLinkingHandler(ctx context.Context, linker *lin
}), nil
}
+// writeVideoCard renders an OpenGraph link card for a VOD at
+// //video/, pulling the video out of the local index. It returns
+// true when it wrote a response, and false (having written nothing) when the
+// video can't be found or rendered, so the caller can fall back to
+// static-file / default-card handling.
+func (a *StreamplaceAPI) writeVideoCard(ctx context.Context, w http.ResponseWriter, req *http.Request, linker *linking.Linker, user, tid string) bool {
+ repo, err := a.Model.GetRepoByHandleOrDID(user)
+ if err != nil || repo == nil {
+ return false
+ }
+ uri := fmt.Sprintf("at://%s/place.stream.video/%s", repo.DID, tid)
+ vv, err := a.Model.GetVideoView(ctx, uri)
+ if err != nil {
+ log.Error(ctx, "error fetching video view for card", "uri", uri, "error", err)
+ return false
+ }
+ if vv == nil {
+ return false
+ }
+ bs, err := linker.GenerateVideoCard(ctx, req.URL, vv, a.CLI.SentryDSN)
+ if err != nil {
+ log.Error(ctx, "error generating video card", "uri", uri, "error", err)
+ return false
+ }
+ w.Header().Set("Content-Type", "text/html")
+ if _, err := w.Write(bs); err != nil {
+ log.Error(ctx, "error writing response", "error", err)
+ }
+ return true
+}
+
func (a *StreamplaceAPI) MistProxyHandler(ctx context.Context, tmpl string) httprouter.Handle {
return func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
if !a.CLI.HasMist() {
diff --git a/pkg/linking/linking.go b/pkg/linking/linking.go
index b89dd9f66..748998383 100644
--- a/pkg/linking/linking.go
+++ b/pkg/linking/linking.go
@@ -176,6 +176,110 @@ func (l *Linker) GenerateStreamerCard(ctx context.Context, u *url.URL, lsv *stre
})
}
+func (l *Linker) GenerateVideoCard(ctx context.Context, u *url.URL, vv *streamplace.MediaGetVideo_VideoView, sentryDSN string) ([]byte, error) {
+ if u == nil {
+ return nil, errors.New("url is nil")
+ }
+ if vv == nil {
+ return nil, errors.New("video view is nil")
+ }
+ video, ok := vv.Record.Val.(*streamplace.Video)
+ if !ok {
+ return nil, errors.New("video view record is not a video")
+ }
+
+ outURL := u.String()
+
+ authorDid := ""
+ authorHandle := ""
+ if vv.Author != nil {
+ authorDid = vv.Author.Did
+ authorHandle = vv.Author.Handle
+ }
+
+ // og:image is the VOD's own thumbnail, served through the Bluesky image
+ // CDN (which fetches the blob from the author's PDS — the same trick used
+ // for game cover art). When the video has no thumbnail we fall back to the
+ // author's generated profile card so the link still carries an image.
+ var imageURL string
+ if video.Thumb != nil && authorDid != "" {
+ imageURL = fmt.Sprintf(
+ "https://cdn.bsky.app/img/feed_thumbnail/plain/%s/%s@jpeg",
+ authorDid, video.Thumb.Ref.String(),
+ )
+ } else {
+ cardURL, _ := url.Parse(u.String())
+ cardURL.Path = "/xrpc/place.stream.live.getProfileCard"
+ cardURL.RawQuery = fmt.Sprintf("id=%s", authorDid)
+ imageURL = cardURL.String()
+ }
+
+ titleStr := fmt.Sprintf("@%s's video on ", authorHandle)
+
+ // Define all meta tags
+ metaTags := []MetaTag{
+ // Basic meta
+ {Type: "name", Key: "description", Content: video.Title},
+
+ // Facebook Meta Tags
+ {Type: "property", Key: "og:url", Content: outURL},
+ {Type: "property", Key: "og:type", Content: "video.other"},
+ {Type: "property", Key: "og:description", Content: video.Title},
+ {Type: "property", Key: "og:image", Content: imageURL},
+
+ // Twitter Meta Tags
+ {Type: "name", Key: "twitter:card", Content: "summary_large_image"},
+ {Type: "property", Key: "twitter:domain", Content: u.Host},
+ {Type: "property", Key: "twitter:url", Content: outURL},
+ {Type: "name", Key: "twitter:description", Content: video.Title},
+ {Type: "name", Key: "twitter:image", Content: imageURL},
+ }
+
+ brandingTitle := "streamplace node"
+ if l.sdb != nil && l.cli != nil {
+ branding, err := l.getBrandingAssets("did:web:" + l.cli.BroadcasterHost)
+ if err == nil {
+ for i := range branding {
+ val := branding[i]
+ if val.Key == "siteTitle" && val.Data != nil {
+ brandingTitle = *val.Data
+ }
+ marshalledJson, err := json.Marshal(val)
+ if err != nil {
+ fmt.Printf("error marshalling branding asset %s: %v\n", val.Key, err)
+ continue
+ }
+ metaTags = append(metaTags, MetaTag{
+ Type: "name",
+ Key: "internal-brand:" + val.Key,
+ Content: string(marshalledJson),
+ })
+ }
+ } else {
+ // log but we should not block rendering
+ fmt.Printf("error fetching branding assets: %v\n", err)
+ }
+ }
+
+ // do twitter/og title after
+ metaTags = append(metaTags, MetaTag{
+ Type: "property",
+ Key: "og:title",
+ Content: fmt.Sprintf("%s%s", titleStr, brandingTitle),
+ })
+ metaTags = append(metaTags, MetaTag{
+ Type: "name",
+ Key: "twitter:title",
+ Content: fmt.Sprintf("%s%s", titleStr, brandingTitle),
+ })
+
+ return l.GenerateHTML(ctx, &PageConfig{
+ Title: fmt.Sprintf("%s%s", titleStr, brandingTitle),
+ Metas: metaTags,
+ SentryDSN: sentryDSN,
+ })
+}
+
func (l *Linker) GenerateDefaultCard(ctx context.Context, u *url.URL, sentryDSN string) ([]byte, error) {
if u == nil {
return nil, errors.New("url is nil")
diff --git a/pkg/linking/linking_test.go b/pkg/linking/linking_test.go
index d442f7920..e6b23de5e 100644
--- a/pkg/linking/linking_test.go
+++ b/pkg/linking/linking_test.go
@@ -10,6 +10,7 @@ import (
"github.com/bluesky-social/indigo/api/atproto"
"github.com/bluesky-social/indigo/api/bsky"
lexutil "github.com/bluesky-social/indigo/lex/util"
+ "github.com/ipfs/go-cid"
"github.com/stretchr/testify/require"
"stream.place/streamplace/js/app"
"stream.place/streamplace/pkg/streamplace"
@@ -69,3 +70,44 @@ func TestGenerateLinkCard(t *testing.T) {
require.True(t, strings.Contains(linkStr, ls.Title), "should contain the livestream title")
require.True(t, strings.Count(linkStr, "") == 1, "should have exactly one title tag")
}
+
+func TestGenerateVideoCard(t *testing.T) {
+ index := IndexHTML(t)
+ linker, err := NewLinker(context.Background(), index, nil, nil)
+ require.NoError(t, err)
+ require.NotNil(t, linker)
+
+ u, err := url.Parse("https://stream.place/iame.li/video/3mms3tfkcxstu")
+ require.NoError(t, err)
+
+ thumbCID := "bafyreib2ohz45jileumnuwa3wdoo3o7caikfyq467eanleqcscouh5wery"
+ c, err := cid.Decode(thumbCID)
+ require.NoError(t, err)
+
+ video := &streamplace.Video{
+ Title: "My excellent VOD",
+ Thumb: &lexutil.LexBlob{
+ Ref: lexutil.LexLink(c),
+ MimeType: "image/jpeg",
+ },
+ }
+ vv := &streamplace.MediaGetVideo_VideoView{
+ Author: &bsky.ActorDefs_ProfileViewBasic{
+ Handle: "iame.li",
+ Did: "did:plc:2zmxikig2sj7gqaezl5gntae",
+ },
+ Cid: "bafyreib2ohz45jileumnuwa3wdoo3o7caikfyq467eanleqcscouh5wery",
+ Record: &lexutil.LexiconTypeDecoder{Val: video},
+ Uri: "at://did:plc:2zmxikig2sj7gqaezl5gntae/place.stream.video/3mms3tfkcxstu",
+ }
+
+ linkCard, err := linker.GenerateVideoCard(context.Background(), u, vv, "")
+ require.NoError(t, err)
+ linkStr := string(linkCard)
+ require.True(t, strings.Contains(linkStr, "iame.li"), "should contain the author handle")
+ require.True(t, strings.Contains(linkStr, video.Title), "should contain the video title")
+ require.True(t, strings.Contains(linkStr,
+ "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:2zmxikig2sj7gqaezl5gntae/"+thumbCID+"@jpeg"),
+ "og:image should be the video thumbnail served via the bsky CDN")
+ require.True(t, strings.Count(linkStr, "") == 1, "should have exactly one title tag")
+}
diff --git a/pkg/spxrpc/place_stream_media_getvideo.go b/pkg/spxrpc/place_stream_media_getvideo.go
index ea7eaf2e7..0dd430388 100644
--- a/pkg/spxrpc/place_stream_media_getvideo.go
+++ b/pkg/spxrpc/place_stream_media_getvideo.go
@@ -4,7 +4,6 @@ import (
"context"
"net/http"
- "github.com/bluesky-social/indigo/atproto/syntax"
"github.com/labstack/echo/v4"
placestream "stream.place/streamplace/pkg/streamplace"
@@ -20,10 +19,11 @@ func (s *Server) handlePlaceStreamMediaGetVideo(ctx context.Context, uri string)
if uri == "" {
return nil, echo.NewHTTPError(http.StatusBadRequest, "uri is required")
}
- if _, err := syntax.ParseATURI(uri); err != nil {
+ aturi, err := s.normalizeURI(ctx, uri)
+ if err != nil {
return nil, echo.NewHTTPError(http.StatusBadRequest, "uri must be a valid AT-URI: "+err.Error())
}
- view, err := s.model.GetVideoView(ctx, uri)
+ view, err := s.model.GetVideoView(ctx, aturi.String())
if err != nil {
return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
diff --git a/pkg/spxrpc/place_stream_playback_getvideo.go b/pkg/spxrpc/place_stream_playback_getvideo.go
index 881abd1fc..8a20789bd 100644
--- a/pkg/spxrpc/place_stream_playback_getvideo.go
+++ b/pkg/spxrpc/place_stream_playback_getvideo.go
@@ -265,7 +265,7 @@ func (s *Server) HandleGetVideoPlaylist(c echo.Context) error {
if rawURI == "" {
return echo.NewHTTPError(http.StatusBadRequest, "uri is required")
}
- aturi, err := syntax.ParseATURI(rawURI)
+ aturi, err := s.normalizeURI(ctx, rawURI)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "uri must be a valid AT-URI: "+err.Error())
}
@@ -273,30 +273,6 @@ func (s *Server) HandleGetVideoPlaylist(c echo.Context) error {
return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("UnsupportedCollection: %s", aturi.Collection()))
}
- var ownerDID syntax.DID
- if aturi.Authority().IsHandle() {
- handle, err := aturi.Authority().AsHandle()
- if err != nil {
- return echo.NewHTTPError(http.StatusBadRequest, "failed to convert handle: "+err.Error())
- }
- res, err := s.resolveStreamer(ctx, handle.String())
- if err != nil {
- return echo.NewHTTPError(http.StatusBadRequest, "failed to resolve streamer: "+err.Error())
- }
- ownerDID, err = syntax.ParseDID(res)
- if err != nil {
- return echo.NewHTTPError(http.StatusBadRequest, "resolved streamer must be a valid DID: "+err.Error())
- }
- aturi, err = syntax.ParseATURI(fmt.Sprintf("at://%s/%s", ownerDID.String(), aturi.Path()))
- if err != nil {
- return echo.NewHTTPError(http.StatusBadRequest, "failed to parse AT-URI: "+err.Error())
- }
- } else {
- ownerDID, err = aturi.Authority().AsDID()
- if err != nil {
- return echo.NewHTTPError(http.StatusBadRequest, "uri authority must be a DID: "+err.Error())
- }
- }
if s.playbackStore == nil {
return echo.NewHTTPError(http.StatusServiceUnavailable, "playback store not configured")
}
@@ -334,7 +310,7 @@ func (s *Server) HandleGetVideoPlaylist(c echo.Context) error {
} else if labeled {
return echo.NewHTTPError(http.StatusForbidden, "VideoUnavailable")
}
- if banned, err := s.accountBanned(ownerDID.String()); err != nil {
+ if banned, err := s.accountBanned(aturi.Authority().String()); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
} else if banned {
return echo.NewHTTPError(http.StatusForbidden, "VideoUnavailable")
@@ -365,7 +341,7 @@ func (s *Server) HandleGetVideoPlaylist(c echo.Context) error {
// server side, so the propagation stays in clip-local time.
body = masterPlaylist(meta, uri, sid, startMS, endMS)
} else {
- body, err = mediaPlaylist(meta, track, ownerDID.String(), sid, s.cli.VODCDNURL, effectiveStartMS, effectiveEndMS)
+ body, err = mediaPlaylist(meta, track, aturi.Authority().String(), sid, s.cli.VODCDNURL, effectiveStartMS, effectiveEndMS)
if err != nil {
return err
}
diff --git a/pkg/spxrpc/resolution_helpers.go b/pkg/spxrpc/resolution_helpers.go
new file mode 100644
index 000000000..c52cc6b88
--- /dev/null
+++ b/pkg/spxrpc/resolution_helpers.go
@@ -0,0 +1,37 @@
+package spxrpc
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+
+ "github.com/bluesky-social/indigo/atproto/syntax"
+ "github.com/labstack/echo/v4"
+)
+
+// takes in and returns an aturi, doing handle resolution if necessary on the way
+func (s *Server) normalizeURI(ctx context.Context, uri string) (*syntax.ATURI, error) {
+ aturi, err := syntax.ParseATURI(uri)
+ if err != nil {
+ return nil, echo.NewHTTPError(http.StatusBadRequest, "failed to parse AT-URI: "+err.Error())
+ }
+
+ if aturi.Authority().IsHandle() {
+ handle, err := aturi.Authority().AsHandle()
+ if err != nil {
+ return nil, echo.NewHTTPError(http.StatusBadRequest, "failed to convert handle: "+err.Error())
+ }
+ res, err := s.resolveStreamer(ctx, handle.String())
+ if err != nil {
+ return nil, echo.NewHTTPError(http.StatusBadRequest, "failed to resolve streamer: "+err.Error())
+ }
+ aturi, err = syntax.ParseATURI(fmt.Sprintf("at://%s/%s", res, aturi.Path()))
+ if err != nil {
+ return nil, echo.NewHTTPError(http.StatusBadRequest, "failed to parse AT-URI: "+err.Error())
+ }
+ } else if !aturi.Authority().IsDID() {
+ return nil, echo.NewHTTPError(http.StatusBadRequest, "uri authority must be a DID or handle: "+uri)
+ }
+
+ return &aturi, nil
+}
--
2.51.2
From 7d82197645c3dc3c14950ca1d3eb29c708bef6bf Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Tue, 26 May 2026 20:24:58 -0700
Subject: [PATCH 09/47] player: sort of kind of working on mobile! og cards!
---
go.mod | 8 +--
go.sum | 16 +++---
js/app/components/mobile/ui.tsx | 18 +++++--
.../mobile-player/video-async.native.tsx | 2 +-
.../src/hooks/useSegmentDimensions.tsx | 54 +++++++++++++++----
.../media/place-stream-media-getvideo.md | 8 +++
.../media/place-stream-media-track.md | 38 +++++++++++++
.../content/docs/lex-reference/openapi.json | 24 +++++++++
js/streamplace/src/useful-types.ts | 6 +++
lexicons/place/stream/media/getVideo.json | 7 +++
lexicons/place/stream/media/track.json | 13 +++++
pkg/linking/linking.go | 36 ++++++-------
pkg/linking/linking_test.go | 5 +-
pkg/model/video.go | 24 +++++++++
pkg/streamplace/mediagetVideo.go | 1 +
pkg/streamplace/mediatrack.go | 9 ++++
16 files changed, 220 insertions(+), 49 deletions(-)
diff --git a/go.mod b/go.mod
index 147824dd6..d98b3625b 100644
--- a/go.mod
+++ b/go.mod
@@ -63,9 +63,9 @@ require (
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/slok/go-http-metrics v0.13.0
github.com/starttoaster/prometheus-exporter-scraper v0.0.1
- github.com/streamplace/atproto-oauth-golang v0.0.0-20260413212710-98956064d06c
- github.com/streamplace/muxl/go v0.0.0-20260528173527-91f80c95ca45
- github.com/streamplace/oatproxy v0.0.0-20260602023256-6c7e423d20a0
+ github.com/streamplace/atproto-oauth-golang v0.0.0-20250619231223-a9c04fb888ac
+ github.com/streamplace/muxl/go v0.2.1
+ github.com/streamplace/oatproxy v0.0.0-20260508220721-f8852e8dbf44
github.com/stretchr/testify v1.11.1
github.com/tdewolff/canvas v0.0.0-20250728095813-50d4cb1eee71
github.com/tus/tusd/v2 v2.8.0
@@ -496,7 +496,7 @@ require (
github.com/tdewolff/minify/v2 v2.23.4 // indirect
github.com/tdewolff/parse/v2 v2.8.0 // indirect
github.com/tetafro/godot v1.5.1 // indirect
- github.com/tetratelabs/wazero v1.11.0 // indirect
+ github.com/tetratelabs/wazero v1.12.0 // indirect
github.com/thales-e-security/pool v0.0.2 // indirect
github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 // indirect
github.com/timonwong/loggercheck v0.11.0 // indirect
diff --git a/go.sum b/go.sum
index 29464487e..05aa64ea6 100644
--- a/go.sum
+++ b/go.sum
@@ -1369,16 +1369,16 @@ github.com/starttoaster/prometheus-exporter-scraper v0.0.1 h1:E3Zl3ho3v3Yt2Yk7i9
github.com/starttoaster/prometheus-exporter-scraper v0.0.1/go.mod h1:qNccVOSo0np2jYi6DolRyUICFcMkhH5k1Gs7CYxav1w=
github.com/stbenjam/no-sprintf-host-port v0.2.0 h1:i8pxvGrt1+4G0czLr/WnmyH7zbZ8Bg8etvARQ1rpyl4=
github.com/stbenjam/no-sprintf-host-port v0.2.0/go.mod h1:eL0bQ9PasS0hsyTyfTjjG+E80QIyPnBVQbYZyv20Jfk=
-github.com/streamplace/atproto-oauth-golang v0.0.0-20260413212710-98956064d06c h1:IzEPU2O4iL58Nb7aw+7lB9ttnesEwOVVE5oV9NEXemM=
-github.com/streamplace/atproto-oauth-golang v0.0.0-20260413212710-98956064d06c/go.mod h1:9LlKkqciiO5lRfbX0n4Wn5KNY9nvFb4R3by8FdW2TWc=
+github.com/streamplace/atproto-oauth-golang v0.0.0-20250619231223-a9c04fb888ac h1:heVM4CGox3kfJclSmagsI3hvKk7W52EegnFqqs9CSYk=
+github.com/streamplace/atproto-oauth-golang v0.0.0-20250619231223-a9c04fb888ac/go.mod h1:9LlKkqciiO5lRfbX0n4Wn5KNY9nvFb4R3by8FdW2TWc=
github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4 h1:L1fS4HJSaAyNnkwfuZubgfeZy8rkWmA0cMtH5Z0HqNc=
github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4/go.mod h1:bGUXY9Wd4mnd+XUrOYZr358J2f6z9QO/dLhL1SsiD+0=
github.com/streamplace/indigo v0.0.0-20260218231908-939cdaf0c507 h1:e8M3qPLr37NxEjlr18TaAwGP+OVyherVjgUG5VVmgWI=
github.com/streamplace/indigo v0.0.0-20260218231908-939cdaf0c507/go.mod h1:Pm2I1+iDXn/hLbF7XCg/DsZi6uDCiOo7hZGWprSM7k0=
-github.com/streamplace/muxl/go v0.0.0-20260528173527-91f80c95ca45 h1:v9KQ+tQnKwbSvS5orbszrd49ucySYi4F9npxoU2sLFU=
-github.com/streamplace/muxl/go v0.0.0-20260528173527-91f80c95ca45/go.mod h1:aCyYTW3o6c1Kush9UJ/Yv6EYMUbj8l8GTD7cHKcSxw8=
-github.com/streamplace/oatproxy v0.0.0-20260602023256-6c7e423d20a0 h1:FtJV63T621Dg5rQh049uTCx6kGQiiAXCReICz3hJSRQ=
-github.com/streamplace/oatproxy v0.0.0-20260602023256-6c7e423d20a0/go.mod h1:wpY+T/wE00jrUhgh2dKXbbE91D36u86KGlENK/hWFkE=
+github.com/streamplace/muxl/go v0.2.1 h1:ITjmjP3OREBgLowafspw7R5kGjmKX3uE6/ATAUIAHPk=
+github.com/streamplace/muxl/go v0.2.1/go.mod h1:aCyYTW3o6c1Kush9UJ/Yv6EYMUbj8l8GTD7cHKcSxw8=
+github.com/streamplace/oatproxy v0.0.0-20260508220721-f8852e8dbf44 h1:b38ToXNQCvKqBlx0SeQYUOhx6uqqnrF5AL6B0Z3zzdk=
+github.com/streamplace/oatproxy v0.0.0-20260508220721-f8852e8dbf44/go.mod h1:j1+zdhe1IC0+PTE2rIXk2LqYn4Lz2x9SJaV3eHkQyfs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
@@ -1424,8 +1424,8 @@ github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpR
github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY=
github.com/tetafro/godot v1.5.1 h1:PZnjCol4+FqaEzvZg5+O8IY2P3hfY9JzRBNPv1pEDS4=
github.com/tetafro/godot v1.5.1/go.mod h1:cCdPtEndkmqqrhiCfkmxDodMQJ/f3L1BCNskCUZdTwk=
-github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
-github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
+github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
+github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
github.com/thales-e-security/pool v0.0.2 h1:RAPs4q2EbWsTit6tpzuvTFlgFRJ3S8Evf5gtvVDbmPg=
github.com/thales-e-security/pool v0.0.2/go.mod h1:qtpMm2+thHtqhLzTwgDBj/OuNnMpupY8mv0Phz0gjhU=
github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 h1:9LPGD+jzxMlnk5r6+hJnar67cgpDIz/iyD+rfl5r2Vk=
diff --git a/js/app/components/mobile/ui.tsx b/js/app/components/mobile/ui.tsx
index a9c25be03..2c40879cb 100644
--- a/js/app/components/mobile/ui.tsx
+++ b/js/app/components/mobile/ui.tsx
@@ -160,7 +160,18 @@ export function MobileUi({
const hover = Gesture.Hover().onChange(onPlayerHover);
const pan = Gesture.Pan().onChange(onPlayerHover);
const tap = Gesture.Tap().onEnd(onPlayerHover);
-
+ let chatSection: React.ReactNode = null;
+ if (
+ mode !== "vod" &&
+ !hideMobileChat &&
+ !isSelfAndNotLive &&
+ playerIsReady &&
+ isPortrait
+ ) {
+ chatSection = (
+
+ );
+ }
const combined = Gesture.Race(hover, pan, tap);
return (
<>
@@ -301,10 +312,7 @@ export function MobileUi({
- {hideMobileChat ||
- (isPortrait && !isSelfAndNotLive && playerIsReady && (
-
- ))}
+ {chatSection}
>
);
}
diff --git a/js/components/src/components/mobile-player/video-async.native.tsx b/js/components/src/components/mobile-player/video-async.native.tsx
index 8ad93783c..da0700f6b 100644
--- a/js/components/src/components/mobile-player/video-async.native.tsx
+++ b/js/components/src/components/mobile-player/video-async.native.tsx
@@ -223,7 +223,7 @@ export function NativeVideo(props?: {
{
setFullscreen(true);
}}
diff --git a/js/components/src/hooks/useSegmentDimensions.tsx b/js/components/src/hooks/useSegmentDimensions.tsx
index bb95718e1..f61b68357 100644
--- a/js/components/src/hooks/useSegmentDimensions.tsx
+++ b/js/components/src/hooks/useSegmentDimensions.tsx
@@ -1,18 +1,52 @@
-import { useLivestreamStore } from "../livestream-store";
+import {
+ PlaceStreamMediaTrack,
+ PlaceStreamSegment,
+ VideoViewHydrated,
+} from "streamplace";
+import { useLivestreamStoreOptional } from "../livestream-store";
+import { useVideoStoreOptional } from "../video-store";
export function useSegmentDimensions() {
- const latestSegment = useLivestreamStore((x) => x.segment);
+ const video = useVideoStoreOptional((x) => x.video);
+ const latestSegment = useLivestreamStoreOptional((x) => x.segment);
- let seg = latestSegment?.video && latestSegment.video[0];
+ let height = 0;
+ let width = 0;
+ if (video) {
+ const meta = getVideoMetadata(video);
+ if (meta) {
+ height = meta.height || 0;
+ width = meta.width || 0;
+ }
+ } else {
+ let seg = latestSegment?.video && latestSegment.video[0];
- let ratio = {
- height: seg?.height || 0,
- width: seg?.width || 0,
- };
+ height = seg?.height || 0;
+ width = seg?.width || 0;
+ }
return {
- isPlayerRatioGreater: ratio.width > ratio.height,
- height: ratio.height,
- width: ratio.width,
+ isPlayerRatioGreater: width > height,
+ height: height,
+ width: width,
};
}
+
+function getVideoMetadata(
+ view: VideoViewHydrated,
+): PlaceStreamSegment.Video | null {
+ if (!view.tracks) {
+ return null;
+ }
+ for (const track of view.tracks) {
+ const meta = track.record.metadata;
+ if (!PlaceStreamMediaTrack.isCommonMetadata(meta)) {
+ continue;
+ }
+ if (!meta.video) {
+ continue;
+ }
+ return meta.video;
+ }
+ return null;
+}
diff --git a/js/docs/src/content/docs/lex-reference/media/place-stream-media-getvideo.md b/js/docs/src/content/docs/lex-reference/media/place-stream-media-getvideo.md
index ee6c54c8c..ecbd9f611 100644
--- a/js/docs/src/content/docs/lex-reference/media/place-stream-media-getvideo.md
+++ b/js/docs/src/content/docs/lex-reference/media/place-stream-media-getvideo.md
@@ -49,6 +49,7 @@ Get a hydrated view of a place.stream.video record — the record itself plus au
| `author` | [`app.bsky.actor.defs#profileViewBasic`](https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky/actor/defs.json#profileViewBasic) | ✅ | | |
| `record` | `unknown` | ✅ | | |
| `viewCounts` | [`#viewCountSummary`](#viewcountsummary) | ✅ | Aggregated view counts across every indexed reporter. Always present; zero-valued (count=0, reporters=0) when no place.stream.media.viewCount records have been observed yet, so consumers can render a count unconditionally. | |
+| `tracks` | Array of [`place.stream.media.track#trackView`](/lex-reference/place-stream-media-track#trackview) | ❌ | | |
---
@@ -129,6 +130,13 @@ Sums across every place.stream.media.viewCount record indexed for this video, re
"type": "ref",
"ref": "#viewCountSummary",
"description": "Aggregated view counts across every indexed reporter. Always present; zero-valued (count=0, reporters=0) when no place.stream.media.viewCount records have been observed yet, so consumers can render a count unconditionally."
+ },
+ "tracks": {
+ "type": "array",
+ "items": {
+ "type": "ref",
+ "ref": "place.stream.media.track#trackView"
+ }
}
}
},
diff --git a/js/docs/src/content/docs/lex-reference/media/place-stream-media-track.md b/js/docs/src/content/docs/lex-reference/media/place-stream-media-track.md
index 74f8bafee..b948bd80a 100644
--- a/js/docs/src/content/docs/lex-reference/media/place-stream-media-track.md
+++ b/js/docs/src/content/docs/lex-reference/media/place-stream-media-track.md
@@ -47,6 +47,23 @@ Metadata common to all media types. Contains subobjects for other media types.
---
+
+
+### `trackView`
+
+**Type:** `object`
+
+**Properties:**
+
+| Name | Type | Req'd | Description | Constraints |
+| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ----------- | ---------------- |
+| `uri` | `string` | ✅ | | Format: `at-uri` |
+| `cid` | `string` | ✅ | | Format: `cid` |
+| `author` | [`app.bsky.actor.defs#profileViewBasic`](https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky/actor/defs.json#profileViewBasic) | ✅ | | |
+| `record` | `unknown` | ✅ | | |
+
+---
+
## Lexicon Source
```json
@@ -105,6 +122,27 @@ Metadata common to all media types. Contains subobjects for other media types.
"ref": "place.stream.segment#audio"
}
}
+ },
+ "trackView": {
+ "type": "object",
+ "required": ["uri", "cid", "author", "record"],
+ "properties": {
+ "uri": {
+ "type": "string",
+ "format": "at-uri"
+ },
+ "cid": {
+ "type": "string",
+ "format": "cid"
+ },
+ "author": {
+ "type": "ref",
+ "ref": "app.bsky.actor.defs#profileViewBasic"
+ },
+ "record": {
+ "type": "unknown"
+ }
+ }
}
}
}
diff --git a/js/docs/src/content/docs/lex-reference/openapi.json b/js/docs/src/content/docs/lex-reference/openapi.json
index ef53b6590..9a5e5efde 100644
--- a/js/docs/src/content/docs/lex-reference/openapi.json
+++ b/js/docs/src/content/docs/lex-reference/openapi.json
@@ -4732,6 +4732,12 @@
"viewCounts": {
"$ref": "#/components/schemas/place.stream.media.getVideo_viewCountSummary",
"description": "Aggregated view counts across every indexed reporter. Always present; zero-valued (count=0, reporters=0) when no place.stream.media.viewCount records have been observed yet, so consumers can render a count unconditionally."
+ },
+ "tracks": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/place.stream.media.track_trackView"
+ }
}
},
"required": ["uri", "cid", "author", "record", "viewCounts"]
@@ -5100,6 +5106,24 @@
},
"required": ["count", "bytes", "durationMs", "reporters"]
},
+ "place.stream.media.track_trackView": {
+ "type": "object",
+ "properties": {
+ "uri": {
+ "type": "string",
+ "format": "uri"
+ },
+ "cid": {
+ "type": "string",
+ "format": "cid"
+ },
+ "author": {
+ "$ref": "#/components/schemas/app.bsky.actor.defs_profileViewBasic"
+ },
+ "record": {}
+ },
+ "required": ["uri", "cid", "author", "record"]
+ },
"place.stream.video": {
"description": "Unknown type"
},
diff --git a/js/streamplace/src/useful-types.ts b/js/streamplace/src/useful-types.ts
index 230bf200d..263d4f8ea 100644
--- a/js/streamplace/src/useful-types.ts
+++ b/js/streamplace/src/useful-types.ts
@@ -4,6 +4,7 @@ import {
PlaceStreamChatPinnedRecord,
PlaceStreamLivestream,
PlaceStreamMediaGetVideo,
+ PlaceStreamMediaTrack,
} from "./lexicons";
export interface LivestreamViewHydrated
@@ -23,4 +24,9 @@ export interface PinnedRecordViewHydrated
export interface VideoViewHydrated extends PlaceStreamMediaGetVideo.VideoView {
record: PlaceStreamLivestream.Record;
+ tracks: TrackViewHydrated[];
+}
+
+export interface TrackViewHydrated extends PlaceStreamMediaTrack.TrackView {
+ record: PlaceStreamMediaTrack.Record;
}
diff --git a/lexicons/place/stream/media/getVideo.json b/lexicons/place/stream/media/getVideo.json
index d210cd528..86f5ae1eb 100644
--- a/lexicons/place/stream/media/getVideo.json
+++ b/lexicons/place/stream/media/getVideo.json
@@ -45,6 +45,13 @@
"type": "ref",
"ref": "#viewCountSummary",
"description": "Aggregated view counts across every indexed reporter. Always present; zero-valued (count=0, reporters=0) when no place.stream.media.viewCount records have been observed yet, so consumers can render a count unconditionally."
+ },
+ "tracks": {
+ "type": "array",
+ "items": {
+ "type": "ref",
+ "ref": "place.stream.media.track#trackView"
+ }
}
}
},
diff --git a/lexicons/place/stream/media/track.json b/lexicons/place/stream/media/track.json
index e2ea1fe3a..8e0af8ab2 100644
--- a/lexicons/place/stream/media/track.json
+++ b/lexicons/place/stream/media/track.json
@@ -53,6 +53,19 @@
"ref": "place.stream.segment#audio"
}
}
+ },
+ "trackView": {
+ "type": "object",
+ "required": ["uri", "cid", "author", "record"],
+ "properties": {
+ "uri": { "type": "string", "format": "at-uri" },
+ "cid": { "type": "string", "format": "cid" },
+ "author": {
+ "type": "ref",
+ "ref": "app.bsky.actor.defs#profileViewBasic"
+ },
+ "record": { "type": "unknown" }
+ }
}
}
}
diff --git a/pkg/linking/linking.go b/pkg/linking/linking.go
index 748998383..aafda0cbc 100644
--- a/pkg/linking/linking.go
+++ b/pkg/linking/linking.go
@@ -214,24 +214,19 @@ func (l *Linker) GenerateVideoCard(ctx context.Context, u *url.URL, vv *streampl
imageURL = cardURL.String()
}
- titleStr := fmt.Sprintf("@%s's video on ", authorHandle)
-
- // Define all meta tags
+ // Define all meta tags. The title/description pair is appended after we
+ // resolve the node's branding title below: the card headline is the
+ // video's own title, and the author + node go in the description.
metaTags := []MetaTag{
- // Basic meta
- {Type: "name", Key: "description", Content: video.Title},
-
// Facebook Meta Tags
{Type: "property", Key: "og:url", Content: outURL},
{Type: "property", Key: "og:type", Content: "video.other"},
- {Type: "property", Key: "og:description", Content: video.Title},
{Type: "property", Key: "og:image", Content: imageURL},
// Twitter Meta Tags
{Type: "name", Key: "twitter:card", Content: "summary_large_image"},
{Type: "property", Key: "twitter:domain", Content: u.Host},
{Type: "property", Key: "twitter:url", Content: outURL},
- {Type: "name", Key: "twitter:description", Content: video.Title},
{Type: "name", Key: "twitter:image", Content: imageURL},
}
@@ -261,20 +256,21 @@ func (l *Linker) GenerateVideoCard(ctx context.Context, u *url.URL, vv *streampl
}
}
- // do twitter/og title after
- metaTags = append(metaTags, MetaTag{
- Type: "property",
- Key: "og:title",
- Content: fmt.Sprintf("%s%s", titleStr, brandingTitle),
- })
- metaTags = append(metaTags, MetaTag{
- Type: "name",
- Key: "twitter:title",
- Content: fmt.Sprintf("%s%s", titleStr, brandingTitle),
- })
+ // The card headline is the video's own title; the author + node name go
+ // in the description. Appended after the branding loop so the description
+ // can reference the resolved branding title.
+ title := video.Title
+ description := fmt.Sprintf("@%s's video on %s", authorHandle, brandingTitle)
+ metaTags = append(metaTags,
+ MetaTag{Type: "name", Key: "description", Content: description},
+ MetaTag{Type: "property", Key: "og:description", Content: description},
+ MetaTag{Type: "property", Key: "og:title", Content: title},
+ MetaTag{Type: "name", Key: "twitter:description", Content: description},
+ MetaTag{Type: "name", Key: "twitter:title", Content: title},
+ )
return l.GenerateHTML(ctx, &PageConfig{
- Title: fmt.Sprintf("%s%s", titleStr, brandingTitle),
+ Title: title,
Metas: metaTags,
SentryDSN: sentryDSN,
})
diff --git a/pkg/linking/linking_test.go b/pkg/linking/linking_test.go
index e6b23de5e..2b9c4132b 100644
--- a/pkg/linking/linking_test.go
+++ b/pkg/linking/linking_test.go
@@ -105,7 +105,10 @@ func TestGenerateVideoCard(t *testing.T) {
require.NoError(t, err)
linkStr := string(linkCard)
require.True(t, strings.Contains(linkStr, "iame.li"), "should contain the author handle")
- require.True(t, strings.Contains(linkStr, video.Title), "should contain the video title")
+ require.True(t, strings.Contains(linkStr, ""+video.Title+""),
+ "page title should be the video's own title")
+ require.True(t, strings.Contains(linkStr, `content="`+video.Title+`"`),
+ "og:title/twitter:title should be the video's own title")
require.True(t, strings.Contains(linkStr,
"https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:2zmxikig2sj7gqaezl5gntae/"+thumbCID+"@jpeg"),
"og:image should be the video thumbnail served via the bsky CDN")
diff --git a/pkg/model/video.go b/pkg/model/video.go
index 5ac3de687..ae4f8436f 100644
--- a/pkg/model/video.go
+++ b/pkg/model/video.go
@@ -114,12 +114,36 @@ func (m *DBModel) GetVideoView(ctx context.Context, uri string) (*streamplace.Me
return nil, err
}
+ tracks := []*streamplace.MediaTrack_TrackView{}
+ if rec.Source.MediaDefs_SourceTracks != nil {
+ for _, track := range rec.Source.MediaDefs_SourceTracks.Tracks {
+ t, err := m.GetMediaTrackByURI(ctx, track.Uri)
+ if err != nil {
+ return nil, fmt.Errorf("get media track by uri: %w", err)
+ }
+ if t == nil {
+ continue
+ }
+ cid, err := spid.GetCID(t)
+ if err != nil {
+ return nil, fmt.Errorf("get cid: %w", err)
+ }
+ tracks = append(tracks, &streamplace.MediaTrack_TrackView{
+ Record: &lexutil.LexiconTypeDecoder{Val: t},
+ Uri: track.Uri,
+ Cid: cid.String(),
+ Author: author,
+ })
+ }
+ }
+
return &streamplace.MediaGetVideo_VideoView{
Uri: row.URI,
Cid: row.CID,
Author: author,
Record: &lexutil.LexiconTypeDecoder{Val: rec},
ViewCounts: summary,
+ Tracks: tracks,
}, nil
}
diff --git a/pkg/streamplace/mediagetVideo.go b/pkg/streamplace/mediagetVideo.go
index 7f365c975..028b80e19 100644
--- a/pkg/streamplace/mediagetVideo.go
+++ b/pkg/streamplace/mediagetVideo.go
@@ -16,6 +16,7 @@ type MediaGetVideo_VideoView struct {
Author *appbsky.ActorDefs_ProfileViewBasic `json:"author" cborgen:"author"`
Cid string `json:"cid" cborgen:"cid"`
Record *lexutil.LexiconTypeDecoder `json:"record" cborgen:"record"`
+ Tracks []*MediaTrack_TrackView `json:"tracks,omitempty" cborgen:"tracks,omitempty"`
Uri string `json:"uri" cborgen:"uri"`
// viewCounts: Aggregated view counts across every indexed reporter. Always present; zero-valued (count=0, reporters=0) when no place.stream.media.viewCount records have been observed yet, so consumers can render a count unconditionally.
ViewCounts *MediaGetVideo_ViewCountSummary `json:"viewCounts" cborgen:"viewCounts"`
diff --git a/pkg/streamplace/mediatrack.go b/pkg/streamplace/mediatrack.go
index 35d00403c..38558888c 100644
--- a/pkg/streamplace/mediatrack.go
+++ b/pkg/streamplace/mediatrack.go
@@ -11,6 +11,7 @@ import (
"io"
comatproto "github.com/bluesky-social/indigo/api/atproto"
+ appbsky "github.com/bluesky-social/indigo/api/bsky"
lexutil "github.com/bluesky-social/indigo/lex/util"
cbg "github.com/whyrusleeping/cbor-gen"
)
@@ -149,3 +150,11 @@ func (t *MediaTrack_Track) UnmarshalCBOR(r io.Reader) error {
return nil
}
}
+
+// MediaTrack_TrackView is a "trackView" in the place.stream.media.track schema.
+type MediaTrack_TrackView struct {
+ Author *appbsky.ActorDefs_ProfileViewBasic `json:"author" cborgen:"author"`
+ Cid string `json:"cid" cborgen:"cid"`
+ Record *lexutil.LexiconTypeDecoder `json:"record" cborgen:"record"`
+ Uri string `json:"uri" cborgen:"uri"`
+}
--
2.51.2
From 32a222ba54c2d512f762edcc6989c3aa284a0e8a Mon Sep 17 00:00:00 2001
From: Natalie Bridgers
Date: Fri, 29 May 2026 22:16:58 -0500
Subject: [PATCH 10/47] likes and comments
---
js/app/components/mobile/player.tsx | 43 +-
.../components/mobile-player/video-retry.tsx | 24 +-
.../src/components/vod/vod-comments.tsx | 173 ++++
.../src/components/vod/vod-like-button.tsx | 77 ++
.../components/vod/vod-mobile-metadata.tsx | 56 +
.../src/components/vod/vod-section.tsx | 28 +
js/components/src/index.tsx | 7 +
.../src/vod-store/vod-interactions.tsx | 173 ++++
.../place-stream-moderation-createvodgate.md | 117 +++
.../place-stream-moderation-deletevodgate.md | 101 ++
.../place-stream-moderation-permission.md | 8 +-
.../content/docs/lex-reference/openapi.json | 957 ++++++++++++------
.../vod/place-stream-vod-comment.md | 109 ++
.../vod/place-stream-vod-defs.md | 73 ++
.../vod/place-stream-vod-gate.md | 53 +
.../vod/place-stream-vod-getcomments.md | 94 ++
.../vod/place-stream-vod-getlikes.md | 148 +++
.../vod/place-stream-vod-like.md | 59 ++
.../stream/moderation/createVodGate.json | 62 ++
.../stream/moderation/deleteVodGate.json | 50 +
.../place/stream/moderation/permission.json | 8 +-
lexicons/place/stream/vod/comment.json | 56 +
lexicons/place/stream/vod/defs.json | 29 +
lexicons/place/stream/vod/gate.json | 22 +
lexicons/place/stream/vod/getComments.json | 49 +
lexicons/place/stream/vod/getLikes.json | 69 ++
lexicons/place/stream/vod/like.json | 27 +
mise.toml | 1 +
pkg/atproto/firehose.go | 21 +
pkg/atproto/sync.go | 127 +++
pkg/constants/constants.go | 3 +
pkg/gen/gen.go | 4 +
pkg/model/model.go | 22 +
pkg/model/vod_comment.go | 141 +++
pkg/model/vod_gate.go | 55 +
pkg/model/vod_like.go | 119 +++
pkg/moderation/permissions.go | 3 +
pkg/spxrpc/place_stream_moderation.go | 96 ++
pkg/spxrpc/place_stream_vod.go | 80 ++
pkg/spxrpc/stubs.go | 92 ++
pkg/streamplace/cbor_gen.go | 756 ++++++++++++++
pkg/streamplace/moderationcreateVodGate.go | 37 +
pkg/streamplace/moderationdeleteVodGate.go | 33 +
pkg/streamplace/vodcomment.go | 33 +
pkg/streamplace/voddefs.go | 53 +
pkg/streamplace/vodgate.go | 19 +
pkg/streamplace/vodgetComments.go | 40 +
pkg/streamplace/vodgetLikes.go | 53 +
pkg/streamplace/vodlike.go | 21 +
49 files changed, 4158 insertions(+), 323 deletions(-)
create mode 100644 js/components/src/components/vod/vod-comments.tsx
create mode 100644 js/components/src/components/vod/vod-like-button.tsx
create mode 100644 js/components/src/components/vod/vod-mobile-metadata.tsx
create mode 100644 js/components/src/components/vod/vod-section.tsx
create mode 100644 js/components/src/vod-store/vod-interactions.tsx
create mode 100644 js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-createvodgate.md
create mode 100644 js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-deletevodgate.md
create mode 100644 js/docs/src/content/docs/lex-reference/vod/place-stream-vod-comment.md
create mode 100644 js/docs/src/content/docs/lex-reference/vod/place-stream-vod-defs.md
create mode 100644 js/docs/src/content/docs/lex-reference/vod/place-stream-vod-gate.md
create mode 100644 js/docs/src/content/docs/lex-reference/vod/place-stream-vod-getcomments.md
create mode 100644 js/docs/src/content/docs/lex-reference/vod/place-stream-vod-getlikes.md
create mode 100644 js/docs/src/content/docs/lex-reference/vod/place-stream-vod-like.md
create mode 100644 lexicons/place/stream/moderation/createVodGate.json
create mode 100644 lexicons/place/stream/moderation/deleteVodGate.json
create mode 100644 lexicons/place/stream/vod/comment.json
create mode 100644 lexicons/place/stream/vod/defs.json
create mode 100644 lexicons/place/stream/vod/gate.json
create mode 100644 lexicons/place/stream/vod/getComments.json
create mode 100644 lexicons/place/stream/vod/getLikes.json
create mode 100644 lexicons/place/stream/vod/like.json
create mode 100644 pkg/model/vod_comment.go
create mode 100644 pkg/model/vod_gate.go
create mode 100644 pkg/model/vod_like.go
create mode 100644 pkg/spxrpc/place_stream_vod.go
create mode 100644 pkg/streamplace/moderationcreateVodGate.go
create mode 100644 pkg/streamplace/moderationdeleteVodGate.go
create mode 100644 pkg/streamplace/vodcomment.go
create mode 100644 pkg/streamplace/voddefs.go
create mode 100644 pkg/streamplace/vodgate.go
create mode 100644 pkg/streamplace/vodgetComments.go
create mode 100644 pkg/streamplace/vodgetLikes.go
create mode 100644 pkg/streamplace/vodlike.go
diff --git a/js/app/components/mobile/player.tsx b/js/app/components/mobile/player.tsx
index 997f3b22d..037351ab7 100644
--- a/js/app/components/mobile/player.tsx
+++ b/js/app/components/mobile/player.tsx
@@ -18,6 +18,7 @@ import {
useSegmentDimensions,
VideoProvider,
View,
+ VodSection,
} from "@streamplace/components";
import { gap, h, pt, w } from "@streamplace/components/src/lib/theme/atoms";
import { useLiveUser } from "hooks/useLiveUser";
@@ -349,11 +350,20 @@ export function PlayerInner(
}, [props.showUnavailable]);
// content info
- const { width, height } = usePlayerDimensions();
+ const { width: pwidth, height: pheight } = usePlayerDimensions();
+
+ console.log(pwidth, pheight);
+
+ let width = pwidth > 0 && props.mode !== "vod" ? pwidth : 16;
+ let height = pheight > 0 && props.mode !== "vod" ? pheight : 9;
+
+ console.log(width, height);
// Calculate aspect ratio and determine if we're in desktop mode
const aspectRatio = width > 0 && height > 0 ? width / height : 16 / 9;
+ console.log(aspectRatio, 16 / 9);
+
// on mobile we want to hide the sidebar when going fullscreen
useEffect(() => {
if (Platform.OS !== "web" && width > height) {
@@ -386,6 +396,12 @@ export function PlayerInner(
const isPlayerRatioGreater = aspectRatio >= 16 / 9;
+ const { height: windowHeight } = useWindowDimensions();
+ const vodMobileHeight =
+ props.mode === "vod"
+ ? Math.min(contentWidth / aspectRatio, windowHeight * 0.7)
+ : undefined;
+
// animated style for offline height transition
const animatedHeightStyle = useAnimatedStyle(() => {
return {
@@ -408,11 +424,13 @@ export function PlayerInner(
flexGrow: 1, // This makes content expand to fill available space
minHeight: "100%", // Ensures minimum height
}
- : {
- flex: 1,
- }
+ : props.mode === "vod"
+ ? { flexGrow: 1 }
+ : {
+ flex: 1,
+ }
}
- scrollEnabled={showFullDesktopMode}
+ scrollEnabled={showFullDesktopMode || props.mode === "vod"}
bounces={false}
showsVerticalScrollIndicator={false}
>
@@ -422,10 +440,15 @@ export function PlayerInner(
? {
width: calculatedWidth,
}
- : {
- flex: 1,
- maxHeight: "auto",
- },
+ : props.mode === "vod" && vodMobileHeight
+ ? {
+ width: "100%" as any,
+ height: vodMobileHeight,
+ }
+ : {
+ flex: 1,
+ maxHeight: "auto",
+ },
{
paddingTop:
isPlayerRatioGreater && !isLandscape && !props.showUnavailable
@@ -444,6 +467,7 @@ export function PlayerInner(
) : (
(isLandscape || props.mode === "vod") && (
@@ -473,6 +497,7 @@ export function PlayerInner(
showChat={props.showChat}
/>
)}
+ {props.mode === "vod" && }
);
}
diff --git a/js/components/src/components/mobile-player/video-retry.tsx b/js/components/src/components/mobile-player/video-retry.tsx
index a7e461f0c..296aa8281 100644
--- a/js/components/src/components/mobile-player/video-retry.tsx
+++ b/js/components/src/components/mobile-player/video-retry.tsx
@@ -5,16 +5,15 @@ export default function VideoRetry(props: { children: React.ReactNode }) {
const retryTimeoutRef = useRef(null);
const [retries, setRetries] = useState(0);
const playing = usePlayerStore((x) => x.status === PlayerStatus.PLAYING);
+ const mode = usePlayerStore((x) => x.mode);
+ const status = usePlayerStore((x) => x.status);
const [lastChange, setLastChange] = useState(Date.now());
const [tick, setTick] = useState(0);
- const status = usePlayerStore((x) => x.status);
useEffect(() => {
setLastChange(Date.now());
}, [status]);
- const mode = usePlayerStore((x) => x.mode);
-
useEffect(() => {
if (mode === "vod") return;
if (!playing) {
@@ -28,7 +27,15 @@ export default function VideoRetry(props: { children: React.ReactNode }) {
const stalledFor5sec = !playing && tick - lastChange > 5000;
useEffect(() => {
- if (!playing) {
+ if (stalledFor5sec) {
+ console.log("Stalled for 5 seconds, retrying...");
+ setRetries((r) => r + 1);
+ }
+ }, [stalledFor5sec]);
+
+ useEffect(() => {
+ if (mode === "vod") return;
+ if (!playing && status !== PlayerStatus.PAUSE) {
const doRetry = () => {
console.log("Retrying video playback...");
setRetries((prevRetries) => prevRetries + 1);
@@ -44,14 +51,7 @@ export default function VideoRetry(props: { children: React.ReactNode }) {
retryTimeoutRef.current = null;
}
};
- }, [!playing, mode]);
-
- useEffect(() => {
- if (stalledFor5sec) {
- console.log("Stalled for 5 seconds, retrying...");
- setRetries((r) => r + 1);
- }
- }, [stalledFor5sec]);
+ }, [!playing, mode, status]);
return {props.children};
}
diff --git a/js/components/src/components/vod/vod-comments.tsx b/js/components/src/components/vod/vod-comments.tsx
new file mode 100644
index 000000000..1d7b99de9
--- /dev/null
+++ b/js/components/src/components/vod/vod-comments.tsx
@@ -0,0 +1,173 @@
+import { useCallback, useEffect, useState } from "react";
+import {
+ ActivityIndicator,
+ FlatList,
+ TouchableOpacity,
+ View,
+} from "react-native";
+import type { PlaceStreamVodDefs } from "streamplace";
+import {
+ borders,
+ flex,
+ gap,
+ layout,
+ mb,
+ mt,
+ pt,
+ px,
+ py,
+ r,
+ useTheme,
+} from "../../ui";
+import {
+ useCreateVodComment,
+ useGetVodComments,
+} from "../../vod-store/vod-interactions";
+import { Text } from "../ui/text";
+import { Textarea } from "../ui/textarea";
+
+export function VodComments({ videoUri }: { videoUri: string }) {
+ const [comments, setComments] = useState(
+ [],
+ );
+ const [loading, setLoading] = useState(true);
+ const [draft, setDraft] = useState("");
+ const [submitting, setSubmitting] = useState(false);
+ const [cursor, setCursor] = useState();
+
+ const getComments = useGetVodComments();
+ const createComment = useCreateVodComment();
+ const { theme } = useTheme();
+
+ const loadComments = useCallback(async () => {
+ setLoading(true);
+ try {
+ const result = await getComments(videoUri, 50, cursor);
+ setComments((prev) =>
+ cursor ? [...prev, ...result.comments] : result.comments,
+ );
+ setCursor(result.cursor);
+ } catch (e) {
+ console.error("Failed to load comments", e);
+ } finally {
+ setLoading(false);
+ }
+ }, [videoUri, cursor, getComments]);
+
+ useEffect(() => {
+ setCursor(undefined);
+ loadComments();
+ }, [videoUri]);
+
+ const handleSubmit = useCallback(async () => {
+ if (!draft.trim()) return;
+ setSubmitting(true);
+ try {
+ await createComment({ text: draft.trim(), video: videoUri });
+ setDraft("");
+ setCursor(undefined);
+ await loadComments();
+ } catch (e) {
+ console.error("Failed to submit comment", e);
+ } finally {
+ setSubmitting(false);
+ }
+ }, [draft, videoUri, createComment, loadComments]);
+
+ const renderComment = useCallback(
+ ({ item }: { item: PlaceStreamVodDefs.CommentView }) => {
+ const record = item.record as any;
+ return (
+
+
+
+ {item.author.handle || item.author.did}
+
+
+ {new Date(item.indexedAt).toLocaleDateString()}
+
+
+ {record.text}
+
+
+ {item.likeCount > 0 ? `${item.likeCount} likes` : ""}
+
+
+
+ );
+ },
+ [theme],
+ );
+
+ return (
+
+
+ Comments
+
+
+ {loading && comments.length === 0 ? (
+
+ ) : (
+ item.uri}
+ onEndReached={cursor ? loadComments : undefined}
+ onEndReachedThreshold={0.5}
+ style={flex.values[1]}
+ />
+ )}
+
+
+
+
+ );
+}
diff --git a/js/components/src/components/vod/vod-like-button.tsx b/js/components/src/components/vod/vod-like-button.tsx
new file mode 100644
index 000000000..3d3816615
--- /dev/null
+++ b/js/components/src/components/vod/vod-like-button.tsx
@@ -0,0 +1,77 @@
+import { useCallback, useEffect, useState } from "react";
+import { TouchableOpacity, View } from "react-native";
+import { useDID } from "../../streamplace-store/streamplace-store";
+import { gap, layout, p, useTheme } from "../../ui";
+import {
+ useCreateVodLike,
+ useDeleteVodLike,
+ useGetVodLikes,
+} from "../../vod-store/vod-interactions";
+import { Text } from "../ui/text";
+
+export function VodLikeButton({ subjectUri }: { subjectUri: string }) {
+ const [likeCount, setLikeCount] = useState(0);
+ const [userLiked, setUserLiked] = useState(false);
+ const [userLikeUri, setUserLikeUri] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ const userDID = useDID();
+ const getLikes = useGetVodLikes();
+ const createLike = useCreateVodLike();
+ const deleteLike = useDeleteVodLike();
+ const { theme } = useTheme();
+
+ const loadLikes = useCallback(async () => {
+ try {
+ const result = await getLikes(subjectUri, 50);
+ setLikeCount(result.count);
+ if (userDID && result.likes) {
+ const myLike = result.likes.find((l) => l.author.did === userDID);
+ setUserLiked(!!myLike);
+ setUserLikeUri(myLike?.uri || null);
+ }
+ } catch (e) {
+ console.error("Failed to load likes", e);
+ }
+ }, [subjectUri, getLikes, userDID]);
+
+ useEffect(() => {
+ loadLikes();
+ }, [loadLikes]);
+
+ const toggleLike = useCallback(async () => {
+ setLoading(true);
+ try {
+ if (userLiked && userLikeUri) {
+ await deleteLike(userLikeUri);
+ setUserLiked(false);
+ setUserLikeUri(null);
+ setLikeCount((c) => Math.max(0, c - 1));
+ } else {
+ const result = await createLike(subjectUri);
+ setUserLiked(true);
+ setUserLikeUri(result.data.uri);
+ setLikeCount((c) => c + 1);
+ }
+ } catch (e) {
+ console.error("Failed to toggle like", e);
+ } finally {
+ setLoading(false);
+ }
+ }, [userLiked, userLikeUri, subjectUri, createLike, deleteLike]);
+
+ return (
+
+
+
+ {userLiked ? "\u2764\uFE0F" : "\u2661"}
+
+
+ {likeCount}
+
+
+
+ );
+}
diff --git a/js/components/src/components/vod/vod-mobile-metadata.tsx b/js/components/src/components/vod/vod-mobile-metadata.tsx
new file mode 100644
index 000000000..a8fa26b29
--- /dev/null
+++ b/js/components/src/components/vod/vod-mobile-metadata.tsx
@@ -0,0 +1,56 @@
+import { View } from "react-native";
+import { zero } from "../..";
+import { useTitle } from "../../hooks/useTitle";
+import { borders, gap, layout, mb, pb, useTheme } from "../../ui";
+import { useVideoStore } from "../../video-store/video-store";
+import { Text } from "../ui/text";
+import { VodLikeButton } from "./vod-like-button";
+
+export function VodMobileMetadata() {
+ const video = useVideoStore((x) => x.video);
+ const aturi = useVideoStore((x) => x.aturi);
+ const title = useTitle();
+ const { theme } = useTheme();
+
+ if (!video || !aturi) return null;
+
+ const author = video.author;
+ const viewCount = video.viewCounts?.count ?? 0;
+
+ return (
+
+
+
+
+ {title || "Untitled"}
+
+
+ {author.handle ? `@${author.handle}` : author.did}
+
+
+
+
+
+
+
+ {viewCount.toLocaleString()} view{viewCount !== 1 ? "s" : ""}
+
+
+
+ );
+}
diff --git a/js/components/src/components/vod/vod-section.tsx b/js/components/src/components/vod/vod-section.tsx
new file mode 100644
index 000000000..b456d8775
--- /dev/null
+++ b/js/components/src/components/vod/vod-section.tsx
@@ -0,0 +1,28 @@
+import { useWindowDimensions, View } from "react-native";
+import { px, py } from "../../ui";
+import { useVideoStore } from "../../video-store/video-store";
+import { VodComments } from "./vod-comments";
+import { VodMobileMetadata } from "./vod-mobile-metadata";
+
+export function VodSection() {
+ const aturi = useVideoStore((x) => x.aturi);
+ const { width } = useWindowDimensions();
+ const isNarrow = width < 768;
+
+ if (!aturi) {
+ return null;
+ }
+
+ return (
+
+ {isNarrow && }
+
+
+ );
+}
diff --git a/js/components/src/index.tsx b/js/components/src/index.tsx
index 55599cdd3..1ed375d84 100644
--- a/js/components/src/index.tsx
+++ b/js/components/src/index.tsx
@@ -81,3 +81,10 @@ export {
ACTIVITY_LABEL_DISPLAY,
formatActivity,
} from "./lib/metadata-constants";
+
+// VOD components and interaction hooks
+export * from "./components/vod/vod-comments";
+export * from "./components/vod/vod-like-button";
+export * from "./components/vod/vod-mobile-metadata";
+export * from "./components/vod/vod-section";
+export * from "./vod-store/vod-interactions";
diff --git a/js/components/src/vod-store/vod-interactions.tsx b/js/components/src/vod-store/vod-interactions.tsx
new file mode 100644
index 000000000..11d8d1233
--- /dev/null
+++ b/js/components/src/vod-store/vod-interactions.tsx
@@ -0,0 +1,173 @@
+import { useCallback } from "react";
+import {
+ PlaceStreamVodComment,
+ PlaceStreamVodDefs,
+ PlaceStreamVodGetComments,
+ PlaceStreamVodGetLikes,
+ PlaceStreamVodLike,
+} from "streamplace";
+import { useDID } from "../streamplace-store/streamplace-store";
+import { usePDSAgent } from "../streamplace-store/xrpc";
+
+export const useCreateVodComment = () => {
+ const pdsAgent = usePDSAgent();
+ const userDID = useDID();
+
+ return useCallback(
+ async (params: { text: string; video: string }) => {
+ if (!pdsAgent || !userDID) {
+ throw new Error("No PDS agent or user DID found");
+ }
+
+ const record: PlaceStreamVodComment.Record = {
+ $type: "place.stream.vod.comment",
+ text: params.text,
+ createdAt: new Date().toISOString(),
+ video: params.video,
+ };
+
+ return await pdsAgent.com.atproto.repo.createRecord({
+ repo: userDID,
+ collection: "place.stream.vod.comment",
+ record,
+ });
+ },
+ [pdsAgent, userDID],
+ );
+};
+
+export const useDeleteVodComment = () => {
+ const pdsAgent = usePDSAgent();
+ const userDID = useDID();
+
+ return useCallback(
+ async (uri: string) => {
+ if (!pdsAgent || !userDID) {
+ throw new Error("No PDS agent or user DID found");
+ }
+ const rkey = uri.split("/").pop();
+ if (!rkey) throw new Error("No rkey found");
+ return await pdsAgent.com.atproto.repo.deleteRecord({
+ repo: userDID,
+ collection: "place.stream.vod.comment",
+ rkey,
+ });
+ },
+ [pdsAgent, userDID],
+ );
+};
+
+export const useCreateVodLike = () => {
+ const pdsAgent = usePDSAgent();
+ const userDID = useDID();
+
+ return useCallback(
+ async (subject: string) => {
+ if (!pdsAgent || !userDID) {
+ throw new Error("No PDS agent or user DID found");
+ }
+
+ const record: PlaceStreamVodLike.Record = {
+ $type: "place.stream.vod.like",
+ subject,
+ createdAt: new Date().toISOString(),
+ };
+
+ return await pdsAgent.com.atproto.repo.createRecord({
+ repo: userDID,
+ collection: "place.stream.vod.like",
+ record,
+ });
+ },
+ [pdsAgent, userDID],
+ );
+};
+
+export const useDeleteVodLike = () => {
+ const pdsAgent = usePDSAgent();
+ const userDID = useDID();
+
+ return useCallback(
+ async (uri: string) => {
+ if (!pdsAgent || !userDID) {
+ throw new Error("No PDS agent or user DID found");
+ }
+ const rkey = uri.split("/").pop();
+ if (!rkey) throw new Error("No rkey found");
+ return await pdsAgent.com.atproto.repo.deleteRecord({
+ repo: userDID,
+ collection: "place.stream.vod.like",
+ rkey,
+ });
+ },
+ [pdsAgent, userDID],
+ );
+};
+
+export const useGetVodComments = () => {
+ const pdsAgent = usePDSAgent();
+
+ return useCallback(
+ async (
+ video: string,
+ limit?: number,
+ cursor?: string,
+ ): Promise => {
+ if (!pdsAgent) {
+ throw new Error("No PDS agent found");
+ }
+ const res = await pdsAgent.place.stream.vod.getComments({
+ video,
+ limit,
+ cursor,
+ });
+ return res.data;
+ },
+ [pdsAgent],
+ );
+};
+
+export const useGetVodLikes = () => {
+ const pdsAgent = usePDSAgent();
+
+ return useCallback(
+ async (
+ subject: string,
+ limit?: number,
+ cursor?: string,
+ ): Promise => {
+ if (!pdsAgent) {
+ throw new Error("No PDS agent found");
+ }
+ const res = await pdsAgent.place.stream.vod.getLikes({
+ subject,
+ limit,
+ cursor,
+ });
+ return res.data;
+ },
+ [pdsAgent],
+ );
+};
+
+export const useVodLikeCount = () => {
+ const pdsAgent = usePDSAgent();
+
+ return useCallback(
+ async (subject: string): Promise => {
+ if (!pdsAgent) {
+ throw new Error("No PDS agent found");
+ }
+ const res = await pdsAgent.place.stream.vod.getLikes({
+ subject,
+ limit: 1,
+ });
+ return res.data.count;
+ },
+ [pdsAgent],
+ );
+};
+
+export type VodCommentHydrated = PlaceStreamVodDefs.CommentView & {
+ record: PlaceStreamVodComment.Record;
+};
diff --git a/js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-createvodgate.md b/js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-createvodgate.md
new file mode 100644
index 000000000..f6bb4c8e0
--- /dev/null
+++ b/js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-createvodgate.md
@@ -0,0 +1,117 @@
+---
+title: place.stream.moderation.createVodGate
+description: Reference for the place.stream.moderation.createVodGate lexicon
+---
+
+**Lexicon Version:** 1
+
+## Definitions
+
+
+
+### `main`
+
+**Type:** `procedure`
+
+Create a gate (hide VOD comment) on behalf of a streamer. Requires 'vod.comment.hide' permission. Creates a place.stream.vod.gate record in the streamer's repository.
+
+**Parameters:** _(None defined)_
+
+**Input:**
+
+- **Encoding:** `application/json`
+- **Schema:**
+
+**Schema Type:** `object`
+
+| Name | Type | Req'd | Description | Constraints |
+| ------------ | -------- | ----- | -------------------------------------- | ---------------- |
+| `streamer` | `string` | ✅ | The DID of the streamer. | Format: `did` |
+| `commentUri` | `string` | ✅ | The AT-URI of the VOD comment to hide. | Format: `at-uri` |
+
+**Output:**
+
+- **Encoding:** `application/json`
+- **Schema:**
+
+**Schema Type:** `object`
+
+| Name | Type | Req'd | Description | Constraints |
+| ----- | -------- | ----- | -------------------------------------- | ---------------- |
+| `uri` | `string` | ✅ | The AT-URI of the created gate record. | Format: `at-uri` |
+| `cid` | `string` | ✅ | The CID of the created gate record. | Format: `cid` |
+
+**Possible Errors:**
+
+- `Unauthorized`: The request lacks valid authentication credentials.
+- `Forbidden`: The caller does not have permission to hide VOD comments for this streamer.
+- `SessionNotFound`: The streamer's OAuth session could not be found or is invalid.
+
+---
+
+## Lexicon Source
+
+```json
+{
+ "lexicon": 1,
+ "id": "place.stream.moderation.createVodGate",
+ "defs": {
+ "main": {
+ "type": "procedure",
+ "description": "Create a gate (hide VOD comment) on behalf of a streamer. Requires 'vod.comment.hide' permission. Creates a place.stream.vod.gate record in the streamer's repository.",
+ "input": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["streamer", "commentUri"],
+ "properties": {
+ "streamer": {
+ "type": "string",
+ "format": "did",
+ "description": "The DID of the streamer."
+ },
+ "commentUri": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "The AT-URI of the VOD comment to hide."
+ }
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["uri", "cid"],
+ "properties": {
+ "uri": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "The AT-URI of the created gate record."
+ },
+ "cid": {
+ "type": "string",
+ "format": "cid",
+ "description": "The CID of the created gate record."
+ }
+ }
+ }
+ },
+ "errors": [
+ {
+ "name": "Unauthorized",
+ "description": "The request lacks valid authentication credentials."
+ },
+ {
+ "name": "Forbidden",
+ "description": "The caller does not have permission to hide VOD comments for this streamer."
+ },
+ {
+ "name": "SessionNotFound",
+ "description": "The streamer's OAuth session could not be found or is invalid."
+ }
+ ]
+ }
+ }
+}
+```
diff --git a/js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-deletevodgate.md b/js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-deletevodgate.md
new file mode 100644
index 000000000..bb1ed4b3b
--- /dev/null
+++ b/js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-deletevodgate.md
@@ -0,0 +1,101 @@
+---
+title: place.stream.moderation.deleteVodGate
+description: Reference for the place.stream.moderation.deleteVodGate lexicon
+---
+
+**Lexicon Version:** 1
+
+## Definitions
+
+
+
+### `main`
+
+**Type:** `procedure`
+
+Delete a gate (unhide VOD comment) on behalf of a streamer. Requires 'vod.comment.hide' permission. Deletes a place.stream.vod.gate record from the streamer's repository.
+
+**Parameters:** _(None defined)_
+
+**Input:**
+
+- **Encoding:** `application/json`
+- **Schema:**
+
+**Schema Type:** `object`
+
+| Name | Type | Req'd | Description | Constraints |
+| ---------- | -------- | ----- | ---------------------------------------- | ---------------- |
+| `streamer` | `string` | ✅ | The DID of the streamer. | Format: `did` |
+| `gateUri` | `string` | ✅ | The AT-URI of the gate record to delete. | Format: `at-uri` |
+
+**Output:**
+
+- **Encoding:** `application/json`
+- **Schema:**
+
+**Schema Type:** `object`
+
+_(No properties defined)_
+**Possible Errors:**
+
+- `Unauthorized`: The request lacks valid authentication credentials.
+- `Forbidden`: The caller does not have permission to unhide VOD comments for this streamer.
+- `SessionNotFound`: The streamer's OAuth session could not be found or is invalid.
+
+---
+
+## Lexicon Source
+
+```json
+{
+ "lexicon": 1,
+ "id": "place.stream.moderation.deleteVodGate",
+ "defs": {
+ "main": {
+ "type": "procedure",
+ "description": "Delete a gate (unhide VOD comment) on behalf of a streamer. Requires 'vod.comment.hide' permission. Deletes a place.stream.vod.gate record from the streamer's repository.",
+ "input": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["streamer", "gateUri"],
+ "properties": {
+ "streamer": {
+ "type": "string",
+ "format": "did",
+ "description": "The DID of the streamer."
+ },
+ "gateUri": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "The AT-URI of the gate record to delete."
+ }
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "properties": {}
+ }
+ },
+ "errors": [
+ {
+ "name": "Unauthorized",
+ "description": "The request lacks valid authentication credentials."
+ },
+ {
+ "name": "Forbidden",
+ "description": "The caller does not have permission to unhide VOD comments for this streamer."
+ },
+ {
+ "name": "SessionNotFound",
+ "description": "The streamer's OAuth session could not be found or is invalid."
+ }
+ ]
+ }
+ }
+}
+```
diff --git a/js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-permission.md b/js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-permission.md
index c45f3a9b3..c14e61eed 100644
--- a/js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-permission.md
+++ b/js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-permission.md
@@ -52,7 +52,13 @@ Record granting moderation permissions to a user for this streamer's content.
"type": "array",
"items": {
"type": "string",
- "enum": ["ban", "hide", "livestream.manage", "message.pin"]
+ "enum": [
+ "ban",
+ "hide",
+ "livestream.manage",
+ "message.pin",
+ "vod.comment.hide"
+ ]
},
"description": "Array of permissions granted to this moderator. 'ban' covers blocks/bans (with optional expiration), 'hide' covers message gates, 'livestream.manage' allows updating livestream metadata."
},
diff --git a/js/docs/src/content/docs/lex-reference/openapi.json b/js/docs/src/content/docs/lex-reference/openapi.json
index 9a5e5efde..873bb2632 100644
--- a/js/docs/src/content/docs/lex-reference/openapi.json
+++ b/js/docs/src/content/docs/lex-reference/openapi.json
@@ -6,6 +6,149 @@
"description": "Autogenerated using lexmd"
},
"paths": {
+ "/xrpc/place.stream.vod.getComments": {
+ "get": {
+ "summary": "Get comments for a place.stream.video record.",
+ "operationId": "place.stream.vod.getComments",
+ "tags": ["place.stream.vod"],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "cursor": {
+ "type": "string"
+ },
+ "comments": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/place.stream.vod.defs_commentView"
+ }
+ }
+ },
+ "required": ["comments"]
+ }
+ }
+ }
+ }
+ },
+ "parameters": [
+ {
+ "name": "video",
+ "in": "query",
+ "required": true,
+ "description": "AT-URI of the place.stream.video record.",
+ "schema": {
+ "type": "string",
+ "description": "AT-URI of the place.stream.video record.",
+ "format": "uri"
+ }
+ },
+ {
+ "name": "cursor",
+ "in": "query",
+ "required": false,
+ "description": "Optional pagination cursor.",
+ "schema": {
+ "type": "string",
+ "description": "Optional pagination cursor."
+ }
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "description": "Maximum number of comments to return.",
+ "schema": {
+ "type": "integer",
+ "description": "Maximum number of comments to return.",
+ "default": 50,
+ "minimum": 1,
+ "maximum": 100
+ }
+ }
+ ]
+ }
+ },
+ "/xrpc/place.stream.vod.getLikes": {
+ "get": {
+ "summary": "Get likes for a subject (video or comment).",
+ "operationId": "place.stream.vod.getLikes",
+ "tags": ["place.stream.vod"],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "subject": {
+ "type": "string",
+ "format": "uri"
+ },
+ "count": {
+ "type": "integer",
+ "description": "Total number of likes for this subject.",
+ "minimum": 0
+ },
+ "cursor": {
+ "type": "string"
+ },
+ "likes": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/place.stream.vod.getLikes_likeView"
+ }
+ }
+ },
+ "required": ["subject", "count"]
+ }
+ }
+ }
+ }
+ },
+ "parameters": [
+ {
+ "name": "subject",
+ "in": "query",
+ "required": true,
+ "description": "AT-URI of the subject (video or comment).",
+ "schema": {
+ "type": "string",
+ "description": "AT-URI of the subject (video or comment).",
+ "format": "uri"
+ }
+ },
+ {
+ "name": "cursor",
+ "in": "query",
+ "required": false,
+ "description": "Optional pagination cursor.",
+ "schema": {
+ "type": "string",
+ "description": "Optional pagination cursor."
+ }
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "description": "Maximum number of likes to return.",
+ "schema": {
+ "type": "integer",
+ "description": "Maximum number of likes to return.",
+ "default": 50,
+ "minimum": 1,
+ "maximum": 100
+ }
+ }
+ ]
+ }
+ },
"/xrpc/place.stream.server.createWebhook": {
"post": {
"summary": "Create a new webhook for receiving Streamplace events.",
@@ -1576,10 +1719,10 @@
}
}
},
- "/xrpc/place.stream.moderation.deleteBlock": {
+ "/xrpc/place.stream.moderation.createVodGate": {
"post": {
- "summary": "Delete a block (unban) on behalf of a streamer. Requires 'ban' permission. Deletes an app.bsky.graph.block record from the streamer's repository.",
- "operationId": "place.stream.moderation.deleteBlock",
+ "summary": "Create a gate (hide VOD comment) on behalf of a streamer. Requires 'vod.comment.hide' permission. Creates a place.stream.vod.gate record in the streamer's repository.",
+ "operationId": "place.stream.moderation.createVodGate",
"tags": ["place.stream.moderation"],
"responses": {
"200": {
@@ -1588,7 +1731,19 @@
"application/json": {
"schema": {
"type": "object",
- "properties": {}
+ "properties": {
+ "uri": {
+ "type": "string",
+ "description": "The AT-URI of the created gate record.",
+ "format": "uri"
+ },
+ "cid": {
+ "type": "string",
+ "description": "The CID of the created gate record.",
+ "format": "cid"
+ }
+ },
+ "required": ["uri", "cid"]
}
}
}
@@ -1636,23 +1791,23 @@
"description": "The DID of the streamer.",
"format": "did"
},
- "blockUri": {
+ "commentUri": {
"type": "string",
- "description": "The AT-URI of the block record to delete.",
+ "description": "The AT-URI of the VOD comment to hide.",
"format": "uri"
}
},
- "required": ["streamer", "blockUri"]
+ "required": ["streamer", "commentUri"]
}
}
}
}
}
},
- "/xrpc/place.stream.moderation.deleteGate": {
+ "/xrpc/place.stream.moderation.deleteBlock": {
"post": {
- "summary": "Delete a gate (unhide message) on behalf of a streamer. Requires 'hide' permission. Deletes a place.stream.chat.gate record from the streamer's repository.",
- "operationId": "place.stream.moderation.deleteGate",
+ "summary": "Delete a block (unban) on behalf of a streamer. Requires 'ban' permission. Deletes an app.bsky.graph.block record from the streamer's repository.",
+ "operationId": "place.stream.moderation.deleteBlock",
"tags": ["place.stream.moderation"],
"responses": {
"200": {
@@ -1709,23 +1864,23 @@
"description": "The DID of the streamer.",
"format": "did"
},
- "gateUri": {
+ "blockUri": {
"type": "string",
- "description": "The AT-URI of the gate record to delete.",
+ "description": "The AT-URI of the block record to delete.",
"format": "uri"
}
},
- "required": ["streamer", "gateUri"]
+ "required": ["streamer", "blockUri"]
}
}
}
}
}
},
- "/xrpc/place.stream.moderation.deletePin": {
+ "/xrpc/place.stream.moderation.deleteGate": {
"post": {
- "summary": "Unpin a pinned chat message on behalf of a streamer. Requires 'message.pin' permission. Deletes the place.stream.chat.pinnedRecord from the streamer's repo.",
- "operationId": "place.stream.moderation.deletePin",
+ "summary": "Delete a gate (unhide message) on behalf of a streamer. Requires 'hide' permission. Deletes a place.stream.chat.gate record from the streamer's repository.",
+ "operationId": "place.stream.moderation.deleteGate",
"tags": ["place.stream.moderation"],
"responses": {
"200": {
@@ -1782,23 +1937,23 @@
"description": "The DID of the streamer.",
"format": "did"
},
- "pinUri": {
+ "gateUri": {
"type": "string",
- "description": "The AT-URI of the pinned record to delete.",
+ "description": "The AT-URI of the gate record to delete.",
"format": "uri"
}
},
- "required": ["streamer", "pinUri"]
+ "required": ["streamer", "gateUri"]
}
}
}
}
}
},
- "/xrpc/place.stream.moderation.updateLivestream": {
+ "/xrpc/place.stream.moderation.deletePin": {
"post": {
- "summary": "Update livestream metadata on behalf of a streamer. Requires 'livestream.manage' permission. Updates a place.stream.livestream record in the streamer's repository.",
- "operationId": "place.stream.moderation.updateLivestream",
+ "summary": "Unpin a pinned chat message on behalf of a streamer. Requires 'message.pin' permission. Deletes the place.stream.chat.pinnedRecord from the streamer's repo.",
+ "operationId": "place.stream.moderation.deletePin",
"tags": ["place.stream.moderation"],
"responses": {
"200": {
@@ -1807,19 +1962,7 @@
"application/json": {
"schema": {
"type": "object",
- "properties": {
- "uri": {
- "type": "string",
- "description": "The AT-URI of the updated livestream record.",
- "format": "uri"
- },
- "cid": {
- "type": "string",
- "description": "The CID of the updated livestream record.",
- "format": "cid"
- }
- },
- "required": ["uri", "cid"]
+ "properties": {}
}
}
}
@@ -1843,9 +1986,6 @@
},
{
"const": "SessionNotFound"
- },
- {
- "const": "RecordNotFound"
}
]
},
@@ -1870,29 +2010,24 @@
"description": "The DID of the streamer.",
"format": "did"
},
- "livestreamUri": {
+ "pinUri": {
"type": "string",
- "description": "The AT-URI of the livestream record to update.",
+ "description": "The AT-URI of the pinned record to delete.",
"format": "uri"
- },
- "title": {
- "type": "string",
- "description": "New title for the livestream.",
- "maxLength": 1400
}
},
- "required": ["streamer", "livestreamUri"]
+ "required": ["streamer", "pinUri"]
}
}
}
}
}
},
- "/xrpc/place.stream.media.createUpload": {
+ "/xrpc/place.stream.moderation.deleteVodGate": {
"post": {
- "summary": "Start a resumable upload of arbitrary media content. Returns a TUS upload URL and a short-lived bearer token used to authenticate subsequent chunk requests (no DPoP required on chunks). The DID of the authenticated user is recorded against the upload; on completion a VOD processing task is enqueued.",
- "operationId": "place.stream.media.createUpload",
- "tags": ["place.stream.media"],
+ "summary": "Delete a gate (unhide VOD comment) on behalf of a streamer. Requires 'vod.comment.hide' permission. Deletes a place.stream.vod.gate record from the streamer's repository.",
+ "operationId": "place.stream.moderation.deleteVodGate",
+ "tags": ["place.stream.moderation"],
"responses": {
"200": {
"description": "Success",
@@ -1900,32 +2035,7 @@
"application/json": {
"schema": {
"type": "object",
- "properties": {
- "uploadId": {
- "type": "string",
- "description": "Server-side identifier for this upload."
- },
- "uploadUrl": {
- "type": "string",
- "description": "Absolute URL to PATCH/HEAD per the TUS protocol.",
- "format": "uri"
- },
- "uploadToken": {
- "type": "string",
- "description": "Short-lived bearer token bound to this upload. Send as 'Authorization: Bearer ' on TUS requests."
- },
- "expiresAt": {
- "type": "string",
- "description": "When the upload token expires.",
- "format": "date-time"
- }
- },
- "required": [
- "uploadUrl",
- "uploadToken",
- "expiresAt",
- "uploadId"
- ]
+ "properties": {}
}
}
}
@@ -1942,10 +2052,13 @@
"type": "string",
"oneOf": [
{
- "const": "UploadTooLarge"
+ "const": "Unauthorized"
},
{
- "const": "UnsupportedMimeType"
+ "const": "Forbidden"
+ },
+ {
+ "const": "SessionNotFound"
}
]
},
@@ -1965,35 +2078,223 @@
"schema": {
"type": "object",
"properties": {
- "size": {
- "type": "integer",
- "description": "Total size of the upload in bytes."
- },
- "mimeType": {
+ "streamer": {
"type": "string",
- "description": "MIME type of the content being uploaded (e.g. video/mp4)."
+ "description": "The DID of the streamer.",
+ "format": "did"
},
- "filename": {
+ "gateUri": {
"type": "string",
- "description": "Optional filename hint to attach as upload metadata."
+ "description": "The AT-URI of the gate record to delete.",
+ "format": "uri"
}
},
- "required": ["size", "mimeType"]
+ "required": ["streamer", "gateUri"]
}
}
}
}
}
},
- "/xrpc/place.stream.media.getUploadStatus": {
- "get": {
- "summary": "Get the processing status of a previously created upload. Only accessible by the DID that created the upload.",
- "operationId": "place.stream.media.getUploadStatus",
- "tags": ["place.stream.media"],
- "responses": {
- "200": {
- "description": "Success",
- "content": {
+ "/xrpc/place.stream.moderation.updateLivestream": {
+ "post": {
+ "summary": "Update livestream metadata on behalf of a streamer. Requires 'livestream.manage' permission. Updates a place.stream.livestream record in the streamer's repository.",
+ "operationId": "place.stream.moderation.updateLivestream",
+ "tags": ["place.stream.moderation"],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "uri": {
+ "type": "string",
+ "description": "The AT-URI of the updated livestream record.",
+ "format": "uri"
+ },
+ "cid": {
+ "type": "string",
+ "description": "The CID of the updated livestream record.",
+ "format": "cid"
+ }
+ },
+ "required": ["uri", "cid"]
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["error", "message"],
+ "properties": {
+ "error": {
+ "type": "string",
+ "oneOf": [
+ {
+ "const": "Unauthorized"
+ },
+ {
+ "const": "Forbidden"
+ },
+ {
+ "const": "SessionNotFound"
+ },
+ {
+ "const": "RecordNotFound"
+ }
+ ]
+ },
+ "message": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "streamer": {
+ "type": "string",
+ "description": "The DID of the streamer.",
+ "format": "did"
+ },
+ "livestreamUri": {
+ "type": "string",
+ "description": "The AT-URI of the livestream record to update.",
+ "format": "uri"
+ },
+ "title": {
+ "type": "string",
+ "description": "New title for the livestream.",
+ "maxLength": 1400
+ }
+ },
+ "required": ["streamer", "livestreamUri"]
+ }
+ }
+ }
+ }
+ }
+ },
+ "/xrpc/place.stream.media.createUpload": {
+ "post": {
+ "summary": "Start a resumable upload of arbitrary media content. Returns a TUS upload URL and a short-lived bearer token used to authenticate subsequent chunk requests (no DPoP required on chunks). The DID of the authenticated user is recorded against the upload; on completion a VOD processing task is enqueued.",
+ "operationId": "place.stream.media.createUpload",
+ "tags": ["place.stream.media"],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "uploadId": {
+ "type": "string",
+ "description": "Server-side identifier for this upload."
+ },
+ "uploadUrl": {
+ "type": "string",
+ "description": "Absolute URL to PATCH/HEAD per the TUS protocol.",
+ "format": "uri"
+ },
+ "uploadToken": {
+ "type": "string",
+ "description": "Short-lived bearer token bound to this upload. Send as 'Authorization: Bearer ' on TUS requests."
+ },
+ "expiresAt": {
+ "type": "string",
+ "description": "When the upload token expires.",
+ "format": "date-time"
+ }
+ },
+ "required": [
+ "uploadUrl",
+ "uploadToken",
+ "expiresAt",
+ "uploadId"
+ ]
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["error", "message"],
+ "properties": {
+ "error": {
+ "type": "string",
+ "oneOf": [
+ {
+ "const": "UploadTooLarge"
+ },
+ {
+ "const": "UnsupportedMimeType"
+ }
+ ]
+ },
+ "message": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "size": {
+ "type": "integer",
+ "description": "Total size of the upload in bytes."
+ },
+ "mimeType": {
+ "type": "string",
+ "description": "MIME type of the content being uploaded (e.g. video/mp4)."
+ },
+ "filename": {
+ "type": "string",
+ "description": "Optional filename hint to attach as upload metadata."
+ }
+ },
+ "required": ["size", "mimeType"]
+ }
+ }
+ }
+ }
+ }
+ },
+ "/xrpc/place.stream.media.getUploadStatus": {
+ "get": {
+ "summary": "Get the processing status of a previously created upload. Only accessible by the DID that created the upload.",
+ "operationId": "place.stream.media.getUploadStatus",
+ "tags": ["place.stream.media"],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
"application/json": {
"schema": {
"type": "object",
@@ -4549,225 +4850,66 @@
},
"components": {
"schemas": {
- "place.stream.server.defs_rewriteRule": {
+ "place.stream.vod.defs_commentView": {
"type": "object",
"properties": {
- "from": {
+ "uri": {
"type": "string",
- "description": "Text to search for and replace.",
- "minLength": 1,
- "maxLength": 100
+ "format": "uri"
},
- "to": {
+ "cid": {
"type": "string",
- "description": "Text to replace with.",
- "maxLength": 100
+ "format": "cid"
+ },
+ "author": {
+ "$ref": "#/components/schemas/app.bsky.actor.defs_profileViewBasic"
+ },
+ "record": {},
+ "indexedAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "replyTo": {
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/place.stream.vod.defs_commentView"
+ }
+ ]
+ },
+ "likeCount": {
+ "type": "integer",
+ "description": "Number of likes on this comment.",
+ "minimum": 0
}
},
- "required": ["from", "to"]
+ "required": ["uri", "cid", "author", "record", "indexedAt", "likeCount"]
},
- "place.stream.server.defs_webhook": {
+ "app.bsky.actor.defs_profileViewBasic": {
"type": "object",
- "description": "A webhook configuration for receiving Streamplace events.",
"properties": {
- "id": {
+ "did": {
"type": "string",
- "description": "Unique identifier for this webhook."
+ "format": "did"
},
- "url": {
+ "handle": {
+ "type": "string",
+ "format": "handle"
+ },
+ "displayName": {
+ "type": "string",
+ "maxLength": 640
+ },
+ "avatar": {
"type": "string",
- "description": "The webhook URL where events will be sent.",
"format": "uri"
},
- "events": {
- "type": "array",
- "description": "The types of events this webhook should receive.",
- "items": {
- "type": "string",
- "enum": ["chat", "livestream", "follow", "mention"]
- }
- },
- "active": {
- "type": "boolean",
- "description": "Whether this webhook is currently active."
- },
- "prefix": {
- "type": "string",
- "description": "Text to prepend to webhook messages.",
- "maxLength": 100
- },
- "suffix": {
- "type": "string",
- "description": "Text to append to webhook messages.",
- "maxLength": 100
- },
- "rewrite": {
- "type": "array",
- "description": "Text replacement rules for webhook messages.",
- "items": {
- "$ref": "#/components/schemas/place.stream.server.defs_rewriteRule"
- }
- },
- "createdAt": {
- "type": "string",
- "description": "When this webhook was created.",
- "format": "date-time"
- },
- "updatedAt": {
- "type": "string",
- "description": "When this webhook was last updated.",
- "format": "date-time"
- },
- "name": {
- "type": "string",
- "description": "A user-friendly name for this webhook.",
- "maxLength": 100
- },
- "description": {
- "type": "string",
- "description": "A description of what this webhook is used for.",
- "maxLength": 500
- },
- "lastTriggered": {
- "type": "string",
- "description": "When this webhook was last triggered.",
- "format": "date-time"
- },
- "errorCount": {
- "type": "integer",
- "description": "Number of consecutive errors for this webhook."
- },
- "muteWords": {
- "type": "array",
- "description": "Words to filter out from chat messages. Messages containing any of these words will not be forwarded.",
- "items": {
- "type": "string",
- "maxLength": 100
- }
- }
- },
- "required": ["id", "url", "events", "active", "createdAt"]
- },
- "place.stream.server.defs_storage": {
- "type": "object",
- "description": "S3 storage configuration for backups.",
- "properties": {
- "url": {
- "type": "string",
- "description": "S3 storage URL with masked secret key in format: s3+https://ACCESS_KEY:***@endpoint/bucket"
- },
- "isActive": {
- "type": "boolean",
- "description": "Whether backup storage is currently active."
- }
- },
- "required": ["url", "isActive"]
- },
- "place.stream.multistream.target": {
- "description": "Unknown type"
- },
- "place.stream.multistream.defs_targetView": {
- "type": "object",
- "properties": {
- "uri": {
- "type": "string",
- "format": "uri"
- },
- "cid": {
- "type": "string",
- "format": "cid"
- },
- "record": {},
- "latestEvent": {
- "$ref": "#/components/schemas/place.stream.multistream.defs_event"
- }
- },
- "required": ["uri", "cid", "record"]
- },
- "place.stream.multistream.defs_event": {
- "type": "object",
- "properties": {
- "message": {
- "type": "string"
- },
- "status": {
- "type": "string",
- "enum": ["inactive", "pending", "active", "error"]
- },
- "createdAt": {
- "type": "string",
- "format": "date-time"
- }
- },
- "required": ["message", "status", "createdAt"]
- },
- "place.stream.media.getUploadStatus_trackRef": {
- "type": "object",
- "properties": {
- "uri": {
- "type": "string",
- "format": "uri"
- },
- "cid": {
- "type": "string"
- }
- },
- "required": ["uri", "cid"]
- },
- "place.stream.media.getVideo_videoView": {
- "type": "object",
- "properties": {
- "uri": {
- "type": "string",
- "format": "uri"
- },
- "cid": {
- "type": "string",
- "format": "cid"
- },
- "author": {
- "$ref": "#/components/schemas/app.bsky.actor.defs_profileViewBasic"
- },
- "record": {},
- "viewCounts": {
- "$ref": "#/components/schemas/place.stream.media.getVideo_viewCountSummary",
- "description": "Aggregated view counts across every indexed reporter. Always present; zero-valued (count=0, reporters=0) when no place.stream.media.viewCount records have been observed yet, so consumers can render a count unconditionally."
- },
- "tracks": {
- "type": "array",
- "items": {
- "$ref": "#/components/schemas/place.stream.media.track_trackView"
- }
- }
- },
- "required": ["uri", "cid", "author", "record", "viewCounts"]
- },
- "app.bsky.actor.defs_profileViewBasic": {
- "type": "object",
- "properties": {
- "did": {
- "type": "string",
- "format": "did"
- },
- "handle": {
- "type": "string",
- "format": "handle"
- },
- "displayName": {
- "type": "string",
- "maxLength": 640
- },
- "avatar": {
- "type": "string",
- "format": "uri"
- },
- "associated": {
- "$ref": "#/components/schemas/app.bsky.actor.defs_profileAssociated"
- },
- "viewer": {
- "$ref": "#/components/schemas/app.bsky.actor.defs_viewerState"
- },
- "labels": {
+ "associated": {
+ "$ref": "#/components/schemas/app.bsky.actor.defs_profileAssociated"
+ },
+ "viewer": {
+ "$ref": "#/components/schemas/app.bsky.actor.defs_viewerState"
+ },
+ "labels": {
"type": "array",
"items": {
"$ref": "#/components/schemas/com.atproto.label.defs_label"
@@ -5079,6 +5221,221 @@
},
"required": ["uri", "title", "description"]
},
+ "place.stream.vod.getLikes_likeView": {
+ "type": "object",
+ "properties": {
+ "uri": {
+ "type": "string",
+ "format": "uri"
+ },
+ "cid": {
+ "type": "string",
+ "format": "cid"
+ },
+ "author": {
+ "$ref": "#/components/schemas/app.bsky.actor.defs_profileViewBasic"
+ },
+ "record": {},
+ "indexedAt": {
+ "type": "string",
+ "format": "date-time"
+ }
+ },
+ "required": ["uri", "cid", "author", "record", "indexedAt"]
+ },
+ "place.stream.server.defs_rewriteRule": {
+ "type": "object",
+ "properties": {
+ "from": {
+ "type": "string",
+ "description": "Text to search for and replace.",
+ "minLength": 1,
+ "maxLength": 100
+ },
+ "to": {
+ "type": "string",
+ "description": "Text to replace with.",
+ "maxLength": 100
+ }
+ },
+ "required": ["from", "to"]
+ },
+ "place.stream.server.defs_webhook": {
+ "type": "object",
+ "description": "A webhook configuration for receiving Streamplace events.",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Unique identifier for this webhook."
+ },
+ "url": {
+ "type": "string",
+ "description": "The webhook URL where events will be sent.",
+ "format": "uri"
+ },
+ "events": {
+ "type": "array",
+ "description": "The types of events this webhook should receive.",
+ "items": {
+ "type": "string",
+ "enum": ["chat", "livestream", "follow", "mention"]
+ }
+ },
+ "active": {
+ "type": "boolean",
+ "description": "Whether this webhook is currently active."
+ },
+ "prefix": {
+ "type": "string",
+ "description": "Text to prepend to webhook messages.",
+ "maxLength": 100
+ },
+ "suffix": {
+ "type": "string",
+ "description": "Text to append to webhook messages.",
+ "maxLength": 100
+ },
+ "rewrite": {
+ "type": "array",
+ "description": "Text replacement rules for webhook messages.",
+ "items": {
+ "$ref": "#/components/schemas/place.stream.server.defs_rewriteRule"
+ }
+ },
+ "createdAt": {
+ "type": "string",
+ "description": "When this webhook was created.",
+ "format": "date-time"
+ },
+ "updatedAt": {
+ "type": "string",
+ "description": "When this webhook was last updated.",
+ "format": "date-time"
+ },
+ "name": {
+ "type": "string",
+ "description": "A user-friendly name for this webhook.",
+ "maxLength": 100
+ },
+ "description": {
+ "type": "string",
+ "description": "A description of what this webhook is used for.",
+ "maxLength": 500
+ },
+ "lastTriggered": {
+ "type": "string",
+ "description": "When this webhook was last triggered.",
+ "format": "date-time"
+ },
+ "errorCount": {
+ "type": "integer",
+ "description": "Number of consecutive errors for this webhook."
+ },
+ "muteWords": {
+ "type": "array",
+ "description": "Words to filter out from chat messages. Messages containing any of these words will not be forwarded.",
+ "items": {
+ "type": "string",
+ "maxLength": 100
+ }
+ }
+ },
+ "required": ["id", "url", "events", "active", "createdAt"]
+ },
+ "place.stream.server.defs_storage": {
+ "type": "object",
+ "description": "S3 storage configuration for backups.",
+ "properties": {
+ "url": {
+ "type": "string",
+ "description": "S3 storage URL with masked secret key in format: s3+https://ACCESS_KEY:***@endpoint/bucket"
+ },
+ "isActive": {
+ "type": "boolean",
+ "description": "Whether backup storage is currently active."
+ }
+ },
+ "required": ["url", "isActive"]
+ },
+ "place.stream.multistream.target": {
+ "description": "Unknown type"
+ },
+ "place.stream.multistream.defs_targetView": {
+ "type": "object",
+ "properties": {
+ "uri": {
+ "type": "string",
+ "format": "uri"
+ },
+ "cid": {
+ "type": "string",
+ "format": "cid"
+ },
+ "record": {},
+ "latestEvent": {
+ "$ref": "#/components/schemas/place.stream.multistream.defs_event"
+ }
+ },
+ "required": ["uri", "cid", "record"]
+ },
+ "place.stream.multistream.defs_event": {
+ "type": "object",
+ "properties": {
+ "message": {
+ "type": "string"
+ },
+ "status": {
+ "type": "string",
+ "enum": ["inactive", "pending", "active", "error"]
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time"
+ }
+ },
+ "required": ["message", "status", "createdAt"]
+ },
+ "place.stream.media.getUploadStatus_trackRef": {
+ "type": "object",
+ "properties": {
+ "uri": {
+ "type": "string",
+ "format": "uri"
+ },
+ "cid": {
+ "type": "string"
+ }
+ },
+ "required": ["uri", "cid"]
+ },
+ "place.stream.media.getVideo_videoView": {
+ "type": "object",
+ "properties": {
+ "uri": {
+ "type": "string",
+ "format": "uri"
+ },
+ "cid": {
+ "type": "string",
+ "format": "cid"
+ },
+ "author": {
+ "$ref": "#/components/schemas/app.bsky.actor.defs_profileViewBasic"
+ },
+ "record": {},
+ "viewCounts": {
+ "$ref": "#/components/schemas/place.stream.media.getVideo_viewCountSummary",
+ "description": "Aggregated view counts across every indexed reporter. Always present; zero-valued (count=0, reporters=0) when no place.stream.media.viewCount records have been observed yet, so consumers can render a count unconditionally."
+ },
+ "tracks": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/place.stream.media.track_trackView"
+ }
+ }
+ },
+ "required": ["uri", "cid", "author", "record", "viewCounts"]
+ },
"place.stream.media.getVideo_viewCountSummary": {
"type": "object",
"description": "Sums across every place.stream.media.viewCount record indexed for this video, regardless of reporter or window. The underlying records are window-bounded; the consumer sees a running total.",
diff --git a/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-comment.md b/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-comment.md
new file mode 100644
index 000000000..0f1cc755b
--- /dev/null
+++ b/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-comment.md
@@ -0,0 +1,109 @@
+---
+title: place.stream.vod.comment
+description: Reference for the place.stream.vod.comment lexicon
+---
+
+**Lexicon Version:** 1
+
+## Definitions
+
+
+
+### `main`
+
+**Type:** `record`
+
+Record containing a comment on a VOD.
+
+**Record Key:** `tid`
+
+**Record Properties:**
+
+| Name | Type | Req'd | Description | Constraints |
+| ----------- | ------------------------------------------------------------------------------------ | ----- | ---------------------------------------------------------------- | --------------------------------------- |
+| `text` | `string` | ✅ | The comment text content. | Max Length: 3000
Max Graphemes: 300 |
+| `createdAt` | `string` | ✅ | Client-declared timestamp when this comment was created. | Format: `datetime` |
+| `video` | `string` | ✅ | AT-URI of the place.stream.video record this comment belongs to. | Format: `at-uri` |
+| `facets` | Array of [`place.stream.richtext.facet`](/lex-reference/place-stream-richtext-facet) | ❌ | Annotations of text (mentions, URLs, etc) | |
+| `reply` | [`#replyRef`](#replyref) | ❌ | | |
+
+---
+
+
+
+### `replyRef`
+
+**Type:** `object`
+
+**Properties:**
+
+| Name | Type | Req'd | Description | Constraints |
+| -------- | -------------------------------------------------------------------------------------------------------------------------------------- | ----- | ----------- | ----------- |
+| `root` | [`com.atproto.repo.strongRef`](https://github.com/bluesky-social/atproto/tree/main/lexicons/com/atproto/repo/strongref.json#undefined) | ✅ | | |
+| `parent` | [`com.atproto.repo.strongRef`](https://github.com/bluesky-social/atproto/tree/main/lexicons/com/atproto/repo/strongref.json#undefined) | ✅ | | |
+
+---
+
+## Lexicon Source
+
+```json
+{
+ "lexicon": 1,
+ "id": "place.stream.vod.comment",
+ "defs": {
+ "main": {
+ "type": "record",
+ "description": "Record containing a comment on a VOD.",
+ "key": "tid",
+ "record": {
+ "type": "object",
+ "required": ["text", "createdAt", "video"],
+ "properties": {
+ "text": {
+ "type": "string",
+ "maxLength": 3000,
+ "maxGraphemes": 300,
+ "description": "The comment text content."
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "datetime",
+ "description": "Client-declared timestamp when this comment was created."
+ },
+ "video": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "AT-URI of the place.stream.video record this comment belongs to."
+ },
+ "facets": {
+ "type": "array",
+ "description": "Annotations of text (mentions, URLs, etc)",
+ "items": {
+ "type": "ref",
+ "ref": "place.stream.richtext.facet"
+ }
+ },
+ "reply": {
+ "type": "ref",
+ "ref": "#replyRef"
+ }
+ }
+ }
+ },
+ "replyRef": {
+ "type": "object",
+ "required": ["root", "parent"],
+ "properties": {
+ "root": {
+ "type": "ref",
+ "ref": "com.atproto.repo.strongRef"
+ },
+ "parent": {
+ "type": "ref",
+ "ref": "com.atproto.repo.strongRef"
+ }
+ }
+ }
+ }
+}
+```
diff --git a/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-defs.md b/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-defs.md
new file mode 100644
index 000000000..f9186b949
--- /dev/null
+++ b/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-defs.md
@@ -0,0 +1,73 @@
+---
+title: place.stream.vod.defs
+description: Reference for the place.stream.vod.defs lexicon
+---
+
+**Lexicon Version:** 1
+
+## Definitions
+
+
+
+### `commentView`
+
+**Type:** `object`
+
+**Properties:**
+
+| Name | Type | Req'd | Description | Constraints |
+| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | -------------------------------- | ------------------ |
+| `uri` | `string` | ✅ | | Format: `at-uri` |
+| `cid` | `string` | ✅ | | Format: `cid` |
+| `author` | [`app.bsky.actor.defs#profileViewBasic`](https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky/actor/defs.json#profileViewBasic) | ✅ | | |
+| `record` | `unknown` | ✅ | | |
+| `indexedAt` | `string` | ✅ | | Format: `datetime` |
+| `replyTo` | Union of:
[`#commentView`](#commentview) | ❌ | | |
+| `likeCount` | `integer` | ✅ | Number of likes on this comment. | Min: 0 |
+
+---
+
+## Lexicon Source
+
+```json
+{
+ "lexicon": 1,
+ "id": "place.stream.vod.defs",
+ "defs": {
+ "commentView": {
+ "type": "object",
+ "required": ["uri", "cid", "author", "record", "indexedAt", "likeCount"],
+ "properties": {
+ "uri": {
+ "type": "string",
+ "format": "at-uri"
+ },
+ "cid": {
+ "type": "string",
+ "format": "cid"
+ },
+ "author": {
+ "type": "ref",
+ "ref": "app.bsky.actor.defs#profileViewBasic"
+ },
+ "record": {
+ "type": "unknown"
+ },
+ "indexedAt": {
+ "type": "string",
+ "format": "datetime"
+ },
+ "replyTo": {
+ "type": "union",
+ "refs": ["#commentView"]
+ },
+ "likeCount": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "Number of likes on this comment."
+ }
+ }
+ }
+ }
+}
+```
diff --git a/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-gate.md b/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-gate.md
new file mode 100644
index 000000000..d34ff1e76
--- /dev/null
+++ b/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-gate.md
@@ -0,0 +1,53 @@
+---
+title: place.stream.vod.gate
+description: Reference for the place.stream.vod.gate lexicon
+---
+
+**Lexicon Version:** 1
+
+## Definitions
+
+
+
+### `main`
+
+**Type:** `record`
+
+Record defining a single gated VOD comment.
+
+**Record Key:** `tid`
+
+**Record Properties:**
+
+| Name | Type | Req'd | Description | Constraints |
+| --------------- | -------- | ----- | ------------------------------ | ---------------- |
+| `hiddenComment` | `string` | ✅ | URI of the hidden VOD comment. | Format: `at-uri` |
+
+---
+
+## Lexicon Source
+
+```json
+{
+ "lexicon": 1,
+ "id": "place.stream.vod.gate",
+ "defs": {
+ "main": {
+ "type": "record",
+ "key": "tid",
+ "description": "Record defining a single gated VOD comment.",
+ "record": {
+ "type": "object",
+ "required": ["hiddenComment"],
+ "properties": {
+ "hiddenComment": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "URI of the hidden VOD comment."
+ }
+ }
+ }
+ }
+ }
+}
+```
diff --git a/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-getcomments.md b/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-getcomments.md
new file mode 100644
index 000000000..469b55f7e
--- /dev/null
+++ b/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-getcomments.md
@@ -0,0 +1,94 @@
+---
+title: place.stream.vod.getComments
+description: Reference for the place.stream.vod.getComments lexicon
+---
+
+**Lexicon Version:** 1
+
+## Definitions
+
+
+
+### `main`
+
+**Type:** `query`
+
+Get comments for a place.stream.video record.
+
+**Parameters:**
+
+| Name | Type | Req'd | Description | Constraints |
+| -------- | --------- | ----- | ---------------------------------------- | ------------------------------------- |
+| `video` | `string` | ✅ | AT-URI of the place.stream.video record. | Format: `at-uri` |
+| `cursor` | `string` | ❌ | Optional pagination cursor. | |
+| `limit` | `integer` | ❌ | Maximum number of comments to return. | Min: 1
Max: 100
Default: `50` |
+
+**Output:**
+
+- **Encoding:** `application/json`
+- **Schema:**
+
+**Schema Type:** `object`
+
+| Name | Type | Req'd | Description | Constraints |
+| ---------- | ------------------------------------------------------------------------------------------------ | ----- | ----------- | ----------- |
+| `cursor` | `string` | ❌ | | |
+| `comments` | Array of [`place.stream.vod.defs#commentView`](/lex-reference/place-stream-vod-defs#commentview) | ✅ | | |
+
+---
+
+## Lexicon Source
+
+```json
+{
+ "lexicon": 1,
+ "id": "place.stream.vod.getComments",
+ "defs": {
+ "main": {
+ "type": "query",
+ "description": "Get comments for a place.stream.video record.",
+ "parameters": {
+ "type": "params",
+ "required": ["video"],
+ "properties": {
+ "video": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "AT-URI of the place.stream.video record."
+ },
+ "cursor": {
+ "type": "string",
+ "description": "Optional pagination cursor."
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100,
+ "default": 50,
+ "description": "Maximum number of comments to return."
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["comments"],
+ "properties": {
+ "cursor": {
+ "type": "string"
+ },
+ "comments": {
+ "type": "array",
+ "items": {
+ "type": "ref",
+ "ref": "place.stream.vod.defs#commentView"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+```
diff --git a/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-getlikes.md b/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-getlikes.md
new file mode 100644
index 000000000..dae716a1e
--- /dev/null
+++ b/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-getlikes.md
@@ -0,0 +1,148 @@
+---
+title: place.stream.vod.getLikes
+description: Reference for the place.stream.vod.getLikes lexicon
+---
+
+**Lexicon Version:** 1
+
+## Definitions
+
+
+
+### `main`
+
+**Type:** `query`
+
+Get likes for a subject (video or comment).
+
+**Parameters:**
+
+| Name | Type | Req'd | Description | Constraints |
+| --------- | --------- | ----- | ----------------------------------------- | ------------------------------------- |
+| `subject` | `string` | ✅ | AT-URI of the subject (video or comment). | Format: `at-uri` |
+| `cursor` | `string` | ❌ | Optional pagination cursor. | |
+| `limit` | `integer` | ❌ | Maximum number of likes to return. | Min: 1
Max: 100
Default: `50` |
+
+**Output:**
+
+- **Encoding:** `application/json`
+- **Schema:**
+
+**Schema Type:** `object`
+
+| Name | Type | Req'd | Description | Constraints |
+| --------- | --------------------------------- | ----- | --------------------------------------- | ---------------- |
+| `subject` | `string` | ✅ | | Format: `at-uri` |
+| `count` | `integer` | ✅ | Total number of likes for this subject. | Min: 0 |
+| `cursor` | `string` | ❌ | | |
+| `likes` | Array of [`#likeView`](#likeview) | ❌ | | |
+
+---
+
+
+
+### `likeView`
+
+**Type:** `object`
+
+**Properties:**
+
+| Name | Type | Req'd | Description | Constraints |
+| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ----------- | ------------------ |
+| `uri` | `string` | ✅ | | Format: `at-uri` |
+| `cid` | `string` | ✅ | | Format: `cid` |
+| `author` | [`app.bsky.actor.defs#profileViewBasic`](https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky/actor/defs.json#profileViewBasic) | ✅ | | |
+| `record` | `unknown` | ✅ | | |
+| `indexedAt` | `string` | ✅ | | Format: `datetime` |
+
+---
+
+## Lexicon Source
+
+```json
+{
+ "lexicon": 1,
+ "id": "place.stream.vod.getLikes",
+ "defs": {
+ "main": {
+ "type": "query",
+ "description": "Get likes for a subject (video or comment).",
+ "parameters": {
+ "type": "params",
+ "required": ["subject"],
+ "properties": {
+ "subject": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "AT-URI of the subject (video or comment)."
+ },
+ "cursor": {
+ "type": "string",
+ "description": "Optional pagination cursor."
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100,
+ "default": 50,
+ "description": "Maximum number of likes to return."
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["subject", "count"],
+ "properties": {
+ "subject": {
+ "type": "string",
+ "format": "at-uri"
+ },
+ "count": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "Total number of likes for this subject."
+ },
+ "cursor": {
+ "type": "string"
+ },
+ "likes": {
+ "type": "array",
+ "items": {
+ "type": "ref",
+ "ref": "#likeView"
+ }
+ }
+ }
+ }
+ }
+ },
+ "likeView": {
+ "type": "object",
+ "required": ["uri", "cid", "author", "record", "indexedAt"],
+ "properties": {
+ "uri": {
+ "type": "string",
+ "format": "at-uri"
+ },
+ "cid": {
+ "type": "string",
+ "format": "cid"
+ },
+ "author": {
+ "type": "ref",
+ "ref": "app.bsky.actor.defs#profileViewBasic"
+ },
+ "record": {
+ "type": "unknown"
+ },
+ "indexedAt": {
+ "type": "string",
+ "format": "datetime"
+ }
+ }
+ }
+ }
+}
+```
diff --git a/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-like.md b/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-like.md
new file mode 100644
index 000000000..65c6bf403
--- /dev/null
+++ b/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-like.md
@@ -0,0 +1,59 @@
+---
+title: place.stream.vod.like
+description: Reference for the place.stream.vod.like lexicon
+---
+
+**Lexicon Version:** 1
+
+## Definitions
+
+
+
+### `main`
+
+**Type:** `record`
+
+Record indicating a like on a VOD or VOD comment.
+
+**Record Key:** `tid`
+
+**Record Properties:**
+
+| Name | Type | Req'd | Description | Constraints |
+| ----------- | -------- | ----- | ------------------------------------------------------------------------------------ | ------------------ |
+| `subject` | `string` | ✅ | AT-URI of the record being liked (a place.stream.video or place.stream.vod.comment). | Format: `at-uri` |
+| `createdAt` | `string` | ✅ | Client-declared timestamp when this like was created. | Format: `datetime` |
+
+---
+
+## Lexicon Source
+
+```json
+{
+ "lexicon": 1,
+ "id": "place.stream.vod.like",
+ "defs": {
+ "main": {
+ "type": "record",
+ "key": "tid",
+ "description": "Record indicating a like on a VOD or VOD comment.",
+ "record": {
+ "type": "object",
+ "required": ["subject", "createdAt"],
+ "properties": {
+ "subject": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "AT-URI of the record being liked (a place.stream.video or place.stream.vod.comment)."
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "datetime",
+ "description": "Client-declared timestamp when this like was created."
+ }
+ }
+ }
+ }
+ }
+}
+```
diff --git a/lexicons/place/stream/moderation/createVodGate.json b/lexicons/place/stream/moderation/createVodGate.json
new file mode 100644
index 000000000..07a029822
--- /dev/null
+++ b/lexicons/place/stream/moderation/createVodGate.json
@@ -0,0 +1,62 @@
+{
+ "lexicon": 1,
+ "id": "place.stream.moderation.createVodGate",
+ "defs": {
+ "main": {
+ "type": "procedure",
+ "description": "Create a gate (hide VOD comment) on behalf of a streamer. Requires 'vod.comment.hide' permission. Creates a place.stream.vod.gate record in the streamer's repository.",
+ "input": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["streamer", "commentUri"],
+ "properties": {
+ "streamer": {
+ "type": "string",
+ "format": "did",
+ "description": "The DID of the streamer."
+ },
+ "commentUri": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "The AT-URI of the VOD comment to hide."
+ }
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["uri", "cid"],
+ "properties": {
+ "uri": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "The AT-URI of the created gate record."
+ },
+ "cid": {
+ "type": "string",
+ "format": "cid",
+ "description": "The CID of the created gate record."
+ }
+ }
+ }
+ },
+ "errors": [
+ {
+ "name": "Unauthorized",
+ "description": "The request lacks valid authentication credentials."
+ },
+ {
+ "name": "Forbidden",
+ "description": "The caller does not have permission to hide VOD comments for this streamer."
+ },
+ {
+ "name": "SessionNotFound",
+ "description": "The streamer's OAuth session could not be found or is invalid."
+ }
+ ]
+ }
+ }
+}
diff --git a/lexicons/place/stream/moderation/deleteVodGate.json b/lexicons/place/stream/moderation/deleteVodGate.json
new file mode 100644
index 000000000..fb371a2ae
--- /dev/null
+++ b/lexicons/place/stream/moderation/deleteVodGate.json
@@ -0,0 +1,50 @@
+{
+ "lexicon": 1,
+ "id": "place.stream.moderation.deleteVodGate",
+ "defs": {
+ "main": {
+ "type": "procedure",
+ "description": "Delete a gate (unhide VOD comment) on behalf of a streamer. Requires 'vod.comment.hide' permission. Deletes a place.stream.vod.gate record from the streamer's repository.",
+ "input": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["streamer", "gateUri"],
+ "properties": {
+ "streamer": {
+ "type": "string",
+ "format": "did",
+ "description": "The DID of the streamer."
+ },
+ "gateUri": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "The AT-URI of the gate record to delete."
+ }
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "properties": {}
+ }
+ },
+ "errors": [
+ {
+ "name": "Unauthorized",
+ "description": "The request lacks valid authentication credentials."
+ },
+ {
+ "name": "Forbidden",
+ "description": "The caller does not have permission to unhide VOD comments for this streamer."
+ },
+ {
+ "name": "SessionNotFound",
+ "description": "The streamer's OAuth session could not be found or is invalid."
+ }
+ ]
+ }
+ }
+}
diff --git a/lexicons/place/stream/moderation/permission.json b/lexicons/place/stream/moderation/permission.json
index 68a3d3cec..d269ed995 100644
--- a/lexicons/place/stream/moderation/permission.json
+++ b/lexicons/place/stream/moderation/permission.json
@@ -19,7 +19,13 @@
"type": "array",
"items": {
"type": "string",
- "enum": ["ban", "hide", "livestream.manage", "message.pin"]
+ "enum": [
+ "ban",
+ "hide",
+ "livestream.manage",
+ "message.pin",
+ "vod.comment.hide"
+ ]
},
"description": "Array of permissions granted to this moderator. 'ban' covers blocks/bans (with optional expiration), 'hide' covers message gates, 'livestream.manage' allows updating livestream metadata."
},
diff --git a/lexicons/place/stream/vod/comment.json b/lexicons/place/stream/vod/comment.json
new file mode 100644
index 000000000..4a2ea391a
--- /dev/null
+++ b/lexicons/place/stream/vod/comment.json
@@ -0,0 +1,56 @@
+{
+ "lexicon": 1,
+ "id": "place.stream.vod.comment",
+ "defs": {
+ "main": {
+ "type": "record",
+ "description": "Record containing a comment on a VOD.",
+ "key": "tid",
+ "record": {
+ "type": "object",
+ "required": ["text", "createdAt", "video"],
+ "properties": {
+ "text": {
+ "type": "string",
+ "maxLength": 3000,
+ "maxGraphemes": 300,
+ "description": "The comment text content."
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "datetime",
+ "description": "Client-declared timestamp when this comment was created."
+ },
+ "video": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "AT-URI of the place.stream.video record this comment belongs to."
+ },
+ "facets": {
+ "type": "array",
+ "description": "Annotations of text (mentions, URLs, etc)",
+ "items": { "type": "ref", "ref": "place.stream.richtext.facet" }
+ },
+ "reply": {
+ "type": "ref",
+ "ref": "#replyRef"
+ }
+ }
+ }
+ },
+ "replyRef": {
+ "type": "object",
+ "required": ["root", "parent"],
+ "properties": {
+ "root": {
+ "type": "ref",
+ "ref": "com.atproto.repo.strongRef"
+ },
+ "parent": {
+ "type": "ref",
+ "ref": "com.atproto.repo.strongRef"
+ }
+ }
+ }
+ }
+}
diff --git a/lexicons/place/stream/vod/defs.json b/lexicons/place/stream/vod/defs.json
new file mode 100644
index 000000000..fd0c285a6
--- /dev/null
+++ b/lexicons/place/stream/vod/defs.json
@@ -0,0 +1,29 @@
+{
+ "lexicon": 1,
+ "id": "place.stream.vod.defs",
+ "defs": {
+ "commentView": {
+ "type": "object",
+ "required": ["uri", "cid", "author", "record", "indexedAt", "likeCount"],
+ "properties": {
+ "uri": { "type": "string", "format": "at-uri" },
+ "cid": { "type": "string", "format": "cid" },
+ "author": {
+ "type": "ref",
+ "ref": "app.bsky.actor.defs#profileViewBasic"
+ },
+ "record": { "type": "unknown" },
+ "indexedAt": { "type": "string", "format": "datetime" },
+ "replyTo": {
+ "type": "union",
+ "refs": ["#commentView"]
+ },
+ "likeCount": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "Number of likes on this comment."
+ }
+ }
+ }
+ }
+}
diff --git a/lexicons/place/stream/vod/gate.json b/lexicons/place/stream/vod/gate.json
new file mode 100644
index 000000000..e996e4aff
--- /dev/null
+++ b/lexicons/place/stream/vod/gate.json
@@ -0,0 +1,22 @@
+{
+ "lexicon": 1,
+ "id": "place.stream.vod.gate",
+ "defs": {
+ "main": {
+ "type": "record",
+ "key": "tid",
+ "description": "Record defining a single gated VOD comment.",
+ "record": {
+ "type": "object",
+ "required": ["hiddenComment"],
+ "properties": {
+ "hiddenComment": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "URI of the hidden VOD comment."
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/lexicons/place/stream/vod/getComments.json b/lexicons/place/stream/vod/getComments.json
new file mode 100644
index 000000000..e17c99502
--- /dev/null
+++ b/lexicons/place/stream/vod/getComments.json
@@ -0,0 +1,49 @@
+{
+ "lexicon": 1,
+ "id": "place.stream.vod.getComments",
+ "defs": {
+ "main": {
+ "type": "query",
+ "description": "Get comments for a place.stream.video record.",
+ "parameters": {
+ "type": "params",
+ "required": ["video"],
+ "properties": {
+ "video": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "AT-URI of the place.stream.video record."
+ },
+ "cursor": {
+ "type": "string",
+ "description": "Optional pagination cursor."
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100,
+ "default": 50,
+ "description": "Maximum number of comments to return."
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["comments"],
+ "properties": {
+ "cursor": { "type": "string" },
+ "comments": {
+ "type": "array",
+ "items": {
+ "type": "ref",
+ "ref": "place.stream.vod.defs#commentView"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/lexicons/place/stream/vod/getLikes.json b/lexicons/place/stream/vod/getLikes.json
new file mode 100644
index 000000000..b63c64fca
--- /dev/null
+++ b/lexicons/place/stream/vod/getLikes.json
@@ -0,0 +1,69 @@
+{
+ "lexicon": 1,
+ "id": "place.stream.vod.getLikes",
+ "defs": {
+ "main": {
+ "type": "query",
+ "description": "Get likes for a subject (video or comment).",
+ "parameters": {
+ "type": "params",
+ "required": ["subject"],
+ "properties": {
+ "subject": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "AT-URI of the subject (video or comment)."
+ },
+ "cursor": {
+ "type": "string",
+ "description": "Optional pagination cursor."
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100,
+ "default": 50,
+ "description": "Maximum number of likes to return."
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["subject", "count"],
+ "properties": {
+ "subject": { "type": "string", "format": "at-uri" },
+ "count": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "Total number of likes for this subject."
+ },
+ "cursor": { "type": "string" },
+ "likes": {
+ "type": "array",
+ "items": {
+ "type": "ref",
+ "ref": "#likeView"
+ }
+ }
+ }
+ }
+ }
+ },
+ "likeView": {
+ "type": "object",
+ "required": ["uri", "cid", "author", "record", "indexedAt"],
+ "properties": {
+ "uri": { "type": "string", "format": "at-uri" },
+ "cid": { "type": "string", "format": "cid" },
+ "author": {
+ "type": "ref",
+ "ref": "app.bsky.actor.defs#profileViewBasic"
+ },
+ "record": { "type": "unknown" },
+ "indexedAt": { "type": "string", "format": "datetime" }
+ }
+ }
+ }
+}
diff --git a/lexicons/place/stream/vod/like.json b/lexicons/place/stream/vod/like.json
new file mode 100644
index 000000000..516327211
--- /dev/null
+++ b/lexicons/place/stream/vod/like.json
@@ -0,0 +1,27 @@
+{
+ "lexicon": 1,
+ "id": "place.stream.vod.like",
+ "defs": {
+ "main": {
+ "type": "record",
+ "key": "tid",
+ "description": "Record indicating a like on a VOD or VOD comment.",
+ "record": {
+ "type": "object",
+ "required": ["subject", "createdAt"],
+ "properties": {
+ "subject": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "AT-URI of the record being liked (a place.stream.video or place.stream.vod.comment)."
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "datetime",
+ "description": "Client-declared timestamp when this like was created."
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/mise.toml b/mise.toml
index c5c08b9c0..107d7636e 100644
--- a/mise.toml
+++ b/mise.toml
@@ -5,3 +5,4 @@ node = "22"
meson = "latest"
ninja = "latest"
pnpm = "latest"
+java = "17"
diff --git a/pkg/atproto/firehose.go b/pkg/atproto/firehose.go
index 36df1e56e..9867cd23b 100644
--- a/pkg/atproto/firehose.go
+++ b/pkg/atproto/firehose.go
@@ -405,6 +405,27 @@ func (atsync *ATProtoSynchronizer) handleCommitEventOps(ctx context.Context, evt
}
}
+ if collection.String() == constants.PLACE_STREAM_VOD_COMMENT {
+ log.Debug(ctx, "deleting VOD comment", "uri", uri)
+ if err := atsync.Model.DeleteVodComment(ctx, uri, &opTime); err != nil {
+ log.Error(ctx, "failed to delete VOD comment", "err", err)
+ }
+ }
+
+ if collection.String() == constants.PLACE_STREAM_VOD_LIKE {
+ log.Debug(ctx, "deleting VOD like", "uri", uri)
+ if err := atsync.Model.DeleteVodLike(ctx, uri); err != nil {
+ log.Error(ctx, "failed to delete VOD like", "err", err)
+ }
+ }
+
+ if collection.String() == constants.PLACE_STREAM_VOD_GATE {
+ log.Debug(ctx, "deleting VOD gate", "userDID", evt.Repo, "rkey", rkey.String())
+ if err := atsync.Model.DeleteVodGate(ctx, rkey.String()); err != nil {
+ log.Error(ctx, "failed to delete VOD gate", "err", err)
+ }
+ }
+
default:
log.Error(ctx, "unexpected record op kind")
}
diff --git a/pkg/atproto/sync.go b/pkg/atproto/sync.go
index b45e677c4..7649394e4 100644
--- a/pkg/atproto/sync.go
+++ b/pkg/atproto/sync.go
@@ -781,6 +781,133 @@ func (atsync *ATProtoSynchronizer) handleCreateUpdate(ctx context.Context, userD
log.Debug(ctx, "indexed media view count",
"uri", aturi.String(), "video", rec.Video, "count", rec.Count, "reporter", userDID)
+ case *streamplace.VodComment:
+ repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID)
+ if err != nil {
+ return fmt.Errorf("failed to sync bluesky repo: %w", err)
+ }
+
+ log.Debug(ctx, "place.stream.vod.comment detected", "video", rec.Video, "repo", repo.Handle)
+
+ // Check if the video author has blocked the commenter
+ videoATURI, parseErr := syntax.ParseATURI(rec.Video)
+ var videoAuthor string
+ if parseErr == nil {
+ videoAuthor = videoATURI.Authority().String()
+ block, err := atsync.Model.GetUserBlock(ctx, videoAuthor, userDID)
+ if err != nil {
+ log.Warn(ctx, "failed to check user block for VOD comment", "err", err)
+ } else if block != nil {
+ log.Debug(ctx, "excluding VOD comment from blocked user", "userDID", userDID, "videoAuthor", videoAuthor)
+ return nil
+ }
+ } else {
+ log.Warn(ctx, "failed to parse video URI for block check", "video", rec.Video, "err", err)
+ }
+
+ vc := &model.VodComment{
+ CID: cid,
+ URI: aturi.String(),
+ CreatedAt: now,
+ Comment: recCBOR,
+ RepoDID: userDID,
+ Repo: repo,
+ VideoURI: rec.Video,
+ VideoAuthorDID: videoAuthor,
+ IndexedAt: &now,
+ }
+ if rec.Reply != nil && rec.Reply.Parent != nil && rec.Reply.Root != nil {
+ vc.ReplyToCID = &rec.Reply.Parent.Cid
+ }
+
+ // check for javascript: links in facets
+ for _, facet := range rec.Facets {
+ for _, feature := range facet.Features {
+ if link := feature.RichtextFacet_Link; link != nil {
+ if link.Uri != "" && strings.HasPrefix(strings.ToLower(link.Uri), "javascript:") {
+ log.Warn(ctx, "excluding comment with javascript: link", "uri", aturi.String(), "link", link.Uri)
+ return nil
+ }
+ }
+ }
+ }
+
+ err = atsync.Model.CreateVodComment(ctx, vc)
+ if err != nil {
+ log.Error(ctx, "failed to create VOD comment", "err", err)
+ return nil
+ }
+ vc, err = atsync.Model.GetVodComment(aturi.String())
+ if err != nil {
+ log.Error(ctx, "failed to get just-saved VOD comment", "err", err)
+ return nil
+ }
+ if vc == nil {
+ log.Error(ctx, "failed to retrieve just-saved VOD comment")
+ return nil
+ }
+ sc, err := vc.ToStreamplaceCommentView()
+ if err != nil {
+ log.Error(ctx, "failed to convert VOD comment to view", "err", err)
+ return nil
+ }
+
+ if sc.Author.Handle == "" || sc.Author.Handle == "handle.invalid" {
+ sc.Author.Handle = atsync.ResolveAuthorHandle(ctx, sc.Author.Did)
+ }
+
+ if videoAuthor != "" {
+ go atsync.Bus.Publish(videoAuthor, sc)
+ } else {
+ go atsync.Bus.Publish(userDID, sc)
+ }
+
+ case *streamplace.VodLike:
+ repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID)
+ if err != nil {
+ return fmt.Errorf("failed to sync bluesky repo: %w", err)
+ }
+
+ log.Debug(ctx, "place.stream.vod.like detected", "subject", rec.Subject, "repo", repo.Handle)
+
+ like := &model.VodLike{
+ CID: cid,
+ URI: aturi.String(),
+ Subject: rec.Subject,
+ RepoDID: userDID,
+ Repo: repo,
+ IndexedAt: &now,
+ CreatedAt: now,
+ }
+ err = atsync.Model.CreateVodLike(ctx, like)
+ if err != nil {
+ log.Error(ctx, "failed to create VOD like", "err", err)
+ return nil
+ }
+
+ case *streamplace.VodGate:
+ repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID)
+ if err != nil {
+ return fmt.Errorf("failed to sync bluesky repo: %w", err)
+ }
+ if r == nil {
+ // someone we don't know about
+ return nil
+ }
+ log.Debug(ctx, "creating VOD gate", "userDID", userDID, "hiddenComment", rec.HiddenComment)
+ gate := &model.VodGate{
+ RKey: rkey.String(),
+ RepoDID: userDID,
+ HiddenComment: rec.HiddenComment,
+ CID: cid,
+ CreatedAt: now,
+ Repo: repo,
+ }
+ err = atsync.Model.CreateVodGate(ctx, gate)
+ if err != nil {
+ return fmt.Errorf("failed to create VOD gate: %w", err)
+ }
+
default:
log.Debug(ctx, "unhandled record type", "type", reflect.TypeOf(rec))
}
diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go
index 50a158712..6880168c6 100644
--- a/pkg/constants/constants.go
+++ b/pkg/constants/constants.go
@@ -26,6 +26,9 @@ var PLACE_STREAM_MEDIA_TRACK = "place.stream.media.track" //
var PLACE_STREAM_MEDIA_ORIGIN = "place.stream.media.origin" //nolint:all
var PLACE_STREAM_MEDIA_VIEW_COUNT = "place.stream.media.viewCount" //nolint:all
var PLACE_STREAM_BETA_INVITE = "place.stream.beta.invite" //nolint:all
+var PLACE_STREAM_VOD_COMMENT = "place.stream.vod.comment" //nolint:all
+var PLACE_STREAM_VOD_LIKE = "place.stream.vod.like" //nolint:all
+var PLACE_STREAM_VOD_GATE = "place.stream.vod.gate" //nolint:all
// Streamplace badge types
const (
diff --git a/pkg/gen/gen.go b/pkg/gen/gen.go
index 822b88bcc..a108d3000 100644
--- a/pkg/gen/gen.go
+++ b/pkg/gen/gen.go
@@ -56,6 +56,10 @@ func main() {
streamplace.MediaViewCount_TrackUsage{},
streamplace.Video_Connection{},
streamplace.BetaInvite{},
+ streamplace.VodComment{},
+ streamplace.VodComment_ReplyRef{},
+ streamplace.VodLike{},
+ streamplace.VodGate{},
); err != nil {
panic(err)
}
diff --git a/pkg/model/model.go b/pkg/model/model.go
index 98a6fa351..ddc01182d 100644
--- a/pkg/model/model.go
+++ b/pkg/model/model.go
@@ -157,8 +157,27 @@ type Model interface {
GetMediaViewCountByURI(ctx context.Context, uri string) (*streamplace.MediaViewCount, error)
GetVideoView(ctx context.Context, uri string) (*streamplace.MediaGetVideo_VideoView, error)
GetVideoList(ctx context.Context, repoDID string, limit int, cursor string) (*streamplace.MediaGetVideoList_Output, error)
+
+ CreateVodComment(ctx context.Context, comment *VodComment) error
+ DeleteVodComment(ctx context.Context, uri string, deletedAt *time.Time) error
+ GetVodComment(uri string) (*VodComment, error)
+ GetCommentsForVideo(ctx context.Context, videoURI string, limit int, cursor *time.Time) ([]*streamplace.VodDefs_CommentView, *time.Time, error)
+
+ CreateVodLike(ctx context.Context, like *VodLike) error
+ DeleteVodLike(ctx context.Context, uri string) error
+ GetVodLike(uri string) (*VodLike, error)
+ GetVodLikeBySubjectAndUser(ctx context.Context, subject string, repoDID string) (*VodLike, error)
+ GetLikesForSubject(ctx context.Context, subject string, limit int, cursor *time.Time) ([]*streamplace.VodGetLikes_LikeView, int64, *time.Time, error)
+ GetLikeCount(ctx context.Context, subject string) (int64, error)
+
+ CreateVodGate(ctx context.Context, gate *VodGate) error
+ DeleteVodGate(ctx context.Context, rkey string) error
+ GetVodGate(ctx context.Context, rkey string) (*VodGate, error)
+ GetUserVodGates(ctx context.Context, userDID string) ([]*VodGate, error)
}
+// DO NOT UPDATE THIS UNLESS A BREAKING CHANGE IS MADE
+// WHICH ALSO SHOULD NOT HAPPEN
var DBRevision = 4
func MakeDB(dbURL string) (Model, error) {
@@ -235,6 +254,9 @@ func MakeDB(dbURL string) (Model, error) {
MediaOrigin{},
MediaViewCount{},
BetaInvite{},
+ VodComment{},
+ VodLike{},
+ VodGate{},
} {
err = db.AutoMigrate(model)
if err != nil {
diff --git a/pkg/model/vod_comment.go b/pkg/model/vod_comment.go
new file mode 100644
index 000000000..c9ff77884
--- /dev/null
+++ b/pkg/model/vod_comment.go
@@ -0,0 +1,141 @@
+package model
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/bluesky-social/indigo/api/bsky"
+ lexutil "github.com/bluesky-social/indigo/lex/util"
+ "github.com/rivo/uniseg"
+ "gorm.io/gorm"
+ "stream.place/streamplace/pkg/streamplace"
+)
+
+type VodComment struct {
+ CID string `json:"cid" gorm:"primaryKey;column:cid"`
+ URI string `json:"uri" gorm:"column:uri"`
+ CreatedAt time.Time `json:"createdAt" gorm:"column:created_at;index:idx_vod_comments_video,priority:2"`
+ Comment *[]byte `json:"comment" gorm:"column:comment"`
+ RepoDID string `json:"repoDID" gorm:"column:repo_did"`
+ Repo *Repo `json:"repo,omitempty" gorm:"foreignKey:DID;references:RepoDID"`
+ IndexedAt *time.Time `json:"indexedAt" gorm:"column:indexed_at"`
+ VideoURI string `json:"videoURI" gorm:"column:video_uri;index:idx_vod_comments_video,priority:1"`
+ VideoAuthorDID string `json:"videoAuthorDID" gorm:"column:video_author_did"`
+ ReplyToCID *string `json:"replyToCID" gorm:"column:reply_to_cid"`
+ ReplyTo *VodComment `json:"replyTo,omitempty" gorm:"foreignKey:ReplyToCID;references:CID"`
+ DeletedAt *time.Time `json:"deletedAt" gorm:"column:deleted_at"`
+}
+
+func (c *VodComment) ToStreamplaceCommentView() (*streamplace.VodDefs_CommentView, error) {
+ rec, err := lexutil.CborDecodeValue(*c.Comment)
+ if err != nil {
+ return nil, fmt.Errorf("error decoding comment: %w", err)
+ }
+ if msg, ok := rec.(*streamplace.VodComment); ok {
+ graphemeCount := uniseg.GraphemeClusterCount(msg.Text)
+ if graphemeCount > 300 {
+ gr := uniseg.NewGraphemes(msg.Text)
+ var result strings.Builder
+ for count := 0; count < 300 && gr.Next(); count++ {
+ result.WriteString(gr.Str())
+ }
+ msg.Text = result.String()
+ }
+ }
+ commentView := &streamplace.VodDefs_CommentView{
+ LexiconTypeID: "place.stream.vod.defs#commentView",
+ }
+ commentView.Uri = c.URI
+ commentView.Cid = c.CID
+ commentView.Author = &bsky.ActorDefs_ProfileViewBasic{Did: c.RepoDID}
+ if c.Repo != nil {
+ commentView.Author.Handle = c.Repo.Handle
+ }
+ commentView.Record = &lexutil.LexiconTypeDecoder{Val: rec}
+ commentView.IndexedAt = c.IndexedAt.UTC().Format(time.RFC3339Nano)
+ commentView.LikeCount = 0
+ if c.ReplyTo != nil {
+ replyTo, err := c.ReplyTo.ToStreamplaceCommentView()
+ if err != nil {
+ return nil, fmt.Errorf("error converting reply to comment view: %w", err)
+ }
+ commentView.ReplyTo = &streamplace.VodDefs_CommentView_ReplyTo{
+ VodDefs_CommentView: replyTo,
+ }
+ }
+ return commentView, nil
+}
+
+func (m *DBModel) CreateVodComment(ctx context.Context, comment *VodComment) error {
+ return m.DB.Create(comment).Error
+}
+
+func (m *DBModel) DeleteVodComment(ctx context.Context, uri string, deletedAt *time.Time) error {
+ tx := m.DB.Model(&VodComment{}).Where("uri = ?", uri).Update("deleted_at", deletedAt)
+ if tx.Error != nil {
+ return tx.Error
+ }
+ if tx.RowsAffected == 0 {
+ return fmt.Errorf("no VOD comment found for uri: %s", uri)
+ }
+ return nil
+}
+
+func (m *DBModel) GetVodComment(uri string) (*VodComment, error) {
+ var comment VodComment
+ err := m.DB.
+ Preload("Repo").
+ Preload("ReplyTo").
+ Preload("ReplyTo.Repo").
+ Where("uri = ?", uri).
+ Where("deleted_at IS NULL").
+ First(&comment).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("error retrieving VOD comment: %w", err)
+ }
+ return &comment, nil
+}
+
+func (m *DBModel) GetCommentsForVideo(ctx context.Context, videoURI string, limit int, cursor *time.Time) ([]*streamplace.VodDefs_CommentView, *time.Time, error) {
+ dbcomments := []VodComment{}
+ query := m.DB.
+ Preload("Repo").
+ Preload("ReplyTo").
+ Preload("ReplyTo.Repo").
+ Where("video_uri = ?", videoURI).
+ Where("vod_comments.deleted_at IS NULL").
+ Joins("LEFT JOIN blocks ON blocks.repo_did = vod_comments.video_author_did AND blocks.subject_did = vod_comments.repo_did").
+ Where("blocks.rkey IS NULL").
+ Joins("LEFT JOIN vod_gates ON vod_gates.hidden_comment = vod_comments.uri").
+ Where("vod_gates.rkey IS NULL")
+ if cursor != nil {
+ query = query.Where("vod_comments.created_at < ?", *cursor)
+ }
+ err := query.
+ Limit(limit + 1).
+ Order("vod_comments.created_at DESC").
+ Find(&dbcomments).Error
+ if err != nil {
+ return nil, nil, fmt.Errorf("error retrieving comments: %w", err)
+ }
+ var nextCursor *time.Time
+ if len(dbcomments) > limit {
+ nextCursor = &dbcomments[limit-1].CreatedAt
+ dbcomments = dbcomments[:limit]
+ }
+ spcomments := []*streamplace.VodDefs_CommentView{}
+ for _, c := range dbcomments {
+ spcomment, err := c.ToStreamplaceCommentView()
+ if err != nil {
+ return nil, nil, fmt.Errorf("error converting comment to view: %w", err)
+ }
+ spcomments = append(spcomments, spcomment)
+ }
+ return spcomments, nextCursor, nil
+}
diff --git a/pkg/model/vod_gate.go b/pkg/model/vod_gate.go
new file mode 100644
index 000000000..286da3543
--- /dev/null
+++ b/pkg/model/vod_gate.go
@@ -0,0 +1,55 @@
+package model
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ "gorm.io/gorm"
+ "stream.place/streamplace/pkg/streamplace"
+)
+
+type VodGate struct {
+ RKey string `gorm:"primaryKey;column:rkey"`
+ CID string `gorm:"column:cid"`
+ RepoDID string `json:"repoDID" gorm:"column:repo_did"`
+ Repo *Repo `json:"repo,omitempty" gorm:"foreignKey:DID;references:RepoDID"`
+ HiddenComment string `gorm:"column:hidden_comment" json:"hiddenComment"`
+ CreatedAt time.Time `gorm:"column:created_at"`
+}
+
+func (g *VodGate) ToStreamplaceVodGate() (*streamplace.VodGate, error) {
+ return &streamplace.VodGate{
+ LexiconTypeID: "place.stream.vod.gate",
+ HiddenComment: g.HiddenComment,
+ }, nil
+}
+
+func (m *DBModel) CreateVodGate(ctx context.Context, gate *VodGate) error {
+ return m.DB.Create(gate).Error
+}
+
+func (m *DBModel) GetVodGate(ctx context.Context, rkey string) (*VodGate, error) {
+ var gate VodGate
+ err := m.DB.Preload("Repo").Where("rkey = ?", rkey).First(&gate).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &gate, nil
+}
+
+func (m *DBModel) DeleteVodGate(ctx context.Context, rkey string) error {
+ return m.DB.Where("rkey = ?", rkey).Delete(&VodGate{}).Error
+}
+
+func (m *DBModel) GetUserVodGates(ctx context.Context, userDID string) ([]*VodGate, error) {
+ var gates []*VodGate
+ err := m.DB.Where("repo_did = ?", userDID).Find(&gates).Error
+ if err != nil {
+ return nil, err
+ }
+ return gates, nil
+}
diff --git a/pkg/model/vod_like.go b/pkg/model/vod_like.go
new file mode 100644
index 000000000..a45b26366
--- /dev/null
+++ b/pkg/model/vod_like.go
@@ -0,0 +1,119 @@
+package model
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/bluesky-social/indigo/api/bsky"
+ lexutil "github.com/bluesky-social/indigo/lex/util"
+ "gorm.io/gorm"
+ "stream.place/streamplace/pkg/streamplace"
+)
+
+type VodLike struct {
+ CID string `json:"cid" gorm:"primaryKey;column:cid"`
+ URI string `json:"uri" gorm:"column:uri"`
+ Subject string `json:"subject" gorm:"column:subject;index:idx_vod_likes_subject"`
+ RepoDID string `json:"repoDID" gorm:"column:repo_did"`
+ Repo *Repo `json:"repo,omitempty" gorm:"foreignKey:DID;references:RepoDID"`
+ IndexedAt *time.Time `json:"indexedAt" gorm:"column:indexed_at"`
+ CreatedAt time.Time `json:"createdAt" gorm:"column:created_at"`
+}
+
+func (l *VodLike) ToStreamplaceLikeView() (*streamplace.VodGetLikes_LikeView, error) {
+ rec, err := lexutil.CborDecodeValue([]byte{})
+ if err != nil {
+ rec = &streamplace.VodLike{Subject: l.Subject, CreatedAt: l.CreatedAt.Format(time.RFC3339)}
+ }
+ return &streamplace.VodGetLikes_LikeView{
+ Uri: l.URI,
+ Cid: l.CID,
+ Record: &lexutil.LexiconTypeDecoder{Val: rec},
+ Author: &bsky.ActorDefs_ProfileViewBasic{
+ Did: l.RepoDID,
+ Handle: func() string {
+ if l.Repo != nil {
+ return l.Repo.Handle
+ }
+ return ""
+ }(),
+ },
+ IndexedAt: l.IndexedAt.UTC().Format(time.RFC3339Nano),
+ }, nil
+}
+
+func (m *DBModel) CreateVodLike(ctx context.Context, like *VodLike) error {
+ return m.DB.Create(like).Error
+}
+
+func (m *DBModel) DeleteVodLike(ctx context.Context, uri string) error {
+ tx := m.DB.Where("uri = ?", uri).Delete(&VodLike{})
+ if tx.Error != nil {
+ return tx.Error
+ }
+ return nil
+}
+
+func (m *DBModel) GetVodLike(uri string) (*VodLike, error) {
+ var like VodLike
+ err := m.DB.Preload("Repo").Where("uri = ?", uri).First(&like).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &like, nil
+}
+
+func (m *DBModel) GetVodLikeBySubjectAndUser(ctx context.Context, subject string, repoDID string) (*VodLike, error) {
+ var like VodLike
+ err := m.DB.Preload("Repo").Where("subject = ? AND repo_did = ?", subject, repoDID).First(&like).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, nil
+ }
+ return &like, nil
+}
+
+func (m *DBModel) GetLikesForSubject(ctx context.Context, subject string, limit int, cursor *time.Time) ([]*streamplace.VodGetLikes_LikeView, int64, *time.Time, error) {
+ count, err := m.GetLikeCount(ctx, subject)
+ if err != nil {
+ return nil, 0, nil, err
+ }
+ dblikes := []VodLike{}
+ query := m.DB.
+ Preload("Repo").
+ Where("subject = ?", subject)
+ if cursor != nil {
+ query = query.Where("vod_likes.created_at < ?", *cursor)
+ }
+ err = query.
+ Limit(limit + 1).
+ Order("vod_likes.created_at DESC").
+ Find(&dblikes).Error
+ if err != nil {
+ return nil, 0, nil, fmt.Errorf("error retrieving likes: %w", err)
+ }
+ var nextCursor *time.Time
+ if len(dblikes) > limit {
+ nextCursor = &dblikes[limit-1].CreatedAt
+ dblikes = dblikes[:limit]
+ }
+ views := []*streamplace.VodGetLikes_LikeView{}
+ for _, l := range dblikes {
+ view, err := l.ToStreamplaceLikeView()
+ if err != nil {
+ return nil, 0, nil, err
+ }
+ views = append(views, view)
+ }
+ return views, count, nextCursor, nil
+}
+
+func (m *DBModel) GetLikeCount(ctx context.Context, subject string) (int64, error) {
+ var count int64
+ err := m.DB.Model(&VodLike{}).Where("subject = ?", subject).Count(&count).Error
+ return count, err
+}
diff --git a/pkg/moderation/permissions.go b/pkg/moderation/permissions.go
index 92f2b8e79..47d83efa4 100644
--- a/pkg/moderation/permissions.go
+++ b/pkg/moderation/permissions.go
@@ -18,6 +18,7 @@ const (
PermissionHide = "hide"
PermissionLivestreamManage = "livestream.manage"
PermissionMessagePin = "message.pin"
+ PermissionVodCommentHide = "vod.comment.hide"
)
// ActionPermissions maps moderation actions to required permissions
@@ -26,6 +27,8 @@ var ActionPermissions = map[string]string{
"deleteBlock": PermissionBan,
"createGate": PermissionHide,
"deleteGate": PermissionHide,
+ "createVodGate": PermissionVodCommentHide,
+ "deleteVodGate": PermissionVodCommentHide,
"updateLivestream": PermissionLivestreamManage,
"createPin": PermissionMessagePin,
"deletePin": PermissionMessagePin,
diff --git a/pkg/spxrpc/place_stream_moderation.go b/pkg/spxrpc/place_stream_moderation.go
index f621f8e31..0bd453c8f 100644
--- a/pkg/spxrpc/place_stream_moderation.go
+++ b/pkg/spxrpc/place_stream_moderation.go
@@ -503,3 +503,99 @@ func (s *Server) handlePlaceStreamModerationDeletePin(ctx context.Context, input
return &streamplace.ModerationDeletePin_Output{}, nil
}
+
+// handlePlaceStreamModerationCreateVodGate creates a gate (hide VOD comment) on behalf of a streamer
+func (s *Server) handlePlaceStreamModerationCreateVodGate(ctx context.Context, input *streamplace.ModerationCreateVodGate_Input) (*streamplace.ModerationCreateVodGate_Output, error) {
+ // Validate input
+ if err := validateDID(input.Streamer); err != nil {
+ return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid streamer DID: %v", err))
+ }
+ if err := validateATURI(input.CommentUri); err != nil {
+ return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid comment URI: %v", err))
+ }
+
+ // Get delegated moderation context (validates OAuth, permission, and returns client)
+ modCtx, err := s.GetDelegatedModerationContext(ctx, input.Streamer, "createVodGate")
+ if err != nil {
+ return nil, err
+ }
+
+ // Create gate record in streamer's repo
+ gate := &streamplace.VodGate{
+ HiddenComment: input.CommentUri,
+ }
+
+ createInput := comatproto.RepoCreateRecord_Input{
+ Collection: constants.PLACE_STREAM_VOD_GATE,
+ Record: &lexutil.LexiconTypeDecoder{Val: gate},
+ Repo: input.Streamer,
+ }
+ createOutput := comatproto.RepoCreateRecord_Output{}
+
+ err = modCtx.StreamerClient.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.createRecord", map[string]any{}, createInput, &createOutput)
+ if err != nil {
+ log.Error(ctx, "failed to create VOD gate record", "err", err)
+ if auditErr := s.logAudit(ctx, input.Streamer, modCtx.ModeratorDID, "createVodGate", input.CommentUri, "", "", false, err.Error()); auditErr != nil {
+ log.Error(ctx, "failed to create audit log", "error", auditErr)
+ }
+ return nil, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create VOD gate: %v", err))
+ }
+
+ // Log successful audit entry
+ if err := s.logAudit(ctx, input.Streamer, modCtx.ModeratorDID, "createVodGate", input.CommentUri, "", createOutput.Uri, true, ""); err != nil {
+ log.Error(ctx, "failed to create audit log", "error", err)
+ }
+
+ return &streamplace.ModerationCreateVodGate_Output{
+ Uri: createOutput.Uri,
+ Cid: createOutput.Cid,
+ }, nil
+}
+
+// handlePlaceStreamModerationDeleteVodGate deletes a gate (unhide VOD comment) on behalf of a streamer
+func (s *Server) handlePlaceStreamModerationDeleteVodGate(ctx context.Context, input *streamplace.ModerationDeleteVodGate_Input) (*streamplace.ModerationDeleteVodGate_Output, error) {
+ // Validate input
+ if err := validateDID(input.Streamer); err != nil {
+ return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid streamer DID: %v", err))
+ }
+ if err := validateATURI(input.GateUri); err != nil {
+ return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid gate URI: %v", err))
+ }
+
+ // Get delegated moderation context (validates OAuth, permission, and returns client)
+ modCtx, err := s.GetDelegatedModerationContext(ctx, input.Streamer, "deleteVodGate")
+ if err != nil {
+ return nil, err
+ }
+
+ // Parse gateUri to extract rkey
+ rkey, err := extractRKey(input.GateUri)
+ if err != nil {
+ log.Error(ctx, "failed to extract rkey from gateUri", "uri", input.GateUri, "err", err)
+ return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid gateUri format")
+ }
+
+ // Delete gate record from streamer's repo
+ deleteInput := comatproto.RepoDeleteRecord_Input{
+ Collection: constants.PLACE_STREAM_VOD_GATE,
+ Rkey: rkey,
+ Repo: input.Streamer,
+ }
+ deleteOutput := comatproto.RepoDeleteRecord_Output{}
+
+ err = modCtx.StreamerClient.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.deleteRecord", map[string]any{}, deleteInput, &deleteOutput)
+ if err != nil {
+ log.Error(ctx, "failed to delete VOD gate record", "err", err)
+ if auditErr := s.logAudit(ctx, input.Streamer, modCtx.ModeratorDID, "deleteVodGate", input.GateUri, "", "", false, err.Error()); auditErr != nil {
+ log.Error(ctx, "failed to create audit log", "error", auditErr)
+ }
+ return nil, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to delete VOD gate: %v", err))
+ }
+
+ // Log successful audit entry
+ if err := s.logAudit(ctx, input.Streamer, modCtx.ModeratorDID, "deleteVodGate", input.GateUri, "", "", true, ""); err != nil {
+ log.Error(ctx, "failed to create audit log", "error", err)
+ }
+
+ return &streamplace.ModerationDeleteVodGate_Output{}, nil
+}
diff --git a/pkg/spxrpc/place_stream_vod.go b/pkg/spxrpc/place_stream_vod.go
new file mode 100644
index 000000000..9dd074f4a
--- /dev/null
+++ b/pkg/spxrpc/place_stream_vod.go
@@ -0,0 +1,80 @@
+package spxrpc
+
+import (
+ "context"
+ "net/http"
+ "time"
+
+ "github.com/bluesky-social/indigo/atproto/syntax"
+ "github.com/labstack/echo/v4"
+
+ placestream "stream.place/streamplace/pkg/streamplace"
+)
+
+func (s *Server) handlePlaceStreamVodGetComments(ctx context.Context, cursor string, limit int, video string) (*placestream.VodGetComments_Output, error) {
+ if video == "" {
+ return nil, echo.NewHTTPError(http.StatusBadRequest, "video is required")
+ }
+ if _, err := syntax.ParseATURI(video); err != nil {
+ return nil, echo.NewHTTPError(http.StatusBadRequest, "video must be a valid AT-URI: "+err.Error())
+ }
+ l := 50
+ if limit > 0 && limit <= 100 {
+ l = limit
+ }
+ var c *time.Time
+ if cursor != "" {
+ t, err := time.Parse(time.RFC3339Nano, cursor)
+ if err != nil {
+ return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid cursor: "+err.Error())
+ }
+ c = &t
+ }
+ comments, nextCursor, err := s.model.GetCommentsForVideo(ctx, video, l, c)
+ if err != nil {
+ return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
+ }
+ out := &placestream.VodGetComments_Output{
+ Comments: comments,
+ }
+ if nextCursor != nil {
+ cs := nextCursor.Format(time.RFC3339Nano)
+ out.Cursor = &cs
+ }
+ return out, nil
+}
+
+func (s *Server) handlePlaceStreamVodGetLikes(ctx context.Context, cursor string, limit int, subject string) (*placestream.VodGetLikes_Output, error) {
+ if subject == "" {
+ return nil, echo.NewHTTPError(http.StatusBadRequest, "subject is required")
+ }
+ if _, err := syntax.ParseATURI(subject); err != nil {
+ return nil, echo.NewHTTPError(http.StatusBadRequest, "subject must be a valid AT-URI: "+err.Error())
+ }
+ l := 50
+ if limit > 0 && limit <= 100 {
+ l = limit
+ }
+ var c *time.Time
+ if cursor != "" {
+ t, err := time.Parse(time.RFC3339Nano, cursor)
+ if err != nil {
+ return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid cursor: "+err.Error())
+ }
+ c = &t
+ }
+ likes, count, nextCursor, err := s.model.GetLikesForSubject(ctx, subject, l, c)
+ if err != nil {
+ return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
+ }
+ out := &placestream.VodGetLikes_Output{
+ Subject: subject,
+ Count: count,
+ Likes: likes,
+ }
+ if nextCursor != nil {
+ cs := nextCursor.Format(time.RFC3339Nano)
+ out.Cursor = &cs
+ }
+ return out, nil
+}
diff --git a/pkg/spxrpc/stubs.go b/pkg/spxrpc/stubs.go
index 1bc0504af..010c9a6e0 100644
--- a/pkg/spxrpc/stubs.go
+++ b/pkg/spxrpc/stubs.go
@@ -310,9 +310,11 @@ func (s *Server) RegisterHandlersPlaceStream(e *echo.Echo) error {
e.POST("/xrpc/place.stream.moderation.createBlock", s.HandlePlaceStreamModerationCreateBlock)
e.POST("/xrpc/place.stream.moderation.createGate", s.HandlePlaceStreamModerationCreateGate)
e.POST("/xrpc/place.stream.moderation.createPin", s.HandlePlaceStreamModerationCreatePin)
+ e.POST("/xrpc/place.stream.moderation.createVodGate", s.HandlePlaceStreamModerationCreateVodGate)
e.POST("/xrpc/place.stream.moderation.deleteBlock", s.HandlePlaceStreamModerationDeleteBlock)
e.POST("/xrpc/place.stream.moderation.deleteGate", s.HandlePlaceStreamModerationDeleteGate)
e.POST("/xrpc/place.stream.moderation.deletePin", s.HandlePlaceStreamModerationDeletePin)
+ e.POST("/xrpc/place.stream.moderation.deleteVodGate", s.HandlePlaceStreamModerationDeleteVodGate)
e.POST("/xrpc/place.stream.moderation.updateLivestream", s.HandlePlaceStreamModerationUpdateLivestream)
e.POST("/xrpc/place.stream.multistream.createTarget", s.HandlePlaceStreamMultistreamCreateTarget)
e.POST("/xrpc/place.stream.multistream.deleteTarget", s.HandlePlaceStreamMultistreamDeleteTarget)
@@ -333,6 +335,8 @@ func (s *Server) RegisterHandlersPlaceStream(e *echo.Echo) error {
e.GET("/xrpc/place.stream.server.listWebhooks", s.HandlePlaceStreamServerListWebhooks)
e.POST("/xrpc/place.stream.server.updateWebhook", s.HandlePlaceStreamServerUpdateWebhook)
e.POST("/xrpc/place.stream.server.upsertStorage", s.HandlePlaceStreamServerUpsertStorage)
+ e.GET("/xrpc/place.stream.vod.getComments", s.HandlePlaceStreamVodGetComments)
+ e.GET("/xrpc/place.stream.vod.getLikes", s.HandlePlaceStreamVodGetLikes)
return nil
}
@@ -825,6 +829,24 @@ func (s *Server) HandlePlaceStreamModerationCreatePin(c echo.Context) error {
return c.JSON(200, out)
}
+func (s *Server) HandlePlaceStreamModerationCreateVodGate(c echo.Context) error {
+ ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamModerationCreateVodGate")
+ defer span.End()
+
+ var body placestream.ModerationCreateVodGate_Input
+ if err := c.Bind(&body); err != nil {
+ return err
+ }
+ var out *placestream.ModerationCreateVodGate_Output
+ var handleErr error
+ // func (s *Server) handlePlaceStreamModerationCreateVodGate(ctx context.Context,body *placestream.ModerationCreateVodGate_Input) (*placestream.ModerationCreateVodGate_Output, error)
+ out, handleErr = s.handlePlaceStreamModerationCreateVodGate(ctx, &body)
+ if handleErr != nil {
+ return handleErr
+ }
+ return c.JSON(200, out)
+}
+
func (s *Server) HandlePlaceStreamModerationDeleteBlock(c echo.Context) error {
ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamModerationDeleteBlock")
defer span.End()
@@ -879,6 +901,24 @@ func (s *Server) HandlePlaceStreamModerationDeletePin(c echo.Context) error {
return c.JSON(200, out)
}
+func (s *Server) HandlePlaceStreamModerationDeleteVodGate(c echo.Context) error {
+ ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamModerationDeleteVodGate")
+ defer span.End()
+
+ var body placestream.ModerationDeleteVodGate_Input
+ if err := c.Bind(&body); err != nil {
+ return err
+ }
+ var out *placestream.ModerationDeleteVodGate_Output
+ var handleErr error
+ // func (s *Server) handlePlaceStreamModerationDeleteVodGate(ctx context.Context,body *placestream.ModerationDeleteVodGate_Input) (*placestream.ModerationDeleteVodGate_Output, error)
+ out, handleErr = s.handlePlaceStreamModerationDeleteVodGate(ctx, &body)
+ if handleErr != nil {
+ return handleErr
+ }
+ return c.JSON(200, out)
+}
+
func (s *Server) HandlePlaceStreamModerationUpdateLivestream(c echo.Context) error {
ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamModerationUpdateLivestream")
defer span.End()
@@ -1250,6 +1290,58 @@ func (s *Server) HandlePlaceStreamServerUpsertStorage(c echo.Context) error {
return c.JSON(200, out)
}
+func (s *Server) HandlePlaceStreamVodGetComments(c echo.Context) error {
+ ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamVodGetComments")
+ defer span.End()
+ cursor := c.QueryParam("cursor")
+
+ var limit int
+ if p := c.QueryParam("limit"); p != "" {
+ var err error
+ limit, err = strconv.Atoi(p)
+ if err != nil {
+ return err
+ }
+ } else {
+ limit = 50
+ }
+ video := c.QueryParam("video")
+ var out *placestream.VodGetComments_Output
+ var handleErr error
+ // func (s *Server) handlePlaceStreamVodGetComments(ctx context.Context,cursor string,limit int,video string) (*placestream.VodGetComments_Output, error)
+ out, handleErr = s.handlePlaceStreamVodGetComments(ctx, cursor, limit, video)
+ if handleErr != nil {
+ return handleErr
+ }
+ return c.JSON(200, out)
+}
+
+func (s *Server) HandlePlaceStreamVodGetLikes(c echo.Context) error {
+ ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamVodGetLikes")
+ defer span.End()
+ cursor := c.QueryParam("cursor")
+
+ var limit int
+ if p := c.QueryParam("limit"); p != "" {
+ var err error
+ limit, err = strconv.Atoi(p)
+ if err != nil {
+ return err
+ }
+ } else {
+ limit = 50
+ }
+ subject := c.QueryParam("subject")
+ var out *placestream.VodGetLikes_Output
+ var handleErr error
+ // func (s *Server) handlePlaceStreamVodGetLikes(ctx context.Context,cursor string,limit int,subject string) (*placestream.VodGetLikes_Output, error)
+ out, handleErr = s.handlePlaceStreamVodGetLikes(ctx, cursor, limit, subject)
+ if handleErr != nil {
+ return handleErr
+ }
+ return c.JSON(200, out)
+}
+
func (s *Server) RegisterHandlersToolsOzone(e *echo.Echo) error {
return nil
}
diff --git a/pkg/streamplace/cbor_gen.go b/pkg/streamplace/cbor_gen.go
index 2d4310d6c..f47b0b308 100644
--- a/pkg/streamplace/cbor_gen.go
+++ b/pkg/streamplace/cbor_gen.go
@@ -11455,3 +11455,759 @@ func (t *BetaInvite) UnmarshalCBOR(r io.Reader) (err error) {
return nil
}
+func (t *VodComment) MarshalCBOR(w io.Writer) error {
+ if t == nil {
+ _, err := w.Write(cbg.CborNull)
+ return err
+ }
+
+ cw := cbg.NewCborWriter(w)
+ fieldCount := 6
+
+ if t.Facets == nil {
+ fieldCount--
+ }
+
+ if t.Reply == nil {
+ fieldCount--
+ }
+
+ if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
+ return err
+ }
+
+ // t.Text (string) (string)
+ if len("text") > 1000000 {
+ return xerrors.Errorf("Value in field \"text\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("text"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("text")); err != nil {
+ return err
+ }
+
+ if len(t.Text) > 1000000 {
+ return xerrors.Errorf("Value in field t.Text was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Text))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string(t.Text)); err != nil {
+ return err
+ }
+
+ // t.LexiconTypeID (string) (string)
+ if len("$type") > 1000000 {
+ return xerrors.Errorf("Value in field \"$type\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("$type")); err != nil {
+ return err
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("place.stream.vod.comment"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("place.stream.vod.comment")); err != nil {
+ return err
+ }
+
+ // t.Reply (streamplace.VodComment_ReplyRef) (struct)
+ if t.Reply != nil {
+
+ if len("reply") > 1000000 {
+ return xerrors.Errorf("Value in field \"reply\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("reply"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("reply")); err != nil {
+ return err
+ }
+
+ if err := t.Reply.MarshalCBOR(cw); err != nil {
+ return err
+ }
+ }
+
+ // t.Video (string) (string)
+ if len("video") > 1000000 {
+ return xerrors.Errorf("Value in field \"video\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("video"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("video")); err != nil {
+ return err
+ }
+
+ if len(t.Video) > 1000000 {
+ return xerrors.Errorf("Value in field t.Video was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Video))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string(t.Video)); err != nil {
+ return err
+ }
+
+ // t.Facets ([]*streamplace.RichtextFacet) (slice)
+ if t.Facets != nil {
+
+ if len("facets") > 1000000 {
+ return xerrors.Errorf("Value in field \"facets\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("facets"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("facets")); err != nil {
+ return err
+ }
+
+ if len(t.Facets) > 8192 {
+ return xerrors.Errorf("Slice value in field t.Facets was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Facets))); err != nil {
+ return err
+ }
+ for _, v := range t.Facets {
+ if err := v.MarshalCBOR(cw); err != nil {
+ return err
+ }
+
+ }
+ }
+
+ // t.CreatedAt (string) (string)
+ if len("createdAt") > 1000000 {
+ return xerrors.Errorf("Value in field \"createdAt\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("createdAt")); err != nil {
+ return err
+ }
+
+ if len(t.CreatedAt) > 1000000 {
+ return xerrors.Errorf("Value in field t.CreatedAt was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (t *VodComment) UnmarshalCBOR(r io.Reader) (err error) {
+ *t = VodComment{}
+
+ cr := cbg.NewCborReader(r)
+
+ maj, extra, err := cr.ReadHeader()
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err == io.EOF {
+ err = io.ErrUnexpectedEOF
+ }
+ }()
+
+ if maj != cbg.MajMap {
+ return fmt.Errorf("cbor input should be of type map")
+ }
+
+ if extra > cbg.MaxLength {
+ return fmt.Errorf("VodComment: map struct too large (%d)", extra)
+ }
+
+ n := extra
+
+ nameBuf := make([]byte, 9)
+ for i := uint64(0); i < n; i++ {
+ nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
+ if err != nil {
+ return err
+ }
+
+ if !ok {
+ // Field doesn't exist on this type, so ignore it
+ if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
+ return err
+ }
+ continue
+ }
+
+ switch string(nameBuf[:nameLen]) {
+ // t.Text (string) (string)
+ case "text":
+
+ {
+ sval, err := cbg.ReadStringWithMax(cr, 1000000)
+ if err != nil {
+ return err
+ }
+
+ t.Text = string(sval)
+ }
+ // t.LexiconTypeID (string) (string)
+ case "$type":
+
+ {
+ sval, err := cbg.ReadStringWithMax(cr, 1000000)
+ if err != nil {
+ return err
+ }
+
+ t.LexiconTypeID = string(sval)
+ }
+ // t.Reply (streamplace.VodComment_ReplyRef) (struct)
+ case "reply":
+
+ {
+
+ b, err := cr.ReadByte()
+ if err != nil {
+ return err
+ }
+ if b != cbg.CborNull[0] {
+ if err := cr.UnreadByte(); err != nil {
+ return err
+ }
+ t.Reply = new(VodComment_ReplyRef)
+ if err := t.Reply.UnmarshalCBOR(cr); err != nil {
+ return xerrors.Errorf("unmarshaling t.Reply pointer: %w", err)
+ }
+ }
+
+ }
+ // t.Video (string) (string)
+ case "video":
+
+ {
+ sval, err := cbg.ReadStringWithMax(cr, 1000000)
+ if err != nil {
+ return err
+ }
+
+ t.Video = string(sval)
+ }
+ // t.Facets ([]*streamplace.RichtextFacet) (slice)
+ case "facets":
+
+ maj, extra, err = cr.ReadHeader()
+ if err != nil {
+ return err
+ }
+
+ if extra > 8192 {
+ return fmt.Errorf("t.Facets: array too large (%d)", extra)
+ }
+
+ if maj != cbg.MajArray {
+ return fmt.Errorf("expected cbor array")
+ }
+
+ if extra > 0 {
+ t.Facets = make([]*RichtextFacet, extra)
+ }
+
+ for i := 0; i < int(extra); i++ {
+ {
+ var maj byte
+ var extra uint64
+ var err error
+ _ = maj
+ _ = extra
+ _ = err
+
+ {
+
+ b, err := cr.ReadByte()
+ if err != nil {
+ return err
+ }
+ if b != cbg.CborNull[0] {
+ if err := cr.UnreadByte(); err != nil {
+ return err
+ }
+ t.Facets[i] = new(RichtextFacet)
+ if err := t.Facets[i].UnmarshalCBOR(cr); err != nil {
+ return xerrors.Errorf("unmarshaling t.Facets[i] pointer: %w", err)
+ }
+ }
+
+ }
+
+ }
+ }
+ // t.CreatedAt (string) (string)
+ case "createdAt":
+
+ {
+ sval, err := cbg.ReadStringWithMax(cr, 1000000)
+ if err != nil {
+ return err
+ }
+
+ t.CreatedAt = string(sval)
+ }
+
+ default:
+ // Field doesn't exist on this type, so ignore it
+ if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+}
+func (t *VodComment_ReplyRef) MarshalCBOR(w io.Writer) error {
+ if t == nil {
+ _, err := w.Write(cbg.CborNull)
+ return err
+ }
+
+ cw := cbg.NewCborWriter(w)
+
+ if _, err := cw.Write([]byte{162}); err != nil {
+ return err
+ }
+
+ // t.Root (atproto.RepoStrongRef) (struct)
+ if len("root") > 1000000 {
+ return xerrors.Errorf("Value in field \"root\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("root"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("root")); err != nil {
+ return err
+ }
+
+ if err := t.Root.MarshalCBOR(cw); err != nil {
+ return err
+ }
+
+ // t.Parent (atproto.RepoStrongRef) (struct)
+ if len("parent") > 1000000 {
+ return xerrors.Errorf("Value in field \"parent\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("parent"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("parent")); err != nil {
+ return err
+ }
+
+ if err := t.Parent.MarshalCBOR(cw); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (t *VodComment_ReplyRef) UnmarshalCBOR(r io.Reader) (err error) {
+ *t = VodComment_ReplyRef{}
+
+ cr := cbg.NewCborReader(r)
+
+ maj, extra, err := cr.ReadHeader()
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err == io.EOF {
+ err = io.ErrUnexpectedEOF
+ }
+ }()
+
+ if maj != cbg.MajMap {
+ return fmt.Errorf("cbor input should be of type map")
+ }
+
+ if extra > cbg.MaxLength {
+ return fmt.Errorf("VodComment_ReplyRef: map struct too large (%d)", extra)
+ }
+
+ n := extra
+
+ nameBuf := make([]byte, 6)
+ for i := uint64(0); i < n; i++ {
+ nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
+ if err != nil {
+ return err
+ }
+
+ if !ok {
+ // Field doesn't exist on this type, so ignore it
+ if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
+ return err
+ }
+ continue
+ }
+
+ switch string(nameBuf[:nameLen]) {
+ // t.Root (atproto.RepoStrongRef) (struct)
+ case "root":
+
+ {
+
+ b, err := cr.ReadByte()
+ if err != nil {
+ return err
+ }
+ if b != cbg.CborNull[0] {
+ if err := cr.UnreadByte(); err != nil {
+ return err
+ }
+ t.Root = new(atproto.RepoStrongRef)
+ if err := t.Root.UnmarshalCBOR(cr); err != nil {
+ return xerrors.Errorf("unmarshaling t.Root pointer: %w", err)
+ }
+ }
+
+ }
+ // t.Parent (atproto.RepoStrongRef) (struct)
+ case "parent":
+
+ {
+
+ b, err := cr.ReadByte()
+ if err != nil {
+ return err
+ }
+ if b != cbg.CborNull[0] {
+ if err := cr.UnreadByte(); err != nil {
+ return err
+ }
+ t.Parent = new(atproto.RepoStrongRef)
+ if err := t.Parent.UnmarshalCBOR(cr); err != nil {
+ return xerrors.Errorf("unmarshaling t.Parent pointer: %w", err)
+ }
+ }
+
+ }
+
+ default:
+ // Field doesn't exist on this type, so ignore it
+ if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+}
+func (t *VodLike) MarshalCBOR(w io.Writer) error {
+ if t == nil {
+ _, err := w.Write(cbg.CborNull)
+ return err
+ }
+
+ cw := cbg.NewCborWriter(w)
+
+ if _, err := cw.Write([]byte{163}); err != nil {
+ return err
+ }
+
+ // t.LexiconTypeID (string) (string)
+ if len("$type") > 1000000 {
+ return xerrors.Errorf("Value in field \"$type\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("$type")); err != nil {
+ return err
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("place.stream.vod.like"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("place.stream.vod.like")); err != nil {
+ return err
+ }
+
+ // t.Subject (string) (string)
+ if len("subject") > 1000000 {
+ return xerrors.Errorf("Value in field \"subject\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("subject")); err != nil {
+ return err
+ }
+
+ if len(t.Subject) > 1000000 {
+ return xerrors.Errorf("Value in field t.Subject was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string(t.Subject)); err != nil {
+ return err
+ }
+
+ // t.CreatedAt (string) (string)
+ if len("createdAt") > 1000000 {
+ return xerrors.Errorf("Value in field \"createdAt\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("createdAt")); err != nil {
+ return err
+ }
+
+ if len(t.CreatedAt) > 1000000 {
+ return xerrors.Errorf("Value in field t.CreatedAt was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (t *VodLike) UnmarshalCBOR(r io.Reader) (err error) {
+ *t = VodLike{}
+
+ cr := cbg.NewCborReader(r)
+
+ maj, extra, err := cr.ReadHeader()
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err == io.EOF {
+ err = io.ErrUnexpectedEOF
+ }
+ }()
+
+ if maj != cbg.MajMap {
+ return fmt.Errorf("cbor input should be of type map")
+ }
+
+ if extra > cbg.MaxLength {
+ return fmt.Errorf("VodLike: map struct too large (%d)", extra)
+ }
+
+ n := extra
+
+ nameBuf := make([]byte, 9)
+ for i := uint64(0); i < n; i++ {
+ nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
+ if err != nil {
+ return err
+ }
+
+ if !ok {
+ // Field doesn't exist on this type, so ignore it
+ if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
+ return err
+ }
+ continue
+ }
+
+ switch string(nameBuf[:nameLen]) {
+ // t.LexiconTypeID (string) (string)
+ case "$type":
+
+ {
+ sval, err := cbg.ReadStringWithMax(cr, 1000000)
+ if err != nil {
+ return err
+ }
+
+ t.LexiconTypeID = string(sval)
+ }
+ // t.Subject (string) (string)
+ case "subject":
+
+ {
+ sval, err := cbg.ReadStringWithMax(cr, 1000000)
+ if err != nil {
+ return err
+ }
+
+ t.Subject = string(sval)
+ }
+ // t.CreatedAt (string) (string)
+ case "createdAt":
+
+ {
+ sval, err := cbg.ReadStringWithMax(cr, 1000000)
+ if err != nil {
+ return err
+ }
+
+ t.CreatedAt = string(sval)
+ }
+
+ default:
+ // Field doesn't exist on this type, so ignore it
+ if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+}
+func (t *VodGate) MarshalCBOR(w io.Writer) error {
+ if t == nil {
+ _, err := w.Write(cbg.CborNull)
+ return err
+ }
+
+ cw := cbg.NewCborWriter(w)
+
+ if _, err := cw.Write([]byte{162}); err != nil {
+ return err
+ }
+
+ // t.LexiconTypeID (string) (string)
+ if len("$type") > 1000000 {
+ return xerrors.Errorf("Value in field \"$type\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("$type")); err != nil {
+ return err
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("place.stream.vod.gate"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("place.stream.vod.gate")); err != nil {
+ return err
+ }
+
+ // t.HiddenComment (string) (string)
+ if len("hiddenComment") > 1000000 {
+ return xerrors.Errorf("Value in field \"hiddenComment\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("hiddenComment"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("hiddenComment")); err != nil {
+ return err
+ }
+
+ if len(t.HiddenComment) > 1000000 {
+ return xerrors.Errorf("Value in field t.HiddenComment was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.HiddenComment))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string(t.HiddenComment)); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (t *VodGate) UnmarshalCBOR(r io.Reader) (err error) {
+ *t = VodGate{}
+
+ cr := cbg.NewCborReader(r)
+
+ maj, extra, err := cr.ReadHeader()
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err == io.EOF {
+ err = io.ErrUnexpectedEOF
+ }
+ }()
+
+ if maj != cbg.MajMap {
+ return fmt.Errorf("cbor input should be of type map")
+ }
+
+ if extra > cbg.MaxLength {
+ return fmt.Errorf("VodGate: map struct too large (%d)", extra)
+ }
+
+ n := extra
+
+ nameBuf := make([]byte, 13)
+ for i := uint64(0); i < n; i++ {
+ nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
+ if err != nil {
+ return err
+ }
+
+ if !ok {
+ // Field doesn't exist on this type, so ignore it
+ if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
+ return err
+ }
+ continue
+ }
+
+ switch string(nameBuf[:nameLen]) {
+ // t.LexiconTypeID (string) (string)
+ case "$type":
+
+ {
+ sval, err := cbg.ReadStringWithMax(cr, 1000000)
+ if err != nil {
+ return err
+ }
+
+ t.LexiconTypeID = string(sval)
+ }
+ // t.HiddenComment (string) (string)
+ case "hiddenComment":
+
+ {
+ sval, err := cbg.ReadStringWithMax(cr, 1000000)
+ if err != nil {
+ return err
+ }
+
+ t.HiddenComment = string(sval)
+ }
+
+ default:
+ // Field doesn't exist on this type, so ignore it
+ if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+}
diff --git a/pkg/streamplace/moderationcreateVodGate.go b/pkg/streamplace/moderationcreateVodGate.go
new file mode 100644
index 000000000..291d81965
--- /dev/null
+++ b/pkg/streamplace/moderationcreateVodGate.go
@@ -0,0 +1,37 @@
+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+// Lexicon schema: place.stream.moderation.createVodGate
+
+package streamplace
+
+import (
+ "context"
+
+ lexutil "github.com/bluesky-social/indigo/lex/util"
+)
+
+// ModerationCreateVodGate_Input is the input argument to a place.stream.moderation.createVodGate call.
+type ModerationCreateVodGate_Input struct {
+ // commentUri: The AT-URI of the VOD comment to hide.
+ CommentUri string `json:"commentUri" cborgen:"commentUri"`
+ // streamer: The DID of the streamer.
+ Streamer string `json:"streamer" cborgen:"streamer"`
+}
+
+// ModerationCreateVodGate_Output is the output of a place.stream.moderation.createVodGate call.
+type ModerationCreateVodGate_Output struct {
+ // cid: The CID of the created gate record.
+ Cid string `json:"cid" cborgen:"cid"`
+ // uri: The AT-URI of the created gate record.
+ Uri string `json:"uri" cborgen:"uri"`
+}
+
+// ModerationCreateVodGate calls the XRPC method "place.stream.moderation.createVodGate".
+func ModerationCreateVodGate(ctx context.Context, c lexutil.LexClient, input *ModerationCreateVodGate_Input) (*ModerationCreateVodGate_Output, error) {
+ var out ModerationCreateVodGate_Output
+ if err := c.LexDo(ctx, lexutil.Procedure, "application/json", "place.stream.moderation.createVodGate", nil, input, &out); err != nil {
+ return nil, err
+ }
+
+ return &out, nil
+}
diff --git a/pkg/streamplace/moderationdeleteVodGate.go b/pkg/streamplace/moderationdeleteVodGate.go
new file mode 100644
index 000000000..baefcf1a2
--- /dev/null
+++ b/pkg/streamplace/moderationdeleteVodGate.go
@@ -0,0 +1,33 @@
+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+// Lexicon schema: place.stream.moderation.deleteVodGate
+
+package streamplace
+
+import (
+ "context"
+
+ lexutil "github.com/bluesky-social/indigo/lex/util"
+)
+
+// ModerationDeleteVodGate_Input is the input argument to a place.stream.moderation.deleteVodGate call.
+type ModerationDeleteVodGate_Input struct {
+ // gateUri: The AT-URI of the gate record to delete.
+ GateUri string `json:"gateUri" cborgen:"gateUri"`
+ // streamer: The DID of the streamer.
+ Streamer string `json:"streamer" cborgen:"streamer"`
+}
+
+// ModerationDeleteVodGate_Output is the output of a place.stream.moderation.deleteVodGate call.
+type ModerationDeleteVodGate_Output struct {
+}
+
+// ModerationDeleteVodGate calls the XRPC method "place.stream.moderation.deleteVodGate".
+func ModerationDeleteVodGate(ctx context.Context, c lexutil.LexClient, input *ModerationDeleteVodGate_Input) (*ModerationDeleteVodGate_Output, error) {
+ var out ModerationDeleteVodGate_Output
+ if err := c.LexDo(ctx, lexutil.Procedure, "application/json", "place.stream.moderation.deleteVodGate", nil, input, &out); err != nil {
+ return nil, err
+ }
+
+ return &out, nil
+}
diff --git a/pkg/streamplace/vodcomment.go b/pkg/streamplace/vodcomment.go
new file mode 100644
index 000000000..b4b693e99
--- /dev/null
+++ b/pkg/streamplace/vodcomment.go
@@ -0,0 +1,33 @@
+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+// Lexicon schema: place.stream.vod.comment
+
+package streamplace
+
+import (
+ comatproto "github.com/bluesky-social/indigo/api/atproto"
+ lexutil "github.com/bluesky-social/indigo/lex/util"
+)
+
+func init() {
+ lexutil.RegisterType("place.stream.vod.comment", &VodComment{})
+}
+
+type VodComment struct {
+ LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.vod.comment"`
+ // createdAt: Client-declared timestamp when this comment was created.
+ CreatedAt string `json:"createdAt" cborgen:"createdAt"`
+ // facets: Annotations of text (mentions, URLs, etc)
+ Facets []*RichtextFacet `json:"facets,omitempty" cborgen:"facets,omitempty"`
+ Reply *VodComment_ReplyRef `json:"reply,omitempty" cborgen:"reply,omitempty"`
+ // text: The comment text content.
+ Text string `json:"text" cborgen:"text"`
+ // video: AT-URI of the place.stream.video record this comment belongs to.
+ Video string `json:"video" cborgen:"video"`
+}
+
+// VodComment_ReplyRef is a "replyRef" in the place.stream.vod.comment schema.
+type VodComment_ReplyRef struct {
+ Parent *comatproto.RepoStrongRef `json:"parent" cborgen:"parent"`
+ Root *comatproto.RepoStrongRef `json:"root" cborgen:"root"`
+}
diff --git a/pkg/streamplace/voddefs.go b/pkg/streamplace/voddefs.go
new file mode 100644
index 000000000..ce229064a
--- /dev/null
+++ b/pkg/streamplace/voddefs.go
@@ -0,0 +1,53 @@
+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+// Lexicon schema: place.stream.vod.defs
+
+package streamplace
+
+import (
+ "encoding/json"
+ "fmt"
+
+ appbsky "github.com/bluesky-social/indigo/api/bsky"
+ lexutil "github.com/bluesky-social/indigo/lex/util"
+)
+
+// VodDefs_CommentView is a "commentView" in the place.stream.vod.defs schema.
+type VodDefs_CommentView struct {
+ LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.vod.defs#commentView"`
+ Author *appbsky.ActorDefs_ProfileViewBasic `json:"author" cborgen:"author"`
+ Cid string `json:"cid" cborgen:"cid"`
+ IndexedAt string `json:"indexedAt" cborgen:"indexedAt"`
+ // likeCount: Number of likes on this comment.
+ LikeCount int64 `json:"likeCount" cborgen:"likeCount"`
+ Record *lexutil.LexiconTypeDecoder `json:"record" cborgen:"record"`
+ ReplyTo *VodDefs_CommentView_ReplyTo `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"`
+ Uri string `json:"uri" cborgen:"uri"`
+}
+
+type VodDefs_CommentView_ReplyTo struct {
+ VodDefs_CommentView *VodDefs_CommentView
+}
+
+func (t *VodDefs_CommentView_ReplyTo) MarshalJSON() ([]byte, error) {
+ if t.VodDefs_CommentView != nil {
+ t.VodDefs_CommentView.LexiconTypeID = "place.stream.vod.defs#commentView"
+ return json.Marshal(t.VodDefs_CommentView)
+ }
+ return nil, fmt.Errorf("can not marshal empty union as JSON")
+}
+
+func (t *VodDefs_CommentView_ReplyTo) UnmarshalJSON(b []byte) error {
+ typ, err := lexutil.TypeExtract(b)
+ if err != nil {
+ return err
+ }
+
+ switch typ {
+ case "place.stream.vod.defs#commentView":
+ t.VodDefs_CommentView = new(VodDefs_CommentView)
+ return json.Unmarshal(b, t.VodDefs_CommentView)
+ default:
+ return nil
+ }
+}
diff --git a/pkg/streamplace/vodgate.go b/pkg/streamplace/vodgate.go
new file mode 100644
index 000000000..dff664b7a
--- /dev/null
+++ b/pkg/streamplace/vodgate.go
@@ -0,0 +1,19 @@
+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+// Lexicon schema: place.stream.vod.gate
+
+package streamplace
+
+import (
+ lexutil "github.com/bluesky-social/indigo/lex/util"
+)
+
+func init() {
+ lexutil.RegisterType("place.stream.vod.gate", &VodGate{})
+}
+
+type VodGate struct {
+ LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.vod.gate"`
+ // hiddenComment: URI of the hidden VOD comment.
+ HiddenComment string `json:"hiddenComment" cborgen:"hiddenComment"`
+}
diff --git a/pkg/streamplace/vodgetComments.go b/pkg/streamplace/vodgetComments.go
new file mode 100644
index 000000000..65eccbb5b
--- /dev/null
+++ b/pkg/streamplace/vodgetComments.go
@@ -0,0 +1,40 @@
+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+// Lexicon schema: place.stream.vod.getComments
+
+package streamplace
+
+import (
+ "context"
+
+ lexutil "github.com/bluesky-social/indigo/lex/util"
+)
+
+// VodGetComments_Output is the output of a place.stream.vod.getComments call.
+type VodGetComments_Output struct {
+ Comments []*VodDefs_CommentView `json:"comments" cborgen:"comments"`
+ Cursor *string `json:"cursor,omitempty" cborgen:"cursor,omitempty"`
+}
+
+// VodGetComments calls the XRPC method "place.stream.vod.getComments".
+//
+// cursor: Optional pagination cursor.
+// limit: Maximum number of comments to return.
+// video: AT-URI of the place.stream.video record.
+func VodGetComments(ctx context.Context, c lexutil.LexClient, cursor string, limit int64, video string) (*VodGetComments_Output, error) {
+ var out VodGetComments_Output
+
+ params := map[string]interface{}{}
+ if cursor != "" {
+ params["cursor"] = cursor
+ }
+ if limit != 0 {
+ params["limit"] = limit
+ }
+ params["video"] = video
+ if err := c.LexDo(ctx, lexutil.Query, "", "place.stream.vod.getComments", params, nil, &out); err != nil {
+ return nil, err
+ }
+
+ return &out, nil
+}
diff --git a/pkg/streamplace/vodgetLikes.go b/pkg/streamplace/vodgetLikes.go
new file mode 100644
index 000000000..679d7b123
--- /dev/null
+++ b/pkg/streamplace/vodgetLikes.go
@@ -0,0 +1,53 @@
+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+// Lexicon schema: place.stream.vod.getLikes
+
+package streamplace
+
+import (
+ "context"
+
+ appbsky "github.com/bluesky-social/indigo/api/bsky"
+ lexutil "github.com/bluesky-social/indigo/lex/util"
+)
+
+// VodGetLikes_LikeView is a "likeView" in the place.stream.vod.getLikes schema.
+type VodGetLikes_LikeView struct {
+ Author *appbsky.ActorDefs_ProfileViewBasic `json:"author" cborgen:"author"`
+ Cid string `json:"cid" cborgen:"cid"`
+ IndexedAt string `json:"indexedAt" cborgen:"indexedAt"`
+ Record *lexutil.LexiconTypeDecoder `json:"record" cborgen:"record"`
+ Uri string `json:"uri" cborgen:"uri"`
+}
+
+// VodGetLikes_Output is the output of a place.stream.vod.getLikes call.
+type VodGetLikes_Output struct {
+ // count: Total number of likes for this subject.
+ Count int64 `json:"count" cborgen:"count"`
+ Cursor *string `json:"cursor,omitempty" cborgen:"cursor,omitempty"`
+ Likes []*VodGetLikes_LikeView `json:"likes,omitempty" cborgen:"likes,omitempty"`
+ Subject string `json:"subject" cborgen:"subject"`
+}
+
+// VodGetLikes calls the XRPC method "place.stream.vod.getLikes".
+//
+// cursor: Optional pagination cursor.
+// limit: Maximum number of likes to return.
+// subject: AT-URI of the subject (video or comment).
+func VodGetLikes(ctx context.Context, c lexutil.LexClient, cursor string, limit int64, subject string) (*VodGetLikes_Output, error) {
+ var out VodGetLikes_Output
+
+ params := map[string]interface{}{}
+ if cursor != "" {
+ params["cursor"] = cursor
+ }
+ if limit != 0 {
+ params["limit"] = limit
+ }
+ params["subject"] = subject
+ if err := c.LexDo(ctx, lexutil.Query, "", "place.stream.vod.getLikes", params, nil, &out); err != nil {
+ return nil, err
+ }
+
+ return &out, nil
+}
diff --git a/pkg/streamplace/vodlike.go b/pkg/streamplace/vodlike.go
new file mode 100644
index 000000000..aae9c58dc
--- /dev/null
+++ b/pkg/streamplace/vodlike.go
@@ -0,0 +1,21 @@
+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+// Lexicon schema: place.stream.vod.like
+
+package streamplace
+
+import (
+ lexutil "github.com/bluesky-social/indigo/lex/util"
+)
+
+func init() {
+ lexutil.RegisterType("place.stream.vod.like", &VodLike{})
+}
+
+type VodLike struct {
+ LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.vod.like"`
+ // createdAt: Client-declared timestamp when this like was created.
+ CreatedAt string `json:"createdAt" cborgen:"createdAt"`
+ // subject: AT-URI of the record being liked (a place.stream.video or place.stream.vod.comment).
+ Subject string `json:"subject" cborgen:"subject"`
+}
--
2.51.2
From 1f7178b1b210a60d987599aec8706a82c95c6799 Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Sat, 30 May 2026 16:32:14 -0700
Subject: [PATCH 11/47] vod: YouTube-style video listing pages at /video and
/:did/video
Add a global newest-first VOD feed at /video and a per-user variant at
/:did/video, sharing one VideoListScreen (responsive grid, thumbnails,
infinite scroll). Surface it in the web sidebar and as a new bottom-bar
tab on mobile.
Backend: make place.stream.media.getVideoList's repo param optional so an
empty repo lists videos across every indexed repo; accept a handle or DID
and resolve handles server-side. Regenerated Go client + docs via
`make lexicons`.
Co-Authored-By: Claude Opus 4.8
---
js/app/components/sidebar/sidebar-overlay.tsx | 7 +
js/app/components/video/video-card.tsx | 145 ++++++++++++++++++
js/app/hooks/useVideoList.ts | 79 ++++++++++
js/app/src/linking-config.ts | 8 +
js/app/src/navigation-helper.tsx | 17 ++
js/app/src/navigation-types.ts | 7 +
js/app/src/screens/video-list.tsx | 112 ++++++++++++++
js/app/src/shell.tsx | 58 ++++++-
js/app/utils/video.ts | 43 ++++++
.../media/place-stream-media-getvideolist.md | 19 ++-
.../content/docs/lex-reference/openapi.json | 9 +-
lexicons/place/stream/media/getVideoList.json | 7 +-
pkg/model/video_list.go | 12 +-
pkg/spxrpc/place_stream_media_getvideolist.go | 21 ++-
pkg/streamplace/mediagetVideoList.go | 6 +-
15 files changed, 516 insertions(+), 34 deletions(-)
create mode 100644 js/app/components/video/video-card.tsx
create mode 100644 js/app/hooks/useVideoList.ts
create mode 100644 js/app/src/screens/video-list.tsx
create mode 100644 js/app/utils/video.ts
diff --git a/js/app/components/sidebar/sidebar-overlay.tsx b/js/app/components/sidebar/sidebar-overlay.tsx
index e6cf92a07..1e7699729 100644
--- a/js/app/components/sidebar/sidebar-overlay.tsx
+++ b/js/app/components/sidebar/sidebar-overlay.tsx
@@ -18,6 +18,7 @@ import usePlatform from "hooks/usePlatform";
import { useSidebarControl } from "hooks/useSidebarControl";
import {
Book,
+ Clapperboard,
Download,
ExternalLink,
Home,
@@ -156,6 +157,12 @@ export function SidebarOverlay() {
label: Home,
href: "/",
},
+ {
+ icon: () => ,
+ label: Videos,
+ href: "/video",
+ matchPrefix: "/video",
+ },
{
icon: () => ,
label: What's Streamplace?,
diff --git a/js/app/components/video/video-card.tsx b/js/app/components/video/video-card.tsx
new file mode 100644
index 000000000..876e6a61d
--- /dev/null
+++ b/js/app/components/video/video-card.tsx
@@ -0,0 +1,145 @@
+import { Text, useTheme, zero } from "@streamplace/components";
+import { Image } from "expo-image";
+import { VideoView } from "hooks/useVideoList";
+import { View } from "react-native";
+import { PlaceStreamVideo } from "streamplace";
+import {
+ formatDuration,
+ getTidFromAtUri,
+ getVideoThumbnailUrl,
+} from "utils/video";
+import AQLink from "../aqlink";
+
+function formatViews(count: number): string {
+ if (count >= 1_000_000) {
+ return `${(count / 1_000_000).toFixed(count >= 10_000_000 ? 0 : 1)}M`;
+ }
+ if (count >= 1_000) {
+ return `${(count / 1_000).toFixed(count >= 10_000 ? 0 : 1)}K`;
+ }
+ return `${count}`;
+}
+
+export default function VideoCard({
+ video,
+ avatarUrl,
+}: {
+ video: VideoView;
+ avatarUrl?: string;
+}) {
+ const { theme } = useTheme();
+ const record = video.record as PlaceStreamVideo.Record;
+ const author = video.author;
+ const user = author.handle || author.did;
+ const tid = getTidFromAtUri(video.uri);
+ const title = record.title || "Untitled";
+ const thumbnailUrl = getVideoThumbnailUrl(record, author.did);
+ const duration = formatDuration(record.durationMs);
+ const viewCount = video.viewCounts?.count ?? 0;
+
+ return (
+
+
+ {/* Thumbnail */}
+
+ {thumbnailUrl ? (
+
+ ) : (
+
+ )}
+ {duration && (
+
+
+ {duration}
+
+
+ )}
+
+
+ {/* Metadata */}
+
+
+ {avatarUrl ? (
+
+ ) : (
+
+ )}
+
+
+
+ {title}
+
+
+ @{user}
+
+
+ {formatViews(viewCount)} view{viewCount === 1 ? "" : "s"}
+
+
+
+
+
+ );
+}
diff --git a/js/app/hooks/useVideoList.ts b/js/app/hooks/useVideoList.ts
new file mode 100644
index 000000000..aff376699
--- /dev/null
+++ b/js/app/hooks/useVideoList.ts
@@ -0,0 +1,79 @@
+import { usePossiblyUnauthedPDSAgent } from "@streamplace/components";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { PlaceStreamMediaGetVideo } from "streamplace";
+
+export type VideoView = PlaceStreamMediaGetVideo.VideoView;
+
+const PAGE_SIZE = 24;
+
+// Fetches a page of place.stream.media.getVideoList and exposes cursor-based
+// infinite scroll. Pass a `repo` (DID or handle) to scope the list to a single
+// uploader; omit it for the global newest-first feed. The two listing screens
+// (/video and /:did/video) share this hook — they differ only in whether a
+// repo is supplied.
+export function useVideoList(repo?: string) {
+ const agent = usePossiblyUnauthedPDSAgent();
+ const [videos, setVideos] = useState([]);
+ const [cursor, setCursor] = useState(undefined);
+ const [hasMore, setHasMore] = useState(true);
+ const [loading, setLoading] = useState(true);
+ const [refreshing, setRefreshing] = useState(false);
+ const [error, setError] = useState(null);
+
+ // Guards against overlapping fetches (e.g. onEndReached firing repeatedly).
+ const inFlight = useRef(false);
+
+ const fetchPage = useCallback(
+ async (nextCursor?: string, replace = false) => {
+ if (!agent) return;
+ if (inFlight.current) return;
+ inFlight.current = true;
+ if (replace) {
+ setRefreshing(true);
+ } else {
+ setLoading(true);
+ }
+ setError(null);
+ try {
+ const res = await agent.place.stream.media.getVideoList({
+ ...(repo ? { repo } : {}),
+ limit: PAGE_SIZE,
+ ...(nextCursor ? { cursor: nextCursor } : {}),
+ });
+ const page = (res.data.videos ?? []) as VideoView[];
+ setVideos((prev) => (replace ? page : [...prev, ...page]));
+ setCursor(res.data.cursor);
+ setHasMore(Boolean(res.data.cursor) && page.length > 0);
+ } catch (e: any) {
+ console.error("error fetching video list", e);
+ setError(e?.message ?? "failed to load videos");
+ setHasMore(false);
+ } finally {
+ inFlight.current = false;
+ setLoading(false);
+ setRefreshing(false);
+ }
+ },
+ [agent, repo],
+ );
+
+ // Initial load + reload when the agent or repo changes.
+ useEffect(() => {
+ setVideos([]);
+ setCursor(undefined);
+ setHasMore(true);
+ fetchPage(undefined, true);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [agent, repo]);
+
+ const loadMore = useCallback(() => {
+ if (loading || refreshing || !hasMore || !cursor) return;
+ fetchPage(cursor, false);
+ }, [loading, refreshing, hasMore, cursor, fetchPage]);
+
+ const refresh = useCallback(() => {
+ fetchPage(undefined, true);
+ }, [fetchPage]);
+
+ return { videos, loading, refreshing, error, hasMore, loadMore, refresh };
+}
diff --git a/js/app/src/linking-config.ts b/js/app/src/linking-config.ts
index c8a680181..87d2dfd16 100644
--- a/js/app/src/linking-config.ts
+++ b/js/app/src/linking-config.ts
@@ -94,6 +94,8 @@ export const SCREEN_PATHS = {
Multi: "multi/:config",
Support: "support",
Upload: "upload",
+ VideoList: "video",
+ UserVideoList: ":did/video",
// Settings screens
MainSettings: "settings",
AboutCategory: "settings/about",
@@ -193,6 +195,12 @@ export const streamplaceLinkingOptions: LinkingOptions;
+ VideosTab: NavigatorScreenParams;
GoLiveTab: undefined;
SettingsTab: NavigatorScreenParams;
};
diff --git a/js/app/src/screens/video-list.tsx b/js/app/src/screens/video-list.tsx
new file mode 100644
index 000000000..beb168fa8
--- /dev/null
+++ b/js/app/src/screens/video-list.tsx
@@ -0,0 +1,112 @@
+import { Text, useTheme, zero } from "@streamplace/components";
+import Title from "components/title";
+import VideoCard from "components/video/video-card";
+import useAvatars from "hooks/useAvatars";
+import { useVideoList, VideoView } from "hooks/useVideoList";
+import { useMemo } from "react";
+import {
+ ActivityIndicator,
+ FlatList,
+ Platform,
+ useWindowDimensions,
+ View,
+} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+
+const GAP = 16;
+const MAX_WIDTH = 1600;
+
+function getColumns(width: number): number {
+ if (width >= 1500) return 4;
+ if (width >= 1100) return 3;
+ if (width >= 680) return 2;
+ return 1;
+}
+
+export default function VideoListScreen({
+ route,
+}: {
+ route?: { params?: { did?: string } };
+}) {
+ const repo = route?.params?.did;
+ const { theme } = useTheme();
+ const { width } = useWindowDimensions();
+ const safeAreaInsets = useSafeAreaInsets();
+ const { videos, loading, refreshing, error, hasMore, loadMore, refresh } =
+ useVideoList(repo);
+
+ const columns = getColumns(Math.min(width, MAX_WIDTH));
+
+ // Bulk-resolve author avatars so individual cards don't each fire a fetch.
+ const dids = useMemo(
+ () => Array.from(new Set(videos.map((v) => v.author.did))),
+ [videos],
+ );
+ const avatars = useAvatars(dids);
+
+ // The per-user page borrows the uploader's handle from the first row for an
+ // in-list heading. The global page omits it — the nav bar already says
+ // "Videos", so a second "Videos" heading would just be redundant.
+ const heading =
+ repo && videos[0]?.author
+ ? `@${videos[0].author.handle || repo}`
+ : undefined;
+
+ const renderItem = ({ item }: { item: VideoView }) => (
+
+
+
+ );
+
+ return (
+ item.uri}
+ renderItem={renderItem}
+ numColumns={columns}
+ columnWrapperStyle={columns > 1 ? { gap: GAP } : undefined}
+ contentContainerStyle={{
+ gap: GAP,
+ paddingHorizontal: GAP,
+ paddingBottom:
+ (Platform.OS !== "web" ? 64 : 0) + safeAreaInsets.bottom + GAP,
+ paddingTop: Platform.OS === "ios" ? 96 : GAP,
+ maxWidth: MAX_WIDTH,
+ width: "100%",
+ alignSelf: "center",
+ }}
+ {...(Platform.OS !== "web" ? { refreshing, onRefresh: refresh } : {})}
+ onEndReached={loadMore}
+ onEndReachedThreshold={0.6}
+ ListHeaderComponent={
+ heading ? (
+
+ {heading}
+
+ ) : null
+ }
+ ListEmptyComponent={
+ loading ? (
+
+
+
+ ) : (
+
+
+ {error ? `Couldn't load videos: ${error}` : "No videos yet."}
+
+
+ )
+ }
+ ListFooterComponent={
+ hasMore && videos.length > 0 ? (
+
+
+
+ ) : null
+ }
+ />
+ );
+}
diff --git a/js/app/src/shell.tsx b/js/app/src/shell.tsx
index 82edb0144..9036430d0 100644
--- a/js/app/src/shell.tsx
+++ b/js/app/src/shell.tsx
@@ -41,7 +41,7 @@ import { useBlueskyNotifications } from "hooks/useBlueskyNotifications";
import { useLiveUser } from "hooks/useLiveUser";
import usePlatform from "hooks/usePlatform";
import { useIsLargeScreen, useSidebarControl } from "hooks/useSidebarControl";
-import { Cog, Home, Video } from "lucide-react-native";
+import { Clapperboard, Cog, Home, Video } from "lucide-react-native";
import { useEffect, useRef, useState } from "react";
import { Platform, StatusBar, View } from "react-native";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
@@ -67,6 +67,7 @@ import PopoutStreamMonitor from "src/screens/popout-stream-monitor";
import SupportScreen from "src/screens/support";
import UploadScreen from "src/screens/upload";
import VideoScreen from "src/screens/video";
+import VideoListScreen from "src/screens/video-list";
import { useStore } from "store";
import {
useHydrated,
@@ -78,6 +79,7 @@ import { AvatarButton, LGAvatarButton, NavigationButton } from "./router";
const Tab = createBottomTabNavigator();
const RootStack = createNativeStackNavigator();
const HomeStack = createNativeStackNavigator();
+const VideosStack = createNativeStackNavigator();
const SettingsStack = createNativeStackNavigator();
function useBaseScreenOptions() {
@@ -198,6 +200,42 @@ function HomeNavigator() {
);
}
+// Videos stack navigator (global + per-user VOD listings). Unlike the pushed
+// Home screens, these are tab roots, so they always carry their own header.
+function VideosNavigator() {
+ const baseScreenOptions = useBaseScreenOptions();
+ const isNative = Platform.OS !== "web";
+ const z = useTheme();
+
+ return (
+ (
+
+ ),
+ headerRight: () => ,
+ headerTitleStyle: {
+ fontFamily: z.theme.typography.universal.base.fontFamily,
+ },
+ }}
+ >
+
+
+
+ );
+}
+
// Settings stack navigator
function SettingsNavigator() {
const baseScreenOptions = useBaseScreenOptions();
@@ -308,11 +346,13 @@ function SettingsNavigator() {
const IOS_ICONS: Record = {
Home: "house.fill",
+ Videos: "play.rectangle.fill",
GoLive: "video.fill",
Settings: "gearshape.fill",
};
const ANDROID_ICONS = {
Home: "home",
+ Videos: "video_library",
GoLive: "videocam",
Settings: "settings",
};
@@ -377,6 +417,22 @@ function TabNavigator() {
}),
}}
/>
+ (
+
+ ),
+ }),
+ }}
+ />
/place.stream.video/
+export function getDidFromAtUri(uri: string): string {
+ return uri.split("/")[2] ?? "";
+}
+
+// The record key (tid) is the final segment of the at:// URI.
+export function getTidFromAtUri(uri: string): string {
+ return uri.split("/").pop() ?? "";
+}
+
+// VOD thumbnails are stored as blobs on the author's PDS and served through
+// the Bluesky image CDN — the same trick the OG link cards and game cover art
+// use. Returns undefined when the video has no thumbnail so callers can fall
+// back to a placeholder.
+export function getVideoThumbnailUrl(
+ record: PlaceStreamVideo.Record | undefined,
+ did: string,
+): string | undefined {
+ const ref = record?.thumb?.ref as { $link?: string } | undefined;
+ const cid = ref?.$link ?? record?.thumb?.ref?.toString();
+ if (!cid || !did) return undefined;
+ return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${cid}@jpeg`;
+}
+
+// Format a millisecond duration as a compact runtime badge, e.g. 9:05 or
+// 1:09:05. Returns undefined for missing/zero durations.
+export function formatDuration(
+ durationMs: number | undefined,
+): string | undefined {
+ if (!durationMs || durationMs <= 0) return undefined;
+ const totalSeconds = Math.floor(durationMs / 1000);
+ const hours = Math.floor(totalSeconds / 3600);
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
+ const seconds = totalSeconds % 60;
+ const two = (n: number) => n.toString().padStart(2, "0");
+ if (hours > 0) {
+ return `${hours}:${two(minutes)}:${two(seconds)}`;
+ }
+ return `${minutes}:${two(seconds)}`;
+}
diff --git a/js/docs/src/content/docs/lex-reference/media/place-stream-media-getvideolist.md b/js/docs/src/content/docs/lex-reference/media/place-stream-media-getvideolist.md
index b153ea01b..4a6316b91 100644
--- a/js/docs/src/content/docs/lex-reference/media/place-stream-media-getvideolist.md
+++ b/js/docs/src/content/docs/lex-reference/media/place-stream-media-getvideolist.md
@@ -13,15 +13,15 @@ description: Reference for the place.stream.media.getVideoList lexicon
**Type:** `query`
-List videos for a given repo DID, newest first. Returns hydrated video views with author info and view counts.
+List videos newest first. Scoped to a single repo when `repo` is supplied; otherwise lists videos across every indexed repo. Returns hydrated video views with author info and view counts.
**Parameters:**
-| Name | Type | Req'd | Description | Constraints |
-| -------- | --------- | ----- | ------------------------------------------- | ------------------------------------- |
-| `repo` | `string` | ✅ | DID of the repo whose videos to list. | Format: `did` |
-| `limit` | `integer` | ❌ | Maximum number of videos to return. | Min: 1
Max: 100
Default: `25` |
-| `cursor` | `string` | ❌ | Pagination cursor from a previous response. | |
+| Name | Type | Req'd | Description | Constraints |
+| -------- | --------- | ----- | ---------------------------------------------------------------------------------------------- | ------------------------------------- |
+| `repo` | `string` | ❌ | DID or handle of the repo whose videos to list. Omit to list videos globally across all repos. | |
+| `limit` | `integer` | ❌ | Maximum number of videos to return. | Min: 1
Max: 100
Default: `25` |
+| `cursor` | `string` | ❌ | Pagination cursor from a previous response. | |
**Output:**
@@ -50,15 +50,14 @@ List videos for a given repo DID, newest first. Returns hydrated video views wit
"defs": {
"main": {
"type": "query",
- "description": "List videos for a given repo DID, newest first. Returns hydrated video views with author info and view counts.",
+ "description": "List videos newest first. Scoped to a single repo when `repo` is supplied; otherwise lists videos across every indexed repo. Returns hydrated video views with author info and view counts.",
"parameters": {
"type": "params",
- "required": ["repo"],
+ "required": [],
"properties": {
"repo": {
"type": "string",
- "format": "did",
- "description": "DID of the repo whose videos to list."
+ "description": "DID or handle of the repo whose videos to list. Omit to list videos globally across all repos."
},
"limit": {
"type": "integer",
diff --git a/js/docs/src/content/docs/lex-reference/openapi.json b/js/docs/src/content/docs/lex-reference/openapi.json
index 873bb2632..f1b3411ed 100644
--- a/js/docs/src/content/docs/lex-reference/openapi.json
+++ b/js/docs/src/content/docs/lex-reference/openapi.json
@@ -2427,7 +2427,7 @@
},
"/xrpc/place.stream.media.getVideoList": {
"get": {
- "summary": "List videos for a given repo DID, newest first. Returns hydrated video views with author info and view counts.",
+ "summary": "List videos newest first. Scoped to a single repo when `repo` is supplied; otherwise lists videos across every indexed repo. Returns hydrated video views with author info and view counts.",
"operationId": "place.stream.media.getVideoList",
"tags": ["place.stream.media"],
"responses": {
@@ -2483,12 +2483,11 @@
{
"name": "repo",
"in": "query",
- "required": true,
- "description": "DID of the repo whose videos to list.",
+ "required": false,
+ "description": "DID or handle of the repo whose videos to list. Omit to list videos globally across all repos.",
"schema": {
"type": "string",
- "description": "DID of the repo whose videos to list.",
- "format": "did"
+ "description": "DID or handle of the repo whose videos to list. Omit to list videos globally across all repos."
}
},
{
diff --git a/lexicons/place/stream/media/getVideoList.json b/lexicons/place/stream/media/getVideoList.json
index 80e1253f6..fdf288cd0 100644
--- a/lexicons/place/stream/media/getVideoList.json
+++ b/lexicons/place/stream/media/getVideoList.json
@@ -4,15 +4,14 @@
"defs": {
"main": {
"type": "query",
- "description": "List videos for a given repo DID, newest first. Returns hydrated video views with author info and view counts.",
+ "description": "List videos newest first. Scoped to a single repo when `repo` is supplied; otherwise lists videos across every indexed repo. Returns hydrated video views with author info and view counts.",
"parameters": {
"type": "params",
- "required": ["repo"],
+ "required": [],
"properties": {
"repo": {
"type": "string",
- "format": "did",
- "description": "DID of the repo whose videos to list."
+ "description": "DID or handle of the repo whose videos to list. Omit to list videos globally across all repos."
},
"limit": {
"type": "integer",
diff --git a/pkg/model/video_list.go b/pkg/model/video_list.go
index 875fe6b5a..6eaf26bce 100644
--- a/pkg/model/video_list.go
+++ b/pkg/model/video_list.go
@@ -10,9 +10,10 @@ import (
"stream.place/streamplace/pkg/streamplace"
)
-// GetVideoList returns a page of hydrated video views for the given
-// repo DID, newest first. Pagination is cursor-based: each page
-// returns a cursor for the next page if more videos exist.
+// GetVideoList returns a page of hydrated video views, newest first.
+// When repoDID is non-empty the list is scoped to that repo; when it's
+// empty the list spans every indexed repo. Pagination is cursor-based:
+// each page returns a cursor for the next page if more videos exist.
func (m *DBModel) GetVideoList(ctx context.Context, repoDID string, limit int, cursor string) (*streamplace.MediaGetVideoList_Output, error) {
if limit <= 0 || limit > 100 {
limit = 25
@@ -20,9 +21,12 @@ func (m *DBModel) GetVideoList(ctx context.Context, repoDID string, limit int, c
query := m.DB.WithContext(ctx).
Model(&Video{}).
- Where("repo_did = ?", repoDID).
Order("indexed_at DESC, uri DESC")
+ if repoDID != "" {
+ query = query.Where("repo_did = ?", repoDID)
+ }
+
if cursor != "" {
// Cursor is the uri of the last video from the previous page.
// Find its indexed_at to use as the pagination anchor.
diff --git a/pkg/spxrpc/place_stream_media_getvideolist.go b/pkg/spxrpc/place_stream_media_getvideolist.go
index c59972597..6d55e5427 100644
--- a/pkg/spxrpc/place_stream_media_getvideolist.go
+++ b/pkg/spxrpc/place_stream_media_getvideolist.go
@@ -4,20 +4,25 @@ import (
"context"
"net/http"
- "github.com/bluesky-social/indigo/atproto/syntax"
"github.com/labstack/echo/v4"
placestream "stream.place/streamplace/pkg/streamplace"
)
// handlePlaceStreamMediaGetVideoList returns a paginated, hydrated
-// list of video records for a given repo DID.
+// list of video records. When repo is supplied (DID or handle) the
+// list is scoped to that repo; when it's empty the list spans every
+// indexed repo, newest first.
func (s *Server) handlePlaceStreamMediaGetVideoList(ctx context.Context, cursor string, limit int, repo string) (*placestream.MediaGetVideoList_Output, error) {
- if repo == "" {
- return nil, echo.NewHTTPError(http.StatusBadRequest, "repo is required")
- }
- if _, err := syntax.ParseDID(repo); err != nil {
- return nil, echo.NewHTTPError(http.StatusBadRequest, "repo must be a valid DID: "+err.Error())
+ var repoDID string
+ if repo != "" {
+ // repo may be a DID or a handle; resolve handles to DIDs so the
+ // model only ever sees DIDs.
+ resolved, err := s.resolveStreamer(ctx, repo)
+ if err != nil {
+ return nil, echo.NewHTTPError(http.StatusBadRequest, "could not resolve repo: "+err.Error())
+ }
+ repoDID = resolved
}
l := 25
@@ -25,7 +30,7 @@ func (s *Server) handlePlaceStreamMediaGetVideoList(ctx context.Context, cursor
l = limit
}
- out, err := s.model.GetVideoList(ctx, repo, l, cursor)
+ out, err := s.model.GetVideoList(ctx, repoDID, l, cursor)
if err != nil {
return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
diff --git a/pkg/streamplace/mediagetVideoList.go b/pkg/streamplace/mediagetVideoList.go
index 9a8940d6a..6d23b3b96 100644
--- a/pkg/streamplace/mediagetVideoList.go
+++ b/pkg/streamplace/mediagetVideoList.go
@@ -21,7 +21,7 @@ type MediaGetVideoList_Output struct {
//
// cursor: Pagination cursor from a previous response.
// limit: Maximum number of videos to return.
-// repo: DID of the repo whose videos to list.
+// repo: DID or handle of the repo whose videos to list. Omit to list videos globally across all repos.
func MediaGetVideoList(ctx context.Context, c lexutil.LexClient, cursor string, limit int64, repo string) (*MediaGetVideoList_Output, error) {
var out MediaGetVideoList_Output
@@ -32,7 +32,9 @@ func MediaGetVideoList(ctx context.Context, c lexutil.LexClient, cursor string,
if limit != 0 {
params["limit"] = limit
}
- params["repo"] = repo
+ if repo != "" {
+ params["repo"] = repo
+ }
if err := c.LexDo(ctx, lexutil.Query, "", "place.stream.media.getVideoList", params, nil, &out); err != nil {
return nil, err
}
--
2.51.2
From 9801086be12d0a134fcab934b0832de31204ef9e Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Sat, 30 May 2026 17:10:53 -0700
Subject: [PATCH 12/47] beta: implement place.stream.beta.request
---
js/app/components/sidebar/sidebar-overlay.tsx | 8 +
js/app/components/upload/beta-access-gate.tsx | 131 ++++++++++++++
js/app/src/screens/upload.tsx | 28 +++
js/components/src/hooks/index.ts | 1 +
js/components/src/hooks/useBeta.ts | 73 ++++++++
.../beta/place-stream-beta-getstatus.md | 101 +++++++++++
.../beta/place-stream-beta-request.md | 58 +++++++
.../content/docs/lex-reference/openapi.json | 82 +++++++++
lexicons/place/stream/beta/getStatus.json | 54 ++++++
lexicons/place/stream/beta/request.json | 26 +++
pkg/atproto/firehose.go | 7 +
pkg/atproto/sync.go | 65 +++++++
pkg/constants/constants.go | 1 +
pkg/gen/gen.go | 1 +
pkg/model/beta_request.go | 70 ++++++++
pkg/model/model.go | 4 +
pkg/spxrpc/beta_invite.go | 60 +++++--
pkg/spxrpc/place_stream_beta.go | 47 +++++
pkg/spxrpc/stubs.go | 16 ++
pkg/streamplace/betagetStatus.go | 40 +++++
pkg/streamplace/betarequest.go | 21 +++
pkg/streamplace/cbor_gen.go | 164 ++++++++++++++++++
22 files changed, 1045 insertions(+), 13 deletions(-)
create mode 100644 js/app/components/upload/beta-access-gate.tsx
create mode 100644 js/components/src/hooks/useBeta.ts
create mode 100644 js/docs/src/content/docs/lex-reference/beta/place-stream-beta-getstatus.md
create mode 100644 js/docs/src/content/docs/lex-reference/beta/place-stream-beta-request.md
create mode 100644 lexicons/place/stream/beta/getStatus.json
create mode 100644 lexicons/place/stream/beta/request.json
create mode 100644 pkg/model/beta_request.go
create mode 100644 pkg/spxrpc/place_stream_beta.go
create mode 100644 pkg/streamplace/betagetStatus.go
create mode 100644 pkg/streamplace/betarequest.go
diff --git a/js/app/components/sidebar/sidebar-overlay.tsx b/js/app/components/sidebar/sidebar-overlay.tsx
index 1e7699729..def95f6cb 100644
--- a/js/app/components/sidebar/sidebar-overlay.tsx
+++ b/js/app/components/sidebar/sidebar-overlay.tsx
@@ -25,6 +25,7 @@ import {
LogIn,
Settings as SettingsIcon,
ShieldQuestion,
+ Upload,
Video,
} from "lucide-react-native";
import React, { useEffect, useState } from "react";
@@ -163,6 +164,13 @@ export function SidebarOverlay() {
href: "/video",
matchPrefix: "/video",
},
+ {
+ icon: () => ,
+ label: Upload,
+ href: "/upload",
+ matchPrefix: "/upload",
+ hidden: !did,
+ },
{
icon: () => ,
label: What's Streamplace?,
diff --git a/js/app/components/upload/beta-access-gate.tsx b/js/app/components/upload/beta-access-gate.tsx
new file mode 100644
index 000000000..9b7a16e3e
--- /dev/null
+++ b/js/app/components/upload/beta-access-gate.tsx
@@ -0,0 +1,131 @@
+import { useLinkTo } from "@react-navigation/native";
+import {
+ Button,
+ Text,
+ useRequestBetaAccess,
+ useTheme,
+ View,
+ zero,
+ type BetaStatus,
+} from "@streamplace/components";
+import { Clapperboard, Clock } from "lucide-react-native";
+import { useState } from "react";
+
+// Shown on the upload page when the logged-in account hasn't been granted VOD
+// upload access. For "none" it offers a request button that publishes a
+// place.stream.beta.request; for "requested" it confirms the request is on
+// file and points users at the app, where the grant notification lands.
+export default function BetaAccessGate({
+ feature,
+ status,
+ onRequested,
+}: {
+ feature: string;
+ status: Exclude;
+ onRequested: () => void;
+}) {
+ const { theme } = useTheme();
+ const linkTo = useLinkTo();
+ const requestAccess = useRequestBetaAccess(feature);
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+
+ const handleRequest = async () => {
+ setSubmitting(true);
+ setError(null);
+ try {
+ await requestAccess();
+ onRequested();
+ } catch (e: any) {
+ console.error("failed to request beta access", e);
+ setError(e?.message ?? "Something went wrong. Please try again.");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const requested = status === "requested";
+ const Icon = requested ? Clock : Clapperboard;
+
+ return (
+
+
+
+
+
+
+
+ {requested ? "Access requested!" : "Video uploads are in beta"}
+
+
+
+ {requested ? (
+ <>
+ Download and log in to{" "}
+ linkTo("/download")}
+ style={{
+ color: theme.colors.primary,
+ textDecorationLine: "underline",
+ }}
+ >
+ the Streamplace app
+ {" "}
+ to get notified the moment that you get access.
+ >
+ ) : (
+ "Uploading videos to Streamplace is invite-only for the moment. Request access and we'll notify you when you're off the waitlist."
+ )}
+
+
+ {!requested && (
+
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ );
+}
diff --git a/js/app/src/screens/upload.tsx b/js/app/src/screens/upload.tsx
index 6809f39dd..28173e3eb 100644
--- a/js/app/src/screens/upload.tsx
+++ b/js/app/src/screens/upload.tsx
@@ -9,6 +9,7 @@ import {
Select,
Text,
Tooltip,
+ useBetaStatus,
useTheme,
View,
zero,
@@ -19,6 +20,8 @@ import {
} from "@streamplace/components/src/lib/metadata-constants";
import { usePDSAgent } from "@streamplace/components/src/streamplace-store/xrpc";
import ActivityPicker from "components/activity-picker";
+import Loading from "components/loading/loading";
+import BetaAccessGate from "components/upload/beta-access-gate";
import { Image } from "expo-image";
import {
AlertCircle,
@@ -99,8 +102,15 @@ const POLL_INTERVAL_MS = 3000;
// ── screen ───────────────────────────────────────────────────────────────────
+const BETA_FEATURE = "vod";
+
export default function UploadScreen() {
const agent = usePDSAgent();
+ const {
+ status: betaStatus,
+ loading: betaLoading,
+ markRequested,
+ } = useBetaStatus(BETA_FEATURE);
const { theme, zero: zt } = useTheme();
const { width } = useWindowDimensions();
const isWide = width > 800;
@@ -578,6 +588,24 @@ export default function UploadScreen() {
// ── render ────────────────────────────────────────────────────────────────
+ // Beta gate: hold the upload UI until we know the account's access status.
+ // While we're still resolving it for a logged-in user, show a spinner; if
+ // they're not granted, swap the whole page for the request-access flow.
+ // Logged-out users keep the legacy behavior (status stays null) and are
+ // prompted to log in only when they actually try to upload.
+ if (agent?.did && betaStatus === null && betaLoading) {
+ return ;
+ }
+ if (betaStatus === "none" || betaStatus === "requested") {
+ return (
+
+ );
+ }
+
return (
diff --git a/js/components/src/hooks/index.ts b/js/components/src/hooks/index.ts
index 0b54ae01f..80de4745e 100644
--- a/js/components/src/hooks/index.ts
+++ b/js/components/src/hooks/index.ts
@@ -3,6 +3,7 @@ export * from "./useAQState";
export * from "./useAuthor";
export * from "./useAvatar";
export * from "./useAvatars";
+export * from "./useBeta";
export * from "./useCameraToggle";
export * from "./useDocumentTitle";
export * from "./useKeyboard";
diff --git a/js/components/src/hooks/useBeta.ts b/js/components/src/hooks/useBeta.ts
new file mode 100644
index 000000000..4354dabff
--- /dev/null
+++ b/js/components/src/hooks/useBeta.ts
@@ -0,0 +1,73 @@
+import { useCallback, useEffect, useState } from "react";
+import { PlaceStreamBetaRequest } from "streamplace";
+import { useDID } from "../streamplace-store/streamplace-store";
+import {
+ usePDSAgent,
+ usePossiblyUnauthedPDSAgent,
+} from "../streamplace-store/xrpc";
+
+export type BetaStatus = "granted" | "requested" | "none";
+
+// Reports the logged-in account's access status for a named beta feature
+// (e.g. "vod") via place.stream.beta.getStatus. The DID is passed explicitly
+// so the query works against the unauthenticated node agent. Returns
+// status=null while loading or when logged out.
+export function useBetaStatus(feature: string) {
+ const agent = usePossiblyUnauthedPDSAgent();
+ const did = useDID();
+ const [status, setStatus] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const refetch = useCallback(async () => {
+ if (!agent || !did) {
+ setStatus(null);
+ setLoading(false);
+ return;
+ }
+ setLoading(true);
+ setError(null);
+ try {
+ const res = await agent.place.stream.beta.getStatus({ feature, did });
+ setStatus(res.data.status as BetaStatus);
+ } catch (e: any) {
+ console.error("error fetching beta status", e);
+ setError(e?.message ?? "failed to load beta status");
+ } finally {
+ setLoading(false);
+ }
+ }, [agent, did, feature]);
+
+ useEffect(() => {
+ refetch();
+ }, [refetch]);
+
+ // Optimistically reflect a just-published request without waiting for the
+ // firehose to index it and getStatus to catch up.
+ const markRequested = useCallback(() => setStatus("requested"), []);
+
+ return { status, loading, error, refetch, markRequested, did };
+}
+
+// Publishes a place.stream.beta.request record in the caller's own repo,
+// asking for access to the named feature. The requesting account is the
+// record's authority, so no subject field is needed.
+export function useRequestBetaAccess(feature: string) {
+ const agent = usePDSAgent();
+ const did = useDID();
+ return useCallback(async () => {
+ if (!agent || !did) {
+ throw new Error("not logged in");
+ }
+ const record: PlaceStreamBetaRequest.Record = {
+ $type: "place.stream.beta.request",
+ feature,
+ createdAt: new Date().toISOString(),
+ };
+ return await agent.com.atproto.repo.createRecord({
+ repo: did,
+ collection: "place.stream.beta.request",
+ record,
+ });
+ }, [agent, did, feature]);
+}
diff --git a/js/docs/src/content/docs/lex-reference/beta/place-stream-beta-getstatus.md b/js/docs/src/content/docs/lex-reference/beta/place-stream-beta-getstatus.md
new file mode 100644
index 000000000..5871ce99a
--- /dev/null
+++ b/js/docs/src/content/docs/lex-reference/beta/place-stream-beta-getstatus.md
@@ -0,0 +1,101 @@
+---
+title: place.stream.beta.getStatus
+description: Reference for the place.stream.beta.getStatus lexicon
+---
+
+**Lexicon Version:** 1
+
+## Definitions
+
+
+
+### `main`
+
+**Type:** `query`
+
+Report an account's access status for a named beta feature: whether it has been granted (via a trusted place.stream.beta.invite, or because this node isn't beta-gating the feature), has an outstanding place.stream.beta.request, or neither. Defaults to the authenticated caller when `did` is omitted.
+
+**Parameters:**
+
+| Name | Type | Req'd | Description | Constraints |
+| --------- | -------- | ----- | -------------------------------------------------------------------------- | ------------- |
+| `feature` | `string` | ✅ | Identifier of the beta feature to check (e.g. "vod"). | |
+| `did` | `string` | ❌ | Account to check. Defaults to the authenticated caller's DID when omitted. | Format: `did` |
+
+**Output:**
+
+- **Encoding:** `application/json`
+- **Schema:**
+
+**Schema Type:** `object`
+
+| Name | Type | Req'd | Description | Constraints |
+| --------- | -------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- |
+| `feature` | `string` | ✅ | The feature this status applies to (echoes the request). | |
+| `did` | `string` | ✅ | The account this status applies to. | Format: `did` |
+| `status` | `string` | ✅ | granted: the account may use the feature. requested: no grant yet, but an access request is on file. none: no grant and no request. | Known Values: `granted`, `requested`, `none` |
+
+**Possible Errors:**
+
+- `DidRequired`: No did supplied and the request was not authenticated, so there is no account to check.
+
+---
+
+## Lexicon Source
+
+```json
+{
+ "lexicon": 1,
+ "id": "place.stream.beta.getStatus",
+ "defs": {
+ "main": {
+ "type": "query",
+ "description": "Report an account's access status for a named beta feature: whether it has been granted (via a trusted place.stream.beta.invite, or because this node isn't beta-gating the feature), has an outstanding place.stream.beta.request, or neither. Defaults to the authenticated caller when `did` is omitted.",
+ "parameters": {
+ "type": "params",
+ "required": ["feature"],
+ "properties": {
+ "feature": {
+ "type": "string",
+ "description": "Identifier of the beta feature to check (e.g. \"vod\")."
+ },
+ "did": {
+ "type": "string",
+ "format": "did",
+ "description": "Account to check. Defaults to the authenticated caller's DID when omitted."
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["feature", "did", "status"],
+ "properties": {
+ "feature": {
+ "type": "string",
+ "description": "The feature this status applies to (echoes the request)."
+ },
+ "did": {
+ "type": "string",
+ "format": "did",
+ "description": "The account this status applies to."
+ },
+ "status": {
+ "type": "string",
+ "knownValues": ["granted", "requested", "none"],
+ "description": "granted: the account may use the feature. requested: no grant yet, but an access request is on file. none: no grant and no request."
+ }
+ }
+ }
+ },
+ "errors": [
+ {
+ "name": "DidRequired",
+ "description": "No did supplied and the request was not authenticated, so there is no account to check."
+ }
+ ]
+ }
+ }
+}
+```
diff --git a/js/docs/src/content/docs/lex-reference/beta/place-stream-beta-request.md b/js/docs/src/content/docs/lex-reference/beta/place-stream-beta-request.md
new file mode 100644
index 000000000..76fe65688
--- /dev/null
+++ b/js/docs/src/content/docs/lex-reference/beta/place-stream-beta-request.md
@@ -0,0 +1,58 @@
+---
+title: place.stream.beta.request
+description: Reference for the place.stream.beta.request lexicon
+---
+
+**Lexicon Version:** 1
+
+## Definitions
+
+
+
+### `main`
+
+**Type:** `record`
+
+Requests access to a named beta feature on this network. Published in the requester's own repo, so the requesting account is the record's authority — there is no separate subject field. Operators index these to surface who is waiting for access; granting access is a separate place.stream.beta.invite record issued by the operator-trusted issuer. Each (requester, feature) pair gets its own record.
+
+**Record Key:** `tid`
+
+**Record Properties:**
+
+| Name | Type | Req'd | Description | Constraints |
+| ----------- | -------- | ----- | --------------------------------------------------------------------------------------------------------------------- | ------------------ |
+| `feature` | `string` | ✅ | Identifier of the beta feature being requested (e.g. "vod"). Mirrors the `feature` field on place.stream.beta.invite. | |
+| `createdAt` | `string` | ✅ | Client-declared timestamp when this request was made. | Format: `datetime` |
+
+---
+
+## Lexicon Source
+
+```json
+{
+ "lexicon": 1,
+ "id": "place.stream.beta.request",
+ "defs": {
+ "main": {
+ "type": "record",
+ "description": "Requests access to a named beta feature on this network. Published in the requester's own repo, so the requesting account is the record's authority — there is no separate subject field. Operators index these to surface who is waiting for access; granting access is a separate place.stream.beta.invite record issued by the operator-trusted issuer. Each (requester, feature) pair gets its own record.",
+ "key": "tid",
+ "record": {
+ "type": "object",
+ "required": ["feature", "createdAt"],
+ "properties": {
+ "feature": {
+ "type": "string",
+ "description": "Identifier of the beta feature being requested (e.g. \"vod\"). Mirrors the `feature` field on place.stream.beta.invite."
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "datetime",
+ "description": "Client-declared timestamp when this request was made."
+ }
+ }
+ }
+ }
+ }
+}
+```
diff --git a/js/docs/src/content/docs/lex-reference/openapi.json b/js/docs/src/content/docs/lex-reference/openapi.json
index f1b3411ed..04df7d198 100644
--- a/js/docs/src/content/docs/lex-reference/openapi.json
+++ b/js/docs/src/content/docs/lex-reference/openapi.json
@@ -3612,6 +3612,88 @@
}
}
},
+ "/xrpc/place.stream.beta.getStatus": {
+ "get": {
+ "summary": "Report an account's access status for a named beta feature: whether it has been granted (via a trusted place.stream.beta.invite, or because this node isn't beta-gating the feature), has an outstanding place.stream.beta.request, or neither. Defaults to the authenticated caller when `did` is omitted.",
+ "operationId": "place.stream.beta.getStatus",
+ "tags": ["place.stream.beta"],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "feature": {
+ "type": "string",
+ "description": "The feature this status applies to (echoes the request)."
+ },
+ "did": {
+ "type": "string",
+ "description": "The account this status applies to.",
+ "format": "did"
+ },
+ "status": {
+ "type": "string",
+ "description": "granted: the account may use the feature. requested: no grant yet, but an access request is on file. none: no grant and no request."
+ }
+ },
+ "required": ["feature", "did", "status"]
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["error", "message"],
+ "properties": {
+ "error": {
+ "type": "string",
+ "oneOf": [
+ {
+ "const": "DidRequired"
+ }
+ ]
+ },
+ "message": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "parameters": [
+ {
+ "name": "feature",
+ "in": "query",
+ "required": true,
+ "description": "Identifier of the beta feature to check (e.g. \"vod\").",
+ "schema": {
+ "type": "string",
+ "description": "Identifier of the beta feature to check (e.g. \"vod\")."
+ }
+ },
+ {
+ "name": "did",
+ "in": "query",
+ "required": false,
+ "description": "Account to check. Defaults to the authenticated caller's DID when omitted.",
+ "schema": {
+ "type": "string",
+ "description": "Account to check. Defaults to the authenticated caller's DID when omitted.",
+ "format": "did"
+ }
+ }
+ ]
+ }
+ },
"/xrpc/place.stream.badge.getIssuedBadges": {
"get": {
"summary": "Returns badges issued to the authenticated user, organized by display slot. Also indicates which badges are currently selected in the user's chat profile.",
diff --git a/lexicons/place/stream/beta/getStatus.json b/lexicons/place/stream/beta/getStatus.json
new file mode 100644
index 000000000..0863957da
--- /dev/null
+++ b/lexicons/place/stream/beta/getStatus.json
@@ -0,0 +1,54 @@
+{
+ "lexicon": 1,
+ "id": "place.stream.beta.getStatus",
+ "defs": {
+ "main": {
+ "type": "query",
+ "description": "Report an account's access status for a named beta feature: whether it has been granted (via a trusted place.stream.beta.invite, or because this node isn't beta-gating the feature), has an outstanding place.stream.beta.request, or neither. Defaults to the authenticated caller when `did` is omitted.",
+ "parameters": {
+ "type": "params",
+ "required": ["feature"],
+ "properties": {
+ "feature": {
+ "type": "string",
+ "description": "Identifier of the beta feature to check (e.g. \"vod\")."
+ },
+ "did": {
+ "type": "string",
+ "format": "did",
+ "description": "Account to check. Defaults to the authenticated caller's DID when omitted."
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["feature", "did", "status"],
+ "properties": {
+ "feature": {
+ "type": "string",
+ "description": "The feature this status applies to (echoes the request)."
+ },
+ "did": {
+ "type": "string",
+ "format": "did",
+ "description": "The account this status applies to."
+ },
+ "status": {
+ "type": "string",
+ "knownValues": ["granted", "requested", "none"],
+ "description": "granted: the account may use the feature. requested: no grant yet, but an access request is on file. none: no grant and no request."
+ }
+ }
+ }
+ },
+ "errors": [
+ {
+ "name": "DidRequired",
+ "description": "No did supplied and the request was not authenticated, so there is no account to check."
+ }
+ ]
+ }
+ }
+}
diff --git a/lexicons/place/stream/beta/request.json b/lexicons/place/stream/beta/request.json
new file mode 100644
index 000000000..084ad0be0
--- /dev/null
+++ b/lexicons/place/stream/beta/request.json
@@ -0,0 +1,26 @@
+{
+ "lexicon": 1,
+ "id": "place.stream.beta.request",
+ "defs": {
+ "main": {
+ "type": "record",
+ "description": "Requests access to a named beta feature on this network. Published in the requester's own repo, so the requesting account is the record's authority — there is no separate subject field. Operators index these to surface who is waiting for access; granting access is a separate place.stream.beta.invite record issued by the operator-trusted issuer. Each (requester, feature) pair gets its own record.",
+ "key": "tid",
+ "record": {
+ "type": "object",
+ "required": ["feature", "createdAt"],
+ "properties": {
+ "feature": {
+ "type": "string",
+ "description": "Identifier of the beta feature being requested (e.g. \"vod\"). Mirrors the `feature` field on place.stream.beta.invite."
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "datetime",
+ "description": "Client-declared timestamp when this request was made."
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/pkg/atproto/firehose.go b/pkg/atproto/firehose.go
index 9867cd23b..cf5a0c217 100644
--- a/pkg/atproto/firehose.go
+++ b/pkg/atproto/firehose.go
@@ -398,6 +398,13 @@ func (atsync *ATProtoSynchronizer) handleCommitEventOps(ctx context.Context, evt
}
}
+ if collection.String() == constants.PLACE_STREAM_BETA_REQUEST {
+ log.Debug(ctx, "deleting beta request", "uri", uri)
+ if err := atsync.Model.DeleteBetaRequest(ctx, uri); err != nil {
+ log.Error(ctx, "failed to delete beta request", "err", err)
+ }
+ }
+
if collection.String() == constants.PLACE_STREAM_MEDIA_VIEW_COUNT {
log.Debug(ctx, "deleting media view count", "uri", uri)
if err := atsync.Model.DeleteMediaViewCount(ctx, uri); err != nil {
diff --git a/pkg/atproto/sync.go b/pkg/atproto/sync.go
index 7649394e4..2797705e3 100644
--- a/pkg/atproto/sync.go
+++ b/pkg/atproto/sync.go
@@ -17,6 +17,7 @@ import (
"stream.place/streamplace/pkg/constants"
"stream.place/streamplace/pkg/log"
"stream.place/streamplace/pkg/model"
+ notificationpkg "stream.place/streamplace/pkg/notifications"
"stream.place/streamplace/pkg/statedb"
"stream.place/streamplace/pkg/streamplace"
@@ -770,6 +771,24 @@ func (atsync *ATProtoSynchronizer) handleCreateUpdate(ctx context.Context, userD
}
log.Debug(ctx, "indexed beta invite", "uri", aturi.String(), "did", rec.Did, "feature", rec.Feature)
+ // Notify the invited account that they're off the waitlist — but
+ // only for a genuinely new invite arriving live from the trusted
+ // issuer. Backfill/first-sync and record updates re-index existing
+ // invites on every restart and must not re-notify.
+ if !isFirstSync && !isUpdate &&
+ atsync.CLI.BetaInviteDID != "" && userDID == atsync.CLI.BetaInviteDID {
+ atsync.notifyBetaInvite(ctx, rec)
+ }
+
+ case *streamplace.BetaRequest:
+ // Access requests are published by users in their own repos. We
+ // index them so operators can see who's waiting and so
+ // place.stream.beta.getStatus can report "requested".
+ if err := atsync.Model.UpsertBetaRequest(ctx, rec, aturi); err != nil {
+ return fmt.Errorf("failed to upsert beta request: %w", err)
+ }
+ log.Debug(ctx, "indexed beta request", "uri", aturi.String(), "did", userDID, "feature", rec.Feature)
+
case *streamplace.MediaViewCount:
// View-count records are published by streamplace nodes (in
// their server repos) reporting on traffic they observed.
@@ -913,3 +932,49 @@ func (atsync *ATProtoSynchronizer) handleCreateUpdate(ctx context.Context, userD
}
return nil
}
+
+// notifyBetaInvite pushes a "you're off the waitlist" notification to the
+// account named by a freshly-issued, trusted beta invite. Best-effort: any
+// failure is logged, never returned, since the invite is already indexed and
+// the upload gate works regardless of whether the push lands.
+func (atsync *ATProtoSynchronizer) notifyBetaInvite(ctx context.Context, rec *streamplace.BetaInvite) {
+ if atsync.Noter == nil || atsync.StatefulDB == nil {
+ return
+ }
+ tokens, err := atsync.StatefulDB.GetManyNotificationTokens([]string{rec.Did})
+ if err != nil {
+ log.Error(ctx, "beta invite notification: failed to load tokens", "did", rec.Did, "err", err)
+ return
+ }
+ if len(tokens) == 0 {
+ log.Debug(ctx, "beta invite notification: no device tokens for invitee", "did", rec.Did, "feature", rec.Feature)
+ return
+ }
+ blast := betaInviteBlast(rec.Feature)
+ if err := atsync.Noter.Blast(ctx, tokens, blast); err != nil {
+ log.Error(ctx, "beta invite notification: blast failed", "did", rec.Did, "feature", rec.Feature, "err", err)
+ return
+ }
+ log.Log(ctx, "sent beta invite notification", "did", rec.Did, "feature", rec.Feature, "tokens", len(tokens))
+}
+
+// betaInviteBlast builds the push payload for a newly-granted beta feature.
+// Copy is feature-aware where we have something specific to say.
+func betaInviteBlast(feature string) *notificationpkg.NotificationBlast {
+ switch feature {
+ case "vod":
+ // Uploads are a web flow today, and pushes land on the native app, so
+ // we route to home rather than a route the app doesn't register.
+ return ¬ificationpkg.NotificationBlast{
+ Title: "🎉 You're off the waitlist!",
+ Body: "You can now upload videos to Streamplace.",
+ Data: map[string]string{"path": "/"},
+ }
+ default:
+ return ¬ificationpkg.NotificationBlast{
+ Title: "🎉 You're off the waitlist!",
+ Body: fmt.Sprintf("You've been granted access to the %s beta.", feature),
+ Data: map[string]string{"path": "/"},
+ }
+ }
+}
diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go
index 6880168c6..c45783a19 100644
--- a/pkg/constants/constants.go
+++ b/pkg/constants/constants.go
@@ -26,6 +26,7 @@ var PLACE_STREAM_MEDIA_TRACK = "place.stream.media.track" //
var PLACE_STREAM_MEDIA_ORIGIN = "place.stream.media.origin" //nolint:all
var PLACE_STREAM_MEDIA_VIEW_COUNT = "place.stream.media.viewCount" //nolint:all
var PLACE_STREAM_BETA_INVITE = "place.stream.beta.invite" //nolint:all
+var PLACE_STREAM_BETA_REQUEST = "place.stream.beta.request" //nolint:all
var PLACE_STREAM_VOD_COMMENT = "place.stream.vod.comment" //nolint:all
var PLACE_STREAM_VOD_LIKE = "place.stream.vod.like" //nolint:all
var PLACE_STREAM_VOD_GATE = "place.stream.vod.gate" //nolint:all
diff --git a/pkg/gen/gen.go b/pkg/gen/gen.go
index a108d3000..5da96a058 100644
--- a/pkg/gen/gen.go
+++ b/pkg/gen/gen.go
@@ -56,6 +56,7 @@ func main() {
streamplace.MediaViewCount_TrackUsage{},
streamplace.Video_Connection{},
streamplace.BetaInvite{},
+ streamplace.BetaRequest{},
streamplace.VodComment{},
streamplace.VodComment_ReplyRef{},
streamplace.VodLike{},
diff --git a/pkg/model/beta_request.go b/pkg/model/beta_request.go
new file mode 100644
index 000000000..eb04a5dbe
--- /dev/null
+++ b/pkg/model/beta_request.go
@@ -0,0 +1,70 @@
+package model
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/bluesky-social/indigo/atproto/syntax"
+ "stream.place/streamplace/pkg/aqtime"
+ "stream.place/streamplace/pkg/spid"
+ "stream.place/streamplace/pkg/streamplace"
+)
+
+// BetaRequest is the indexed view of a place.stream.beta.request
+// record. Unlike an invite, a request is published in the requester's
+// own repo, so the requesting account *is* the record's authority —
+// RepoDID is both who published it and who it's about. One row per
+// (RepoDID, Feature) pair; both columns sit in the composite index
+// since HasBetaRequest filters by exactly that shape.
+type BetaRequest struct {
+ URI string `gorm:"primaryKey;column:uri"`
+ CID string `gorm:"column:cid"`
+ RepoDID string `gorm:"column:repo_did;index:idx_requests_lookup,priority:1"`
+ Feature string `gorm:"column:feature;index:idx_requests_lookup,priority:2"`
+ Record []byte `gorm:"column:record"`
+ IndexedAt time.Time `gorm:"column:indexed_at"`
+}
+
+func (m *DBModel) UpsertBetaRequest(ctx context.Context, rec *streamplace.BetaRequest, aturi syntax.ATURI) error {
+ repoDID, err := aturi.Authority().AsDID()
+ if err != nil {
+ return fmt.Errorf("invalid ATURI authority: %w", err)
+ }
+ cid, err := spid.GetCID(rec)
+ if err != nil {
+ return fmt.Errorf("get beta request CID: %w", err)
+ }
+ var buf bytes.Buffer
+ if err := rec.MarshalCBOR(&buf); err != nil {
+ return fmt.Errorf("marshal beta request record: %w", err)
+ }
+ req := &BetaRequest{
+ URI: aturi.String(),
+ CID: cid.String(),
+ RepoDID: repoDID.String(),
+ Feature: rec.Feature,
+ Record: buf.Bytes(),
+ IndexedAt: aqtime.FromTime(time.Now().UTC()).Time().UTC(),
+ }
+ return m.DB.WithContext(ctx).Save(req).Error
+}
+
+func (m *DBModel) DeleteBetaRequest(ctx context.Context, uri string) error {
+ return m.DB.WithContext(ctx).Where("uri = ?", uri).Delete(&BetaRequest{}).Error
+}
+
+// HasBetaRequest reports whether `subjectDID` has an outstanding
+// place.stream.beta.request record on file for `feature`.
+func (m *DBModel) HasBetaRequest(ctx context.Context, subjectDID, feature string) (bool, error) {
+ var count int64
+ err := m.DB.WithContext(ctx).
+ Model(&BetaRequest{}).
+ Where("repo_did = ? AND feature = ?", subjectDID, feature).
+ Count(&count).Error
+ if err != nil {
+ return false, fmt.Errorf("has beta request: %w", err)
+ }
+ return count > 0, nil
+}
diff --git a/pkg/model/model.go b/pkg/model/model.go
index ddc01182d..69d1d7e83 100644
--- a/pkg/model/model.go
+++ b/pkg/model/model.go
@@ -151,6 +151,9 @@ type Model interface {
UpsertBetaInvite(ctx context.Context, rec *streamplace.BetaInvite, aturi syntax.ATURI) error
DeleteBetaInvite(ctx context.Context, uri string) error
HasBetaInvite(ctx context.Context, fromRepoDID, subjectDID, feature string) (bool, error)
+ UpsertBetaRequest(ctx context.Context, rec *streamplace.BetaRequest, aturi syntax.ATURI) error
+ DeleteBetaRequest(ctx context.Context, uri string) error
+ HasBetaRequest(ctx context.Context, subjectDID, feature string) (bool, error)
UpsertMediaViewCount(ctx context.Context, rec *streamplace.MediaViewCount, aturi syntax.ATURI) error
DeleteMediaViewCount(ctx context.Context, uri string) error
@@ -254,6 +257,7 @@ func MakeDB(dbURL string) (Model, error) {
MediaOrigin{},
MediaViewCount{},
BetaInvite{},
+ BetaRequest{},
VodComment{},
VodLike{},
VodGate{},
diff --git a/pkg/spxrpc/beta_invite.go b/pkg/spxrpc/beta_invite.go
index e0e994432..b15ea626d 100644
--- a/pkg/spxrpc/beta_invite.go
+++ b/pkg/spxrpc/beta_invite.go
@@ -10,16 +10,17 @@ import (
// access. New beta features should pick their own short identifier.
const vodInviteFeature = "vod"
-// allowVODUpload is the gate that runs on every VOD upload attempt.
-// Returns nil when the caller is allowed; otherwise a forbidden-style
-// error suitable for surfacing back to the client.
+// betaFeatureGranted reports whether `did` may use `feature`. This is the
+// single source of truth that both the upload gate and the
+// place.stream.beta.getStatus query consult, so the gate and the status the
+// UI shows can never disagree.
//
// The policy mirrors what the user-facing live-stream gate does:
//
// - If --beta-invite-did is configured, that account is the sole
-// trusted issuer of upload invites. We require an indexed
+// trusted issuer of feature invites. We require an indexed
// place.stream.beta.invite record under its repo naming this DID
-// with feature == "vod"; nothing else gets through.
+// with the given feature; nothing else gets through.
//
// - If --beta-invite-did is empty (self-hosted / dev), we fall back
// to cli.StreamIsAllowed — same allowlist livestreaming uses,
@@ -27,19 +28,52 @@ const vodInviteFeature = "vod"
// behavior. So a fresh dev node keeps working out of the box and
// a self-hoster who already locked down SP_ALLOWED_STREAMS for
// live keeps the same lockdown for uploads.
-func (s *Server) allowVODUpload(ctx context.Context, did string) error {
+func (s *Server) betaFeatureGranted(ctx context.Context, did, feature string) (bool, error) {
if s.cli.BetaInviteDID != "" {
- has, err := s.model.HasBetaInvite(ctx, s.cli.BetaInviteDID, did, vodInviteFeature)
+ has, err := s.model.HasBetaInvite(ctx, s.cli.BetaInviteDID, did, feature)
if err != nil {
- return fmt.Errorf("look up beta invite: %w", err)
+ return false, fmt.Errorf("look up beta invite: %w", err)
}
- if !has {
+ return has, nil
+ }
+ return s.cli.StreamIsAllowed(did) == nil, nil
+}
+
+// betaFeatureStatus reports an account's access status for a feature as one of
+// "granted", "requested", or "none". "granted" folds together both a trusted
+// invite and the open self-hosted fallback (see betaFeatureGranted); a pending
+// place.stream.beta.request downgrades "none" to "requested".
+func (s *Server) betaFeatureStatus(ctx context.Context, did, feature string) (string, error) {
+ granted, err := s.betaFeatureGranted(ctx, did, feature)
+ if err != nil {
+ return "", err
+ }
+ if granted {
+ return "granted", nil
+ }
+ requested, err := s.model.HasBetaRequest(ctx, did, feature)
+ if err != nil {
+ return "", fmt.Errorf("look up beta request: %w", err)
+ }
+ if requested {
+ return "requested", nil
+ }
+ return "none", nil
+}
+
+// allowVODUpload is the gate that runs on every VOD upload attempt.
+// Returns nil when the caller is allowed; otherwise a forbidden-style
+// error suitable for surfacing back to the client.
+func (s *Server) allowVODUpload(ctx context.Context, did string) error {
+ granted, err := s.betaFeatureGranted(ctx, did, vodInviteFeature)
+ if err != nil {
+ return err
+ }
+ if !granted {
+ if s.cli.BetaInviteDID != "" {
return fmt.Errorf("VOD upload is beta-gated; no invite found for %s", did)
}
- return nil
- }
- if err := s.cli.StreamIsAllowed(did); err != nil {
- return fmt.Errorf("VOD upload not allowed: %w", err)
+ return fmt.Errorf("VOD upload not allowed for %s", did)
}
return nil
}
diff --git a/pkg/spxrpc/place_stream_beta.go b/pkg/spxrpc/place_stream_beta.go
new file mode 100644
index 000000000..4c163aaa8
--- /dev/null
+++ b/pkg/spxrpc/place_stream_beta.go
@@ -0,0 +1,47 @@
+package spxrpc
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/bluesky-social/indigo/atproto/syntax"
+ "github.com/labstack/echo/v4"
+ "github.com/streamplace/oatproxy/pkg/oatproxy"
+
+ placestream "stream.place/streamplace/pkg/streamplace"
+)
+
+// handlePlaceStreamBetaGetStatus reports an account's access status for a
+// named beta feature: "granted", "requested", or "none". The subject defaults
+// to the authenticated caller when `did` is omitted, so the common case ("am
+// *I* allowed?") needs no parameter beyond the feature. The status is computed
+// by the same gate that createUpload enforces, so the UI can never claim
+// access the upload path would reject.
+func (s *Server) handlePlaceStreamBetaGetStatus(ctx context.Context, did, feature string) (*placestream.BetaGetStatus_Output, error) {
+ if feature == "" {
+ return nil, echo.NewHTTPError(http.StatusBadRequest, "feature is required")
+ }
+
+ subject := did
+ if subject == "" {
+ session, _ := oatproxy.GetOAuthSession(ctx)
+ if session == nil {
+ return nil, echo.NewHTTPError(http.StatusBadRequest, "DidRequired")
+ }
+ subject = session.DID
+ }
+ if _, err := syntax.ParseDID(subject); err != nil {
+ return nil, echo.NewHTTPError(http.StatusBadRequest, "did must be a valid DID: "+err.Error())
+ }
+
+ status, err := s.betaFeatureStatus(ctx, subject, feature)
+ if err != nil {
+ return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
+ }
+
+ return &placestream.BetaGetStatus_Output{
+ Did: subject,
+ Feature: feature,
+ Status: status,
+ }, nil
+}
diff --git a/pkg/spxrpc/stubs.go b/pkg/spxrpc/stubs.go
index 010c9a6e0..0d1ce7767 100644
--- a/pkg/spxrpc/stubs.go
+++ b/pkg/spxrpc/stubs.go
@@ -284,6 +284,7 @@ func (s *Server) RegisterHandlersGamesGamesgamesgamesgames(e *echo.Echo) error {
func (s *Server) RegisterHandlersPlaceStream(e *echo.Echo) error {
e.GET("/xrpc/place.stream.badge.getIssuedBadges", s.HandlePlaceStreamBadgeGetIssuedBadges)
e.GET("/xrpc/place.stream.badge.getValidBadges", s.HandlePlaceStreamBadgeGetValidBadges)
+ e.GET("/xrpc/place.stream.beta.getStatus", s.HandlePlaceStreamBetaGetStatus)
e.POST("/xrpc/place.stream.branding.deleteBlob", s.HandlePlaceStreamBrandingDeleteBlob)
e.GET("/xrpc/place.stream.branding.getBlob", s.HandlePlaceStreamBrandingGetBlob)
e.GET("/xrpc/place.stream.branding.getBranding", s.HandlePlaceStreamBrandingGetBranding)
@@ -368,6 +369,21 @@ func (s *Server) HandlePlaceStreamBadgeGetValidBadges(c echo.Context) error {
return c.JSON(200, out)
}
+func (s *Server) HandlePlaceStreamBetaGetStatus(c echo.Context) error {
+ ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamBetaGetStatus")
+ defer span.End()
+ did := c.QueryParam("did")
+ feature := c.QueryParam("feature")
+ var out *placestream.BetaGetStatus_Output
+ var handleErr error
+ // func (s *Server) handlePlaceStreamBetaGetStatus(ctx context.Context,did string,feature string) (*placestream.BetaGetStatus_Output, error)
+ out, handleErr = s.handlePlaceStreamBetaGetStatus(ctx, did, feature)
+ if handleErr != nil {
+ return handleErr
+ }
+ return c.JSON(200, out)
+}
+
func (s *Server) HandlePlaceStreamBrandingDeleteBlob(c echo.Context) error {
ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamBrandingDeleteBlob")
defer span.End()
diff --git a/pkg/streamplace/betagetStatus.go b/pkg/streamplace/betagetStatus.go
new file mode 100644
index 000000000..af3209257
--- /dev/null
+++ b/pkg/streamplace/betagetStatus.go
@@ -0,0 +1,40 @@
+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+// Lexicon schema: place.stream.beta.getStatus
+
+package streamplace
+
+import (
+ "context"
+
+ lexutil "github.com/bluesky-social/indigo/lex/util"
+)
+
+// BetaGetStatus_Output is the output of a place.stream.beta.getStatus call.
+type BetaGetStatus_Output struct {
+ // did: The account this status applies to.
+ Did string `json:"did" cborgen:"did"`
+ // feature: The feature this status applies to (echoes the request).
+ Feature string `json:"feature" cborgen:"feature"`
+ // status: granted: the account may use the feature. requested: no grant yet, but an access request is on file. none: no grant and no request.
+ Status string `json:"status" cborgen:"status"`
+}
+
+// BetaGetStatus calls the XRPC method "place.stream.beta.getStatus".
+//
+// did: Account to check. Defaults to the authenticated caller's DID when omitted.
+// feature: Identifier of the beta feature to check (e.g. "vod").
+func BetaGetStatus(ctx context.Context, c lexutil.LexClient, did string, feature string) (*BetaGetStatus_Output, error) {
+ var out BetaGetStatus_Output
+
+ params := map[string]interface{}{}
+ if did != "" {
+ params["did"] = did
+ }
+ params["feature"] = feature
+ if err := c.LexDo(ctx, lexutil.Query, "", "place.stream.beta.getStatus", params, nil, &out); err != nil {
+ return nil, err
+ }
+
+ return &out, nil
+}
diff --git a/pkg/streamplace/betarequest.go b/pkg/streamplace/betarequest.go
new file mode 100644
index 000000000..be63717f3
--- /dev/null
+++ b/pkg/streamplace/betarequest.go
@@ -0,0 +1,21 @@
+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+// Lexicon schema: place.stream.beta.request
+
+package streamplace
+
+import (
+ lexutil "github.com/bluesky-social/indigo/lex/util"
+)
+
+func init() {
+ lexutil.RegisterType("place.stream.beta.request", &BetaRequest{})
+}
+
+type BetaRequest struct {
+ LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.beta.request"`
+ // createdAt: Client-declared timestamp when this request was made.
+ CreatedAt string `json:"createdAt" cborgen:"createdAt"`
+ // feature: Identifier of the beta feature being requested (e.g. "vod"). Mirrors the `feature` field on place.stream.beta.invite.
+ Feature string `json:"feature" cborgen:"feature"`
+}
diff --git a/pkg/streamplace/cbor_gen.go b/pkg/streamplace/cbor_gen.go
index f47b0b308..5474d412b 100644
--- a/pkg/streamplace/cbor_gen.go
+++ b/pkg/streamplace/cbor_gen.go
@@ -11455,6 +11455,170 @@ func (t *BetaInvite) UnmarshalCBOR(r io.Reader) (err error) {
return nil
}
+func (t *BetaRequest) MarshalCBOR(w io.Writer) error {
+ if t == nil {
+ _, err := w.Write(cbg.CborNull)
+ return err
+ }
+
+ cw := cbg.NewCborWriter(w)
+
+ if _, err := cw.Write([]byte{163}); err != nil {
+ return err
+ }
+
+ // t.LexiconTypeID (string) (string)
+ if len("$type") > 1000000 {
+ return xerrors.Errorf("Value in field \"$type\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("$type")); err != nil {
+ return err
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("place.stream.beta.request"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("place.stream.beta.request")); err != nil {
+ return err
+ }
+
+ // t.Feature (string) (string)
+ if len("feature") > 1000000 {
+ return xerrors.Errorf("Value in field \"feature\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("feature"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("feature")); err != nil {
+ return err
+ }
+
+ if len(t.Feature) > 1000000 {
+ return xerrors.Errorf("Value in field t.Feature was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Feature))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string(t.Feature)); err != nil {
+ return err
+ }
+
+ // t.CreatedAt (string) (string)
+ if len("createdAt") > 1000000 {
+ return xerrors.Errorf("Value in field \"createdAt\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("createdAt")); err != nil {
+ return err
+ }
+
+ if len(t.CreatedAt) > 1000000 {
+ return xerrors.Errorf("Value in field t.CreatedAt was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (t *BetaRequest) UnmarshalCBOR(r io.Reader) (err error) {
+ *t = BetaRequest{}
+
+ cr := cbg.NewCborReader(r)
+
+ maj, extra, err := cr.ReadHeader()
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err == io.EOF {
+ err = io.ErrUnexpectedEOF
+ }
+ }()
+
+ if maj != cbg.MajMap {
+ return fmt.Errorf("cbor input should be of type map")
+ }
+
+ if extra > cbg.MaxLength {
+ return fmt.Errorf("BetaRequest: map struct too large (%d)", extra)
+ }
+
+ n := extra
+
+ nameBuf := make([]byte, 9)
+ for i := uint64(0); i < n; i++ {
+ nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
+ if err != nil {
+ return err
+ }
+
+ if !ok {
+ // Field doesn't exist on this type, so ignore it
+ if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
+ return err
+ }
+ continue
+ }
+
+ switch string(nameBuf[:nameLen]) {
+ // t.LexiconTypeID (string) (string)
+ case "$type":
+
+ {
+ sval, err := cbg.ReadStringWithMax(cr, 1000000)
+ if err != nil {
+ return err
+ }
+
+ t.LexiconTypeID = string(sval)
+ }
+ // t.Feature (string) (string)
+ case "feature":
+
+ {
+ sval, err := cbg.ReadStringWithMax(cr, 1000000)
+ if err != nil {
+ return err
+ }
+
+ t.Feature = string(sval)
+ }
+ // t.CreatedAt (string) (string)
+ case "createdAt":
+
+ {
+ sval, err := cbg.ReadStringWithMax(cr, 1000000)
+ if err != nil {
+ return err
+ }
+
+ t.CreatedAt = string(sval)
+ }
+
+ default:
+ // Field doesn't exist on this type, so ignore it
+ if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+}
func (t *VodComment) MarshalCBOR(w io.Writer) error {
if t == nil {
_, err := w.Write(cbg.CborNull)
--
2.51.2
From 9ebc4f961b3e8b6a5e3bdd3d136708d29f8d0ad3 Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Sat, 30 May 2026 18:16:06 -0700
Subject: [PATCH 13/47] docs: break self-referential commentView schema that
hung docs build
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
place.stream.vod.defs#commentView referenced itself via replyTo (a union
of #commentView). Pulled into openapi.json through getComments, that
circular $ref made starlight-openapi recurse until the stack overflowed,
hanging `pnpm run docs build` and blocking CI.
Point replyTo at a new non-recursive #commentViewBasic instead. This
matches what the backend already returns — GetCommentsForVideo only
preloads one level of ReplyTo — so it's behavior-preserving; walk
record.reply to follow a longer thread.
Co-Authored-By: Claude Opus 4.8
---
.../content/docs/lex-reference/openapi.json | 33 +++++++++-
.../vod/place-stream-vod-defs.md | 57 +++++++++++++++++-
lexicons/place/stream/vod/defs.json | 23 ++++++-
pkg/model/vod_comment.go | 60 +++++++++++++++----
pkg/streamplace/voddefs.go | 39 ++++++++----
5 files changed, 182 insertions(+), 30 deletions(-)
diff --git a/js/docs/src/content/docs/lex-reference/openapi.json b/js/docs/src/content/docs/lex-reference/openapi.json
index 04df7d198..58969855b 100644
--- a/js/docs/src/content/docs/lex-reference/openapi.json
+++ b/js/docs/src/content/docs/lex-reference/openapi.json
@@ -4953,9 +4953,10 @@
"replyTo": {
"oneOf": [
{
- "$ref": "#/components/schemas/place.stream.vod.defs_commentView"
+ "$ref": "#/components/schemas/place.stream.vod.defs_commentViewBasic"
}
- ]
+ ],
+ "description": "The parent comment this one replies to, if any. A non-recursive view (it carries no replyTo of its own), so the thread is flattened to a single hop; walk `record.reply` to follow the chain further."
},
"likeCount": {
"type": "integer",
@@ -5302,6 +5303,34 @@
},
"required": ["uri", "title", "description"]
},
+ "place.stream.vod.defs_commentViewBasic": {
+ "type": "object",
+ "description": "A comment view without its own `replyTo`, used to represent the parent of a reply without recursively nesting the whole thread.",
+ "properties": {
+ "uri": {
+ "type": "string",
+ "format": "uri"
+ },
+ "cid": {
+ "type": "string",
+ "format": "cid"
+ },
+ "author": {
+ "$ref": "#/components/schemas/app.bsky.actor.defs_profileViewBasic"
+ },
+ "record": {},
+ "indexedAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "likeCount": {
+ "type": "integer",
+ "description": "Number of likes on this comment.",
+ "minimum": 0
+ }
+ },
+ "required": ["uri", "cid", "author", "record", "indexedAt", "likeCount"]
+ },
"place.stream.vod.getLikes_likeView": {
"type": "object",
"properties": {
diff --git a/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-defs.md b/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-defs.md
index f9186b949..63a398a5e 100644
--- a/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-defs.md
+++ b/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-defs.md
@@ -15,6 +15,28 @@ description: Reference for the place.stream.vod.defs lexicon
**Properties:**
+| Name | Type | Req'd | Description | Constraints |
+| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ |
+| `uri` | `string` | ✅ | | Format: `at-uri` |
+| `cid` | `string` | ✅ | | Format: `cid` |
+| `author` | [`app.bsky.actor.defs#profileViewBasic`](https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky/actor/defs.json#profileViewBasic) | ✅ | | |
+| `record` | `unknown` | ✅ | | |
+| `indexedAt` | `string` | ✅ | | Format: `datetime` |
+| `replyTo` | Union of:
[`#commentViewBasic`](#commentviewbasic) | ❌ | The parent comment this one replies to, if any. A non-recursive view (it carries no replyTo of its own), so the thread is flattened to a single hop; walk `record.reply` to follow the chain further. | |
+| `likeCount` | `integer` | ✅ | Number of likes on this comment. | Min: 0 |
+
+---
+
+
+
+### `commentViewBasic`
+
+**Type:** `object`
+
+A comment view without its own `replyTo`, used to represent the parent of a reply without recursively nesting the whole thread.
+
+**Properties:**
+
| Name | Type | Req'd | Description | Constraints |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | -------------------------------- | ------------------ |
| `uri` | `string` | ✅ | | Format: `at-uri` |
@@ -22,7 +44,6 @@ description: Reference for the place.stream.vod.defs lexicon
| `author` | [`app.bsky.actor.defs#profileViewBasic`](https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky/actor/defs.json#profileViewBasic) | ✅ | | |
| `record` | `unknown` | ✅ | | |
| `indexedAt` | `string` | ✅ | | Format: `datetime` |
-| `replyTo` | Union of:
[`#commentView`](#commentview) | ❌ | | |
| `likeCount` | `integer` | ✅ | Number of likes on this comment. | Min: 0 |
---
@@ -59,7 +80,39 @@ description: Reference for the place.stream.vod.defs lexicon
},
"replyTo": {
"type": "union",
- "refs": ["#commentView"]
+ "refs": ["#commentViewBasic"],
+ "description": "The parent comment this one replies to, if any. A non-recursive view (it carries no replyTo of its own), so the thread is flattened to a single hop; walk `record.reply` to follow the chain further."
+ },
+ "likeCount": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "Number of likes on this comment."
+ }
+ }
+ },
+ "commentViewBasic": {
+ "type": "object",
+ "description": "A comment view without its own `replyTo`, used to represent the parent of a reply without recursively nesting the whole thread.",
+ "required": ["uri", "cid", "author", "record", "indexedAt", "likeCount"],
+ "properties": {
+ "uri": {
+ "type": "string",
+ "format": "at-uri"
+ },
+ "cid": {
+ "type": "string",
+ "format": "cid"
+ },
+ "author": {
+ "type": "ref",
+ "ref": "app.bsky.actor.defs#profileViewBasic"
+ },
+ "record": {
+ "type": "unknown"
+ },
+ "indexedAt": {
+ "type": "string",
+ "format": "datetime"
},
"likeCount": {
"type": "integer",
diff --git a/lexicons/place/stream/vod/defs.json b/lexicons/place/stream/vod/defs.json
index fd0c285a6..5bd77a339 100644
--- a/lexicons/place/stream/vod/defs.json
+++ b/lexicons/place/stream/vod/defs.json
@@ -16,7 +16,8 @@
"indexedAt": { "type": "string", "format": "datetime" },
"replyTo": {
"type": "union",
- "refs": ["#commentView"]
+ "refs": ["#commentViewBasic"],
+ "description": "The parent comment this one replies to, if any. A non-recursive view (it carries no replyTo of its own), so the thread is flattened to a single hop; walk `record.reply` to follow the chain further."
},
"likeCount": {
"type": "integer",
@@ -24,6 +25,26 @@
"description": "Number of likes on this comment."
}
}
+ },
+ "commentViewBasic": {
+ "type": "object",
+ "description": "A comment view without its own `replyTo`, used to represent the parent of a reply without recursively nesting the whole thread.",
+ "required": ["uri", "cid", "author", "record", "indexedAt", "likeCount"],
+ "properties": {
+ "uri": { "type": "string", "format": "at-uri" },
+ "cid": { "type": "string", "format": "cid" },
+ "author": {
+ "type": "ref",
+ "ref": "app.bsky.actor.defs#profileViewBasic"
+ },
+ "record": { "type": "unknown" },
+ "indexedAt": { "type": "string", "format": "datetime" },
+ "likeCount": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "Number of likes on this comment."
+ }
+ }
}
}
}
diff --git a/pkg/model/vod_comment.go b/pkg/model/vod_comment.go
index c9ff77884..4cd4955e1 100644
--- a/pkg/model/vod_comment.go
+++ b/pkg/model/vod_comment.go
@@ -29,7 +29,9 @@ type VodComment struct {
DeletedAt *time.Time `json:"deletedAt" gorm:"column:deleted_at"`
}
-func (c *VodComment) ToStreamplaceCommentView() (*streamplace.VodDefs_CommentView, error) {
+// decodeRecord decodes the stored comment CBOR, truncating overly long text
+// to the same 300-grapheme cap the views enforce.
+func (c *VodComment) decodeRecord() (*lexutil.LexiconTypeDecoder, error) {
rec, err := lexutil.CborDecodeValue(*c.Comment)
if err != nil {
return nil, fmt.Errorf("error decoding comment: %w", err)
@@ -45,25 +47,57 @@ func (c *VodComment) ToStreamplaceCommentView() (*streamplace.VodDefs_CommentVie
msg.Text = result.String()
}
}
- commentView := &streamplace.VodDefs_CommentView{
- LexiconTypeID: "place.stream.vod.defs#commentView",
- }
- commentView.Uri = c.URI
- commentView.Cid = c.CID
- commentView.Author = &bsky.ActorDefs_ProfileViewBasic{Did: c.RepoDID}
+ return &lexutil.LexiconTypeDecoder{Val: rec}, nil
+}
+
+func (c *VodComment) author() *bsky.ActorDefs_ProfileViewBasic {
+ author := &bsky.ActorDefs_ProfileViewBasic{Did: c.RepoDID}
if c.Repo != nil {
- commentView.Author.Handle = c.Repo.Handle
+ author.Handle = c.Repo.Handle
+ }
+ return author
+}
+
+// ToStreamplaceCommentViewBasic builds the non-recursive parent view used for
+// a comment's replyTo. It deliberately carries no replyTo of its own, which is
+// what keeps the thread flattened to a single hop (and keeps the lexicon — and
+// the OpenAPI schema generated from it — free of a self-referential cycle).
+func (c *VodComment) ToStreamplaceCommentViewBasic() (*streamplace.VodDefs_CommentViewBasic, error) {
+ record, err := c.decodeRecord()
+ if err != nil {
+ return nil, err
+ }
+ return &streamplace.VodDefs_CommentViewBasic{
+ LexiconTypeID: "place.stream.vod.defs#commentViewBasic",
+ Uri: c.URI,
+ Cid: c.CID,
+ Author: c.author(),
+ Record: record,
+ IndexedAt: c.IndexedAt.UTC().Format(time.RFC3339Nano),
+ LikeCount: 0,
+ }, nil
+}
+
+func (c *VodComment) ToStreamplaceCommentView() (*streamplace.VodDefs_CommentView, error) {
+ record, err := c.decodeRecord()
+ if err != nil {
+ return nil, err
+ }
+ commentView := &streamplace.VodDefs_CommentView{
+ Uri: c.URI,
+ Cid: c.CID,
+ Author: c.author(),
+ Record: record,
+ IndexedAt: c.IndexedAt.UTC().Format(time.RFC3339Nano),
+ LikeCount: 0,
}
- commentView.Record = &lexutil.LexiconTypeDecoder{Val: rec}
- commentView.IndexedAt = c.IndexedAt.UTC().Format(time.RFC3339Nano)
- commentView.LikeCount = 0
if c.ReplyTo != nil {
- replyTo, err := c.ReplyTo.ToStreamplaceCommentView()
+ replyTo, err := c.ReplyTo.ToStreamplaceCommentViewBasic()
if err != nil {
return nil, fmt.Errorf("error converting reply to comment view: %w", err)
}
commentView.ReplyTo = &streamplace.VodDefs_CommentView_ReplyTo{
- VodDefs_CommentView: replyTo,
+ VodDefs_CommentViewBasic: replyTo,
}
}
return commentView, nil
diff --git a/pkg/streamplace/voddefs.go b/pkg/streamplace/voddefs.go
index ce229064a..2fc455fc7 100644
--- a/pkg/streamplace/voddefs.go
+++ b/pkg/streamplace/voddefs.go
@@ -14,25 +14,40 @@ import (
// VodDefs_CommentView is a "commentView" in the place.stream.vod.defs schema.
type VodDefs_CommentView struct {
- LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.vod.defs#commentView"`
+ Author *appbsky.ActorDefs_ProfileViewBasic `json:"author" cborgen:"author"`
+ Cid string `json:"cid" cborgen:"cid"`
+ IndexedAt string `json:"indexedAt" cborgen:"indexedAt"`
+ // likeCount: Number of likes on this comment.
+ LikeCount int64 `json:"likeCount" cborgen:"likeCount"`
+ Record *lexutil.LexiconTypeDecoder `json:"record" cborgen:"record"`
+ // replyTo: The parent comment this one replies to, if any. A non-recursive view (it carries no replyTo of its own), so the thread is flattened to a single hop; walk `record.reply` to follow the chain further.
+ ReplyTo *VodDefs_CommentView_ReplyTo `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"`
+ Uri string `json:"uri" cborgen:"uri"`
+}
+
+// VodDefs_CommentViewBasic is a "commentViewBasic" in the place.stream.vod.defs schema.
+//
+// A comment view without its own `replyTo`, used to represent the parent of a reply without recursively nesting the whole thread.
+type VodDefs_CommentViewBasic struct {
+ LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.vod.defs#commentViewBasic"`
Author *appbsky.ActorDefs_ProfileViewBasic `json:"author" cborgen:"author"`
Cid string `json:"cid" cborgen:"cid"`
IndexedAt string `json:"indexedAt" cborgen:"indexedAt"`
// likeCount: Number of likes on this comment.
- LikeCount int64 `json:"likeCount" cborgen:"likeCount"`
- Record *lexutil.LexiconTypeDecoder `json:"record" cborgen:"record"`
- ReplyTo *VodDefs_CommentView_ReplyTo `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"`
- Uri string `json:"uri" cborgen:"uri"`
+ LikeCount int64 `json:"likeCount" cborgen:"likeCount"`
+ Record *lexutil.LexiconTypeDecoder `json:"record" cborgen:"record"`
+ Uri string `json:"uri" cborgen:"uri"`
}
+// The parent comment this one replies to, if any. A non-recursive view (it carries no replyTo of its own), so the thread is flattened to a single hop; walk `record.reply` to follow the chain further.
type VodDefs_CommentView_ReplyTo struct {
- VodDefs_CommentView *VodDefs_CommentView
+ VodDefs_CommentViewBasic *VodDefs_CommentViewBasic
}
func (t *VodDefs_CommentView_ReplyTo) MarshalJSON() ([]byte, error) {
- if t.VodDefs_CommentView != nil {
- t.VodDefs_CommentView.LexiconTypeID = "place.stream.vod.defs#commentView"
- return json.Marshal(t.VodDefs_CommentView)
+ if t.VodDefs_CommentViewBasic != nil {
+ t.VodDefs_CommentViewBasic.LexiconTypeID = "place.stream.vod.defs#commentViewBasic"
+ return json.Marshal(t.VodDefs_CommentViewBasic)
}
return nil, fmt.Errorf("can not marshal empty union as JSON")
}
@@ -44,9 +59,9 @@ func (t *VodDefs_CommentView_ReplyTo) UnmarshalJSON(b []byte) error {
}
switch typ {
- case "place.stream.vod.defs#commentView":
- t.VodDefs_CommentView = new(VodDefs_CommentView)
- return json.Unmarshal(b, t.VodDefs_CommentView)
+ case "place.stream.vod.defs#commentViewBasic":
+ t.VodDefs_CommentViewBasic = new(VodDefs_CommentViewBasic)
+ return json.Unmarshal(b, t.VodDefs_CommentViewBasic)
default:
return nil
}
--
2.51.2
From cd70baa0ac280b797c29bcb53c9702dd9d007c71 Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Sat, 30 May 2026 18:42:20 -0700
Subject: [PATCH 14/47] vod: minimal VodPlayer at /:user/vod/:tid; point
listing cards at it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add a deliberately thin VOD player — PlayerProvider + src/mode pushed into
the player store + the platform
);
}
--
2.51.2
From d5b35951a5e749d8d2ffc0486faa9fdd8ef3b80b Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Tue, 2 Jun 2026 12:55:56 -0700
Subject: [PATCH 43/47] app: match VOD top controls to live (single safe-area
inset)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
VOD was double-insetting the top controls: the ScrollView's paddingTop
pushes the video below the notch, and MobileUi's SafeAreaView re-added the
top inset on top of that, floating the controls ~1/3 down. Drop the
SafeAreaView top edge for VOD so the single container inset stands: black
behind the notch, controls at the top of the video, video below — matching
live.
Co-Authored-By: Claude Opus 4.8
---
js/app/components/mobile/ui.tsx | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/js/app/components/mobile/ui.tsx b/js/app/components/mobile/ui.tsx
index c292ed5c0..5c6d7107b 100644
--- a/js/app/components/mobile/ui.tsx
+++ b/js/app/components/mobile/ui.tsx
@@ -190,7 +190,10 @@ export function MobileUi({
{/* Main UI Overlay */}
Date: Tue, 2 Jun 2026 13:00:01 -0700
Subject: [PATCH 44/47] likes: key on AT-URI, refuse double-likes
The Like model used CID as its primary key, but a CID isn't unique per
like: two users liking the same subject at the same createdAt produce
byte-identical records (same $type/subject/createdAt) and thus the same
CID, so the second like would collide on the primary key and be lost. Key
on the AT-URI instead, which is unique per record (repo + rkey).
Refuse to index a like when that user already likes the subject: the
firehose handler now checks GetLikeBySubjectAndUser before CreateLike and
skips duplicates, so a user can't inflate a count by liking twice. Also fix
GetLikeBySubjectAndUser swallowing non-not-found DB errors.
Co-Authored-By: Claude Opus 4.8
---
pkg/atproto/sync.go | 11 ++++++++++
pkg/model/like.go | 12 ++++++++---
pkg/model/video_list_test.go | 40 ++++++++++++++++++++++++++++--------
3 files changed, 52 insertions(+), 11 deletions(-)
diff --git a/pkg/atproto/sync.go b/pkg/atproto/sync.go
index 985bc052e..a5201aabf 100644
--- a/pkg/atproto/sync.go
+++ b/pkg/atproto/sync.go
@@ -891,6 +891,17 @@ func (atsync *ATProtoSynchronizer) handleCreateUpdate(ctx context.Context, userD
log.Debug(ctx, "place.stream.like detected", "subject", rec.Subject, "repo", repo.Handle)
+ // A user can only like a subject once — refuse to index a duplicate
+ // rather than inflating the count with a second row.
+ existing, err := atsync.Model.GetLikeBySubjectAndUser(ctx, rec.Subject, userDID)
+ if err != nil {
+ return fmt.Errorf("check existing like: %w", err)
+ }
+ if existing != nil {
+ log.Debug(ctx, "ignoring duplicate like", "subject", rec.Subject, "repo", userDID)
+ return nil
+ }
+
like := &model.Like{
CID: cid,
URI: aturi.String(),
diff --git a/pkg/model/like.go b/pkg/model/like.go
index b2cb36522..ba8ae2661 100644
--- a/pkg/model/like.go
+++ b/pkg/model/like.go
@@ -13,8 +13,11 @@ import (
)
type Like struct {
- CID string `json:"cid" gorm:"primaryKey;column:cid"`
- URI string `json:"uri" gorm:"column:uri"`
+ // URI is the primary key: it's unique per like record (repo + rkey). CID
+ // is NOT — two users liking the same subject at the same createdAt produce
+ // byte-identical records and thus the same CID, which would collide.
+ URI string `json:"uri" gorm:"primaryKey;column:uri"`
+ CID string `json:"cid" gorm:"column:cid"`
Subject string `json:"subject" gorm:"column:subject;index:idx_likes_subject"`
RepoDID string `json:"repoDID" gorm:"column:repo_did"`
Repo *Repo `json:"repo,omitempty" gorm:"foreignKey:DID;references:RepoDID"`
@@ -70,10 +73,13 @@ func (m *DBModel) GetLike(uri string) (*Like, error) {
func (m *DBModel) GetLikeBySubjectAndUser(ctx context.Context, subject string, repoDID string) (*Like, error) {
var like Like
- err := m.DB.Preload("Repo").Where("subject = ? AND repo_did = ?", subject, repoDID).First(&like).Error
+ err := m.DB.WithContext(ctx).Preload("Repo").Where("subject = ? AND repo_did = ?", subject, repoDID).First(&like).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
+ if err != nil {
+ return nil, err
+ }
return &like, nil
}
diff --git a/pkg/model/video_list_test.go b/pkg/model/video_list_test.go
index 878b49ea9..a8c785381 100644
--- a/pkg/model/video_list_test.go
+++ b/pkg/model/video_list_test.go
@@ -11,15 +11,15 @@ import (
"stream.place/streamplace/pkg/streamplace"
)
-// putLike writes a place.stream.like whose subject is the given video URI.
-func putLike(t *testing.T, m Model, subject, cid string) {
+// putLike writes a place.stream.like by `liker` whose subject is the given URI.
+func putLike(t *testing.T, m Model, subject, liker, cid string) {
t.Helper()
now := time.Now().UTC()
require.NoError(t, m.CreateLike(context.Background(), &Like{
CID: cid,
- URI: "at://did:plc:liker/place.stream.like/" + cid,
+ URI: "at://" + liker + "/place.stream.like/" + cid,
Subject: subject,
- RepoDID: "did:plc:liker",
+ RepoDID: liker,
IndexedAt: &now,
CreatedAt: now,
}))
@@ -41,11 +41,11 @@ func TestVideoLikeCount(t *testing.T) {
require.NotNil(t, view)
require.Equal(t, int64(0), view.LikeCount)
- putLike(t, m, videoURI, "likecid1")
- putLike(t, m, videoURI, "likecid2")
- putLike(t, m, videoURI, "likecid3")
+ putLike(t, m, videoURI, "did:plc:l1", "likecid1")
+ putLike(t, m, videoURI, "did:plc:l2", "likecid2")
+ putLike(t, m, videoURI, "did:plc:l3", "likecid3")
// A like on a different subject must not count toward this video.
- putLike(t, m, "at://did:plc:alice/place.stream.video/other", "likecid4")
+ putLike(t, m, "at://did:plc:alice/place.stream.video/other", "did:plc:l1", "likecid4")
view, err = m.GetVideoView(ctx, videoURI)
require.NoError(t, err)
@@ -63,6 +63,30 @@ func TestVideoLikeCount(t *testing.T) {
require.Equal(t, int64(3), found.LikeCount)
}
+// TestGetLikeBySubjectAndUser covers the lookup the indexer uses to refuse a
+// double-like: same (subject, user) is found; a different user or subject isn't.
+func TestGetLikeBySubjectAndUser(t *testing.T) {
+ m, err := MakeDB(":memory:")
+ require.NoError(t, err)
+ ctx := context.Background()
+
+ const subject = "at://did:plc:alice/place.stream.video/v1"
+ putLike(t, m, subject, "did:plc:liker1", "c1")
+
+ got, err := m.GetLikeBySubjectAndUser(ctx, subject, "did:plc:liker1")
+ require.NoError(t, err)
+ require.NotNil(t, got, "same subject + user should be found")
+ require.Equal(t, subject, got.Subject)
+
+ got, err = m.GetLikeBySubjectAndUser(ctx, subject, "did:plc:liker2")
+ require.NoError(t, err)
+ require.Nil(t, got, "a different user may still like the subject")
+
+ got, err = m.GetLikeBySubjectAndUser(ctx, "at://did:plc:alice/place.stream.video/v2", "did:plc:liker1")
+ require.NoError(t, err)
+ require.Nil(t, got, "a different subject is independent")
+}
+
const testServerDID = "did:web:us.example.com"
// putTrackVideo writes a place.stream.media.track (backed by blobCID) and a
--
2.51.2
From 7b2f7b9df040c710d038115092ced51167c86fbd Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Tue, 2 Jun 2026 13:20:51 -0700
Subject: [PATCH 45/47] vod: disable native controls; fit video in
fullscreen/landscape
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Turn expo-video's nativeControls off entirely — we render our own controls,
and even briefly enabling them flashed the native overlay.
Fix the video clipping off the bottom in fullscreen/landscape: a
width-100% + aspectRatio box is taller than a landscape screen. When
fullscreen or the device is landscape, fill the area with flex:1 and let
objectFit:contain letterbox the video to fit. Portrait inline keeps the
aspect-ratio box (video on top, metadata below).
Co-Authored-By: Claude Opus 4.8
---
js/app/components/mobile/player.tsx | 28 +++++++++++++------
.../mobile-player/video-async.native.tsx | 5 ++--
2 files changed, 22 insertions(+), 11 deletions(-)
diff --git a/js/app/components/mobile/player.tsx b/js/app/components/mobile/player.tsx
index 264b17bc8..f107a048a 100644
--- a/js/app/components/mobile/player.tsx
+++ b/js/app/components/mobile/player.tsx
@@ -393,6 +393,12 @@ export function PlayerInner(
const showFullDesktopMode = aspectRatio > 1 && screenWidth > 1200;
const isLandscape = aspectRatio > 1;
+ // When fullscreen or the device is rotated to landscape, a width-100% +
+ // aspectRatio VOD box is taller than the screen and clips off the bottom.
+ // In those cases fill the area and let objectFit:contain letterbox instead.
+ const { width: winWidth, height: winHeight } = useWindowDimensions();
+ const vodFillScreen = fullscreen || winWidth > winHeight;
+
const isPlayerRatioGreater = aspectRatio >= 16 / 9;
// animated style for offline height transition
@@ -434,15 +440,19 @@ export function PlayerInner(
width: calculatedWidth,
}
: props.mode === "vod"
- ? {
- // Bound the video to its real aspect ratio so it occupies a
- // fixed height with the metadata/comments below — never the
- // whole window. (A pixel height derived from contentWidth
- // collapsed to full-window on Android when contentWidth
- // measured 0.)
- width: "100%" as any,
- aspectRatio: vodAspectRatio,
- }
+ ? vodFillScreen
+ ? // Fullscreen/landscape: fill the area; objectFit:contain
+ // letterboxes so the video fits without clipping.
+ { flex: 1 }
+ : {
+ // Portrait inline: bound the video to its real aspect ratio
+ // so it occupies a fixed height with the metadata below —
+ // never the whole window. (A pixel height derived from
+ // contentWidth collapsed to full-window on Android when
+ // contentWidth measured 0.)
+ width: "100%" as any,
+ aspectRatio: vodAspectRatio,
+ }
: {
flex: 1,
maxHeight: "auto",
diff --git a/js/components/src/components/mobile-player/video-async.native.tsx b/js/components/src/components/mobile-player/video-async.native.tsx
index 357684747..071b93bad 100644
--- a/js/components/src/components/mobile-player/video-async.native.tsx
+++ b/js/components/src/components/mobile-player/video-async.native.tsx
@@ -80,7 +80,6 @@ export function NativeVideo(props?: {
const muted = useMuted();
const volume = useEffectiveVolume();
const setFullscreen = usePlayerStore((x) => x.setFullscreen);
- const fullscreen = usePlayerStore((x) => x.fullscreen);
const playerEvent = usePlayerStore((x) => x.playerEvent);
const spurl = useStreamplaceStore((x) => x.url);
@@ -226,7 +225,9 @@ export function NativeVideo(props?: {
{
setFullscreen(true);
}}
--
2.51.2
From c83a6c1ad245b51e4c1dc31e85721cc42089461e Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Tue, 2 Jun 2026 13:52:50 -0700
Subject: [PATCH 46/47] vod: render the video description, scrollable below the
video
Show the place.stream.video description below the metadata. On mobile the
layout is now fixed-video + fixed-metadata-header with the description
scrolling independently below the divider, so the video stays visible and a
long description can't flex-shrink the player (which broke landscape). The
video box is a capped aspect-ratio box (letterboxes to fit landscape,
flexShrink:0 so it never collapses). Desktop keeps the flow-in-scroll
layout. Pad the description scroll by the bottom safe-area inset so it clears
the Android nav buttons.
Co-Authored-By: Claude Opus 4.8
---
js/app/components/mobile/player.tsx | 89 ++++++++++++-------
.../src/components/vod/vod-description.tsx | 24 +++++
.../src/components/vod/vod-section.tsx | 58 ++++++++++--
3 files changed, 130 insertions(+), 41 deletions(-)
create mode 100644 js/components/src/components/vod/vod-description.tsx
diff --git a/js/app/components/mobile/player.tsx b/js/app/components/mobile/player.tsx
index f107a048a..3d0777caf 100644
--- a/js/app/components/mobile/player.tsx
+++ b/js/app/components/mobile/player.tsx
@@ -410,6 +410,62 @@ export function PlayerInner(
};
});
+ const videoContent = props.showUnavailable ? (
+
+ ) : (
+
+ {showFullDesktopMode || fullscreen ? (
+
+ ) : (
+ (isLandscape || props.mode === "vod") && (
+
+ )
+ )}
+
+ {props.mode !== "vod" && !props.showUnavailable && (
+
+ )}
+
+
+ );
+
+ // Mobile inline VOD: keep the video + metadata header fixed and scroll only
+ // the description below the line, so the video is always visible and a long
+ // description can't flex-shrink the player.
+ if (props.mode === "vod" && !showFullDesktopMode && !fullscreen) {
+ return (
+
+
+ {videoContent}
+
+
+
+ );
+ }
+
return (
- {props.showUnavailable ? (
-
- ) : (
-
- {showFullDesktopMode || fullscreen ? (
-
- ) : (
- (isLandscape || props.mode === "vod") && (
-
- )
- )}
-
- {props.mode !== "vod" && !props.showUnavailable && (
-
- )}
-
-
- )}
+ {videoContent}
{showFullDesktopMode && props.mode !== "vod" && (
x.video);
+ const { theme } = useTheme();
+
+ if (!video) return null;
+ const record = video.record as unknown as PlaceStreamVideo.Record;
+ const description = record.description?.trim();
+ if (!description) return null;
+
+ return (
+
+ {description}
+
+ );
+}
diff --git a/js/components/src/components/vod/vod-section.tsx b/js/components/src/components/vod/vod-section.tsx
index aaf0628a1..5a4bdbf5e 100644
--- a/js/components/src/components/vod/vod-section.tsx
+++ b/js/components/src/components/vod/vod-section.tsx
@@ -1,25 +1,31 @@
-import { View } from "react-native";
+import { ScrollView, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
import { borders, px, py, useTheme } from "../../ui";
import { useVideoStore } from "../../video-store/video-store";
+import { VodDescription } from "./vod-description";
import { VodMobileMetadata } from "./vod-mobile-metadata";
// VodSection is the VOD metadata block (title, author, likes, view count)
-// rendered beneath the player at every breakpoint. The live-stream metadata
-// bar (BottomMetadata) is suppressed for VOD, so this is the single source
-// of VOD metadata across all widths.
+// plus the description below a divider line.
//
-// The outer wrapper spans the full content width and carries the bottom
-// divider so it runs edge to edge; the inner wrapper keeps the metadata
-// itself centered at a readable max width.
-export function VodSection() {
+// With scrollDescription (mobile), the metadata header stays fixed and only the
+// description scrolls, so the video above is always visible and a long
+// description can't shrink the player. Without it (desktop), everything flows
+// in the surrounding scroll view.
+export function VodSection({
+ scrollDescription = false,
+}: {
+ scrollDescription?: boolean;
+}) {
const aturi = useVideoStore((x) => x.aturi);
const { theme } = useTheme();
+ const insets = useSafeAreaInsets();
if (!aturi) {
return null;
}
- return (
+ const header = (
);
+
+ const description = (
+
+
+
+ );
+
+ if (scrollDescription) {
+ return (
+
+ {header}
+
+ {description}
+
+
+ );
+ }
+
+ return (
+
+ {header}
+ {description}
+
+ );
}
--
2.51.2
From 656753be55abb01fb3bf6601d2763bb4fea453d1 Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Tue, 2 Jun 2026 14:23:46 -0700
Subject: [PATCH 47/47] playback: getVideoBlob gates on content owner, not the
requester
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
getVideoBlob required the requesting `did` to own a track in the blob, so a
clip of another user's video — which references the same content-addressed
blob with the clipper's did — was rejected as BlobNotFound unless you
passed the original owner's did.
The blob is shared content; gate it on its content owners (the track
owners) instead: if any is banned the blob is unavailable, otherwise serve
regardless of `did` (which is egress-accounting metadata only, not used for
attribution). A clip's own labels/ban are still enforced at
getVideoPlaylist, and a banned content owner still blocks the blob even for
a non-owner requester.
Co-Authored-By: Claude Opus 4.8
---
pkg/spxrpc/labels_test.go | 15 +++++++++--
pkg/spxrpc/place_stream_playback_getvideo.go | 28 ++++++++------------
2 files changed, 24 insertions(+), 19 deletions(-)
diff --git a/pkg/spxrpc/labels_test.go b/pkg/spxrpc/labels_test.go
index c01f2dc44..7d3ecc3cd 100644
--- a/pkg/spxrpc/labels_test.go
+++ b/pkg/spxrpc/labels_test.go
@@ -144,11 +144,22 @@ func blobReq(did, cid string) (echo.Context, *httptest.ResponseRecorder) {
}
func TestHandleGetVideoBlob_LabelerGating(t *testing.T) {
- t.Run("spoofed did fails as not-found", func(t *testing.T) {
+ t.Run("clip by another user (non-owner did) serves", func(t *testing.T) {
+ // The blob is content-addressed; a clip references it with the
+ // clipper's did, which doesn't own a track. It still serves.
s, _ := setupBlobTest(t)
+ c, rec := blobReq(testOtherUser, testContentCID)
+ require.NoError(t, s.HandleGetVideoBlob(c))
+ require.Equal(t, http.StatusOK, rec.Code)
+ })
+
+ t.Run("banned content owner blocks even a non-owner did", func(t *testing.T) {
+ // A clip can't bypass the original owner's ban.
+ s, m := setupBlobTest(t)
+ putLabel(t, m, testOwner, atproto.LabelTakedown)
c, _ := blobReq(testOtherUser, testContentCID)
he := requireHTTPError(t, s.HandleGetVideoBlob(c))
- require.Equal(t, http.StatusNotFound, he.Code)
+ require.Equal(t, http.StatusForbidden, he.Code)
})
t.Run("banned owner is forbidden", func(t *testing.T) {
diff --git a/pkg/spxrpc/place_stream_playback_getvideo.go b/pkg/spxrpc/place_stream_playback_getvideo.go
index 8a20789bd..cafa8db8d 100644
--- a/pkg/spxrpc/place_stream_playback_getvideo.go
+++ b/pkg/spxrpc/place_stream_playback_getvideo.go
@@ -101,28 +101,22 @@ func (s *Server) HandleGetVideoBlob(c echo.Context) error {
}
did := parsedDID.String()
- // Labeler enforcement. If this CID is a known content blob, the
- // supplied `did` must actually own a track in it (a spoofed DID has
- // no matching MediaTrack and fails as not-found), and that account
- // must not be banned. CIDs with no MediaTrack — per-track init
- // segments, or simply unknown CIDs — serve unguarded.
+ // Labeler enforcement. If this CID is a known content blob, gate it on
+ // its content owners — the track owners — not on the requesting `did`.
+ // The blob is content-addressed and shared: a clip of another user's
+ // video references the same blob with the clipper's did, which doesn't
+ // own a track. So `did` is for egress accounting only; if any of the
+ // blob's track owners is banned, the content is unavailable. (A clip's
+ // own labels/ban are enforced at getVideoPlaylist, the playback entry
+ // point.) CIDs with no MediaTrack — per-track init segments, or simply
+ // unknown CIDs — serve unguarded.
tracks, err := s.model.GetMediaTracksByBlob(ctx, cid)
if err != nil {
log.Error(ctx, "playback: GetMediaTracksByBlob failed", "cid", cid, "error", err)
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
- if len(tracks) > 0 {
- owned := false
- for _, t := range tracks {
- if t.RepoDID == did {
- owned = true
- break
- }
- }
- if !owned {
- return echo.NewHTTPError(http.StatusNotFound, "BlobNotFound")
- }
- if banned, err := s.accountBanned(did); err != nil {
+ for _, t := range tracks {
+ if banned, err := s.accountBanned(t.RepoDID); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
} else if banned {
return echo.NewHTTPError(http.StatusForbidden, "VideoUnavailable")