From 5b4643bcb34bee7eb3be504c6d04486fdec92332 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 19:20:00 +0000 Subject: [PATCH 1/4] feat(mobile): add huddles with LiveKit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the desktop huddle window: tap the headphones button on a stream to fetch a LiveKit token from Orion and join a video room. Active huddles show a red badge on the stream card (participant count) and a red Join pill in the stream top bar — both driven by huddle_active_participants synced from the LiveKit webhook. The HuddleScreen lives at its own route (not a modal/window): video tile grid with placeholders for camera-off, mic/camera toggles, and a leave button. Adds the LiveKit RN SDK plus the WebRTC + LiveKit Expo config plugins for prebuild. https://claude.ai/code/session_01Po5tAD17MXhnqGQ9hKh6PC --- js/mobile/app.config.ts | 5 + js/mobile/index.ts | 6 + js/mobile/package.json | 5 + .../src/features/huddle/HuddleScreen.tsx | 302 ++++++ .../src/features/huddle/use-open-huddle.ts | 46 + .../features/stream-view/StreamTopActions.tsx | 45 +- js/mobile/src/features/streams/StreamCard.tsx | 10 +- js/mobile/src/navigation/RootNavigator.tsx | 6 + js/mobile/src/navigation/types.ts | 7 + js/mobile/yarn.lock | 881 +++++++++++++++++- 10 files changed, 1298 insertions(+), 15 deletions(-) create mode 100644 js/mobile/src/features/huddle/HuddleScreen.tsx create mode 100644 js/mobile/src/features/huddle/use-open-huddle.ts diff --git a/js/mobile/app.config.ts b/js/mobile/app.config.ts index 0069c71..daa8413 100644 --- a/js/mobile/app.config.ts +++ b/js/mobile/app.config.ts @@ -57,6 +57,11 @@ const config: ExpoConfig = { color: "#000000", }, ], + // LiveKit needs WebRTC native modules. The two plugins below patch the + // iOS/Android prebuild so camera/mic permissions and the webrtc pod are + // wired up correctly for huddles. + "@config-plugins/react-native-webrtc", + "@livekit/react-native-expo-plugin", ], experiments: { typedRoutes: false, diff --git a/js/mobile/index.ts b/js/mobile/index.ts index f45fcf4..e0a2575 100644 --- a/js/mobile/index.ts +++ b/js/mobile/index.ts @@ -1,5 +1,11 @@ import "./global.css"; +import { registerGlobals } from "@livekit/react-native"; import { registerRootComponent } from "expo"; import App from "./src/App"; +// LiveKit pipes WebRTC globals (RTCPeerConnection, mediaDevices, ...) onto the +// JS global. Must run before any LiveKit client is constructed, so do it at +// app entry rather than inside the huddle screen. +registerGlobals(); + registerRootComponent(App); diff --git a/js/mobile/package.json b/js/mobile/package.json index 4fe8aea..4ded7b7 100644 --- a/js/mobile/package.json +++ b/js/mobile/package.json @@ -30,6 +30,11 @@ "expo-status-bar": "~3.0.9", "expo-video": "~3.0.10", "firebase": "^12.10.0", + "@config-plugins/react-native-webrtc": "^12.0.0", + "@livekit/react-native": "^2.7.5", + "@livekit/react-native-expo-plugin": "^1.0.2", + "@livekit/react-native-webrtc": "^144.1.0", + "livekit-client": "^2.15.2", "lucide-react-native": "^0.575.0", "nativewind": "^4.1.23", "react": "19.1.0", diff --git a/js/mobile/src/features/huddle/HuddleScreen.tsx b/js/mobile/src/features/huddle/HuddleScreen.tsx new file mode 100644 index 0000000..c307f2f --- /dev/null +++ b/js/mobile/src/features/huddle/HuddleScreen.tsx @@ -0,0 +1,302 @@ +import { useCallback, useEffect } from "react"; +import { + Alert, + Dimensions, + Pressable, + Text, + View, +} from "react-native"; +import type { Human } from "@/api/types"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { StatusBar } from "expo-status-bar"; +import { + AudioSession, + LiveKitRoom, + VideoTrack, + isTrackReference, + useLocalParticipant, + useRoomContext, + useTracks, +} from "@livekit/react-native"; +import type { TrackReferenceOrPlaceholder } from "@livekit/components-core"; +import { Track } from "livekit-client"; +import { Mic, MicOff, PhoneOff, Video, VideoOff } from "lucide-react-native"; +import { useNetwork } from "@/hooks/use-networks"; +import { resolveHumanDisplay } from "@/lib/humans"; +import { cn } from "@/lib/utils"; +import type { RootStackScreenProps } from "@/navigation/types"; + +/** + * Mobile huddle screen — LiveKit room with a tile grid, basic mic/camera + * controls, and a leave button. Mirrors the desktop HuddleApp but tuned for + * touch: bigger controls, grid layout instead of focus/carousel, no chat or + * screenshare (camera + audio only). + */ +export function HuddleScreen({ + route, + navigation, +}: RootStackScreenProps<"Huddle">) { + const { token, serverUrl, streamName, networkId } = route.params; + + // iOS in particular requires us to bracket the room session with + // start/stop calls so the AVAudioSession is configured for VoIP routing + // (earpiece → speaker, ducking, etc.). Without this, remote audio is + // routed to the receiver speaker and the user has to hold the phone to + // their ear to hear anyone. + useEffect(() => { + void AudioSession.startAudioSession().catch(() => { + // Non-fatal: room will still connect, audio routing may just be + // less ideal. Failing the whole screen here would feel worse. + }); + return () => { + void AudioSession.stopAudioSession(); + }; + }, []); + + const leave = useCallback(() => { + navigation.goBack(); + }, [navigation]); + + return ( + + + { + Alert.alert("Huddle error", err.message ?? "Failed to connect."); + leave(); + }} + > + + + + ); +} + +interface HuddleRoomProps { + networkId: string; + streamName: string; + onLeave: () => void; +} + +function HuddleRoom({ networkId, streamName, onLeave }: HuddleRoomProps) { + const room = useRoomContext(); + const network = useNetwork(networkId); + const { localParticipant, isMicrophoneEnabled, isCameraEnabled } = + useLocalParticipant(); + + // `withPlaceholder: true` ensures we get a tile for every participant even + // when their camera is off — same pattern as desktop's HuddleContent. + const tracks = useTracks( + [{ source: Track.Source.Camera, withPlaceholder: true }], + { onlySubscribed: false }, + ); + + const toggleMic = useCallback(() => { + void localParticipant.setMicrophoneEnabled(!isMicrophoneEnabled); + }, [localParticipant, isMicrophoneEnabled]); + + const toggleCamera = useCallback(() => { + void localParticipant.setCameraEnabled(!isCameraEnabled); + }, [localParticipant, isCameraEnabled]); + + const leave = useCallback(async () => { + try { + await room.disconnect(); + } finally { + onLeave(); + } + }, [room, onLeave]); + + return ( + + + + + {streamName} + + + {tracks.length === 1 + ? "1 participant" + : `${tracks.length} participants`} + + + + + + + + + + + ) : ( + + ) + } + /> + + ) : ( + + ) + } + /> + void leave()} + icon={} + /> + + + ); +} + +interface TileGridProps { + tiles: TrackReferenceOrPlaceholder[]; + humans: Human[]; +} + +function TileGrid({ tiles, humans }: TileGridProps) { + // Compute a square-ish grid: 1 → 1col, 2 → 1col (stacked), 3-4 → 2col, + // 5+ → 2col with scroll. Keeps each tile big enough on a phone screen. + const columns = tiles.length <= 1 ? 1 : 2; + const { width, height } = Dimensions.get("window"); + const rows = Math.max(1, Math.ceil(tiles.length / columns)); + const tileWidth = (width - 16) / columns - 8; + // Subtract approx chrome height (header + control bar ≈ 200px). This is a + // simple heuristic — we don't need pixel-perfect since tiles use aspectRatio. + const availableHeight = height - 220; + const tileHeight = Math.max(140, availableHeight / rows - 8); + + return ( + + {tiles.map((tile) => ( + + + + ))} + + ); +} + +function Tile({ + tile, + humans, +}: { + tile: TrackReferenceOrPlaceholder; + humans: Human[]; +}) { + const identity = tile.participant.identity; + const display = resolveHumanDisplay(identity, humans); + const name = tile.participant.name || display.displayName; + const initials = display.initials; + const isSpeaking = tile.participant.isSpeaking; + const muted = + tile.participant.getTrackPublication(Track.Source.Microphone)?.isMuted ?? + true; + + const hasVideo = isTrackReference(tile) && !tile.publication.isMuted; + + return ( + + {hasVideo ? ( + + ) : ( + + + {initials} + + + )} + + {muted ? ( + + ) : ( + + )} + + {name} + + + + ); +} + +function trackKey(tile: TrackReferenceOrPlaceholder): string { + const sid = isTrackReference(tile) ? tile.publication.trackSid : "placeholder"; + return `${tile.participant.identity}:${tile.source}:${sid}`; +} + +interface ControlButtonProps { + icon: React.ReactNode; + label: string; + onPress: () => void; + active?: boolean; + tone?: "default" | "danger"; +} + +function ControlButton({ + icon, + label, + onPress, + active = false, + tone = "default", +}: ControlButtonProps) { + // Used purely for the visual state — destructive tone always wins so + // "Leave" is unmistakable regardless of toggle state. + return ( + + {icon} + + ); +} + diff --git a/js/mobile/src/features/huddle/use-open-huddle.ts b/js/mobile/src/features/huddle/use-open-huddle.ts new file mode 100644 index 0000000..ebd8c32 --- /dev/null +++ b/js/mobile/src/features/huddle/use-open-huddle.ts @@ -0,0 +1,46 @@ +import { useCallback, useState } from "react"; +import { useNavigation } from "@react-navigation/native"; +import { toast } from "sonner-native"; +import { apiClient } from "@/api/client"; +import { toUserMessage } from "@/lib/errors"; +import type { RootStackParamList } from "@/navigation/types"; +import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; + +/** + * Mirrors desktop's `handleOpenHuddle` (stream-view.tsx) — fetch a fresh + * LiveKit token from Orion for the {network, stream} pair and hand off to + * the dedicated huddle screen. Joining and starting are the same operation + * server-side: minting a token implicitly creates/joins the room. + */ +export function useOpenHuddle() { + const navigation = + useNavigation>(); + const [loading, setLoading] = useState(false); + + const open = useCallback( + async (networkId: string, streamId: string, streamName: string) => { + if (loading) return; + setLoading(true); + try { + const { token, server_url } = await apiClient.getLivekitToken( + networkId, + streamId, + ); + navigation.navigate("Huddle", { + networkId, + streamId, + streamName, + token, + serverUrl: server_url, + }); + } catch (err) { + toast.error(toUserMessage(err)); + } finally { + setLoading(false); + } + }, + [navigation, loading], + ); + + return { open, loading }; +} diff --git a/js/mobile/src/features/stream-view/StreamTopActions.tsx b/js/mobile/src/features/stream-view/StreamTopActions.tsx index c335658..8d55602 100644 --- a/js/mobile/src/features/stream-view/StreamTopActions.tsx +++ b/js/mobile/src/features/stream-view/StreamTopActions.tsx @@ -1,9 +1,16 @@ -import { Pressable, Text, View } from "react-native"; -import { EllipsisVertical, Globe, Maximize2, Minimize2 } from "lucide-react-native"; +import { ActivityIndicator, Pressable, Text, View } from "react-native"; +import { + EllipsisVertical, + Globe, + Headphones, + Maximize2, + Minimize2, +} from "lucide-react-native"; import type { Human, Particle } from "@/api/types"; import { parseVisibleTo } from "@/lib/stream-visibility"; import { cn } from "@/lib/utils"; import { Avatar } from "@/components/Avatar"; +import { useOpenHuddle } from "@/features/huddle/use-open-huddle"; import { useStreamPresence } from "./stream-presence-context"; interface StreamTopActionsProps { @@ -36,6 +43,9 @@ export function StreamTopActions({ showFitToggle, }: StreamTopActionsProps) { const { onlineHumanIds } = useStreamPresence(); + const { open: openHuddle, loading: huddleLoading } = useOpenHuddle(); + const huddleCount = streamParticle.huddle_active_participants?.length ?? 0; + const huddleActive = huddleCount > 0; const visibility = parseVisibleTo(streamParticle.visible_to, networkId); const memberIds = visibility.mode === "network" @@ -79,6 +89,37 @@ export function StreamTopActions({ ) : null} + + void openHuddle( + networkId, + streamParticle.id, + streamParticle.properties.name, + ) + } + disabled={huddleLoading} + accessibilityLabel={huddleActive ? "Join huddle" : "Start huddle"} + className={cn( + "h-8 items-center justify-center rounded-full px-2.5 flex-row gap-1", + huddleActive + ? "bg-red-500/90 active:bg-red-600" + : "bg-white/10 active:bg-white/20", + )} + > + {huddleLoading ? ( + + ) : ( + <> + + {huddleActive ? ( + + {huddleCount} + + ) : null} + + )} + + {showFitToggle ? ( ) : null} - {isUnseen ? ( + {(particle.huddle_active_participants?.length ?? 0) > 0 ? ( + + + + {particle.huddle_active_participants?.length} + + + ) : isUnseen ? ( ) : null} diff --git a/js/mobile/src/navigation/RootNavigator.tsx b/js/mobile/src/navigation/RootNavigator.tsx index 0bbf270..1bd39b5 100644 --- a/js/mobile/src/navigation/RootNavigator.tsx +++ b/js/mobile/src/navigation/RootNavigator.tsx @@ -6,6 +6,7 @@ import { NetworkListScreen } from "@/features/networks/NetworkListScreen"; import { StreamListScreen } from "@/features/streams/StreamListScreen"; import { NewStreamScreen } from "@/features/streams/NewStreamScreen"; import { StreamViewScreen } from "@/features/stream-view/StreamViewScreen"; +import { HuddleScreen } from "@/features/huddle/HuddleScreen"; import { SettingsScreen } from "@/features/settings/SettingsScreen"; import { AccountScreen } from "@/features/settings/AccountScreen"; import type { RootStackParamList } from "./types"; @@ -43,6 +44,11 @@ export function RootNavigator() { component={StreamViewScreen} options={{ animation: "fade", gestureEnabled: false }} /> + =5.1.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== +safe-push-apply@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/safe-push-apply/-/safe-push-apply-1.0.0.tgz#01850e981c1602d398c85081f360e4e6d03d27f5" + integrity sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA== + dependencies: + es-errors "^1.3.0" + isarray "^2.0.5" + safe-regex-test@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.1.0.tgz#7f87dfb67a3150782eaaf18583ff5d1711ac10c1" @@ -5461,6 +6067,16 @@ scheduler@0.26.0: resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.26.0.tgz#4ce8a8c2a2095f13ea11bf9a445be50c555d6337" integrity sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA== +sdp-transform@^2.15.0: + version "2.15.0" + resolved "https://registry.yarnpkg.com/sdp-transform/-/sdp-transform-2.15.0.tgz#79d37a2481916f36a0534e07b32ceaa87f71df42" + integrity sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw== + +sdp@^3.2.0: + version "3.2.2" + resolved "https://registry.yarnpkg.com/sdp/-/sdp-3.2.2.tgz#fdef97433fd61d8950cac8987803a84b11fc0aba" + integrity sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA== + semver@7.7.2: version "7.7.2" resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58" @@ -5522,6 +6138,25 @@ set-function-length@^1.2.2: gopd "^1.0.1" has-property-descriptors "^1.0.2" +set-function-name@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.2.tgz#16a705c5a0dc2f5e638ca96d8a8cd4e1c2b90985" + integrity sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + functions-have-names "^1.2.3" + has-property-descriptors "^1.0.2" + +set-proto@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/set-proto/-/set-proto-1.0.0.tgz#0760dbcff30b2d7e801fd6e19983e56da337565e" + integrity sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw== + dependencies: + dunder-proto "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + setprototypeof@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" @@ -5549,6 +6184,46 @@ shell-quote@^1.6.1: resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.3.tgz#55e40ef33cf5c689902353a3d8cd1a6725f08b4b" integrity sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw== +side-channel-list@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.1.tgz#c2e0b5a14a540aebee3bbc6c3f8666cc9b509127" + integrity sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.4" + +side-channel-map@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" + integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + +side-channel-weakmap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea" + integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + side-channel-map "^1.0.1" + +side-channel@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9" + integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + side-channel-list "^1.0.0" + side-channel-map "^1.0.1" + side-channel-weakmap "^1.0.2" + signal-exit@^3.0.2, signal-exit@^3.0.7: version "3.0.7" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" @@ -5652,6 +6327,14 @@ statuses@~2.0.2: resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382" integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== +stop-iteration-iterator@^1.0.0, stop-iteration-iterator@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz#f481ff70a548f6124d0312c3aa14cbfa7aa542ad" + integrity sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ== + dependencies: + es-errors "^1.3.0" + internal-slot "^1.1.0" + stream-buffers@2.2.x: version "2.2.0" resolved "https://registry.yarnpkg.com/stream-buffers/-/stream-buffers-2.2.0.tgz#91d5f5130d1cef96dcfa7f726945188741d09ee4" @@ -5671,6 +6354,38 @@ string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.1" +string.prototype.trim@^1.2.10: + version "1.2.10" + resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz#40b2dd5ee94c959b4dcfb1d65ce72e90da480c81" + integrity sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.2" + define-data-property "^1.1.4" + define-properties "^1.2.1" + es-abstract "^1.23.5" + es-object-atoms "^1.0.0" + has-property-descriptors "^1.0.2" + +string.prototype.trimend@^1.0.9: + version "1.0.9" + resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz#62e2731272cd285041b36596054e9f66569b6942" + integrity sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.2" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + +string.prototype.trimstart@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz#7ee834dda8c7c17eff3118472bb35bfedaa34dde" + integrity sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + strip-ansi@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae" @@ -5862,7 +6577,7 @@ ts-interface-checker@^0.1.9: resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699" integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA== -tslib@^2.1.0: +tslib@2.8.1, tslib@^2.1.0: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== @@ -5882,6 +6597,58 @@ type-fest@^0.7.1: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.7.1.tgz#8dda65feaf03ed78f0a3f9678f1869147f7c5c48" integrity sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg== +typed-array-buffer@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz#a72395450a4869ec033fd549371b47af3a2ee536" + integrity sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + is-typed-array "^1.1.14" + +typed-array-byte-length@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz#8407a04f7d78684f3d252aa1a143d2b77b4160ce" + integrity sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg== + dependencies: + call-bind "^1.0.8" + for-each "^0.3.3" + gopd "^1.2.0" + has-proto "^1.2.0" + is-typed-array "^1.1.14" + +typed-array-byte-offset@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz#ae3698b8ec91a8ab945016108aef00d5bff12355" + integrity sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.8" + for-each "^0.3.3" + gopd "^1.2.0" + has-proto "^1.2.0" + is-typed-array "^1.1.15" + reflect.getprototypeof "^1.0.9" + +typed-array-length@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.7.tgz#ee4deff984b64be1e118b0de8c9c877d5ce73d3d" + integrity sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg== + dependencies: + call-bind "^1.0.7" + for-each "^0.3.3" + gopd "^1.0.1" + is-typed-array "^1.1.13" + possible-typed-array-names "^1.0.0" + reflect.getprototypeof "^1.0.6" + +typed-emitter@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/typed-emitter/-/typed-emitter-2.1.0.tgz#ca78e3d8ef1476f228f548d62e04e3d4d3fd77fb" + integrity sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA== + optionalDependencies: + rxjs "^7.5.2" + typescript@~5.9.0: version "5.9.3" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" @@ -5892,6 +6659,16 @@ ua-parser-js@^0.7.33: resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.41.tgz#9f6dee58c389e8afababa62a4a2dc22edb69a452" integrity sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg== +unbox-primitive@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.1.0.tgz#8d9d2c9edeea8460c7f35033a88867944934d1e2" + integrity sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw== + dependencies: + call-bound "^1.0.3" + has-bigints "^1.0.2" + has-symbols "^1.1.0" + which-boxed-primitive "^1.1.1" + undici-types@~7.19.0: version "7.19.2" resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.19.2.tgz#1b67fc26d0f157a0cba3a58a5b5c1e2276b8ba2a" @@ -5948,6 +6725,13 @@ use-sync-external-store@^1.5.0: resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d" integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w== +usehooks-ts@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/usehooks-ts/-/usehooks-ts-3.1.1.tgz#0bb7f38f36f8219ee4509cc5e944ae610fb97656" + integrity sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA== + dependencies: + lodash.debounce "^4.0.8" + util-deprecate@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" @@ -6008,6 +6792,11 @@ wcwidth@^1.0.1: dependencies: defaults "^1.0.3" +web-streams-polyfill@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-4.3.0.tgz#2f5fe004e2c53f09f7e03aef25b892953cab99a6" + integrity sha512-/Gnggvj9oSrEvJbDyyPtAnxBt5fGQM2iWOKQNu7ie1OxDgK40iZpyV3TKaRiEzVj1oA1UxKnEy9XPXh6PW3eVw== + web-vitals@^4.2.4: version "4.2.4" resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-4.2.4.tgz#1d20bc8590a37769bd0902b289550936069184b7" @@ -6018,6 +6807,13 @@ webidl-conversions@^5.0.0: resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-5.0.0.tgz#ae59c8a00b121543a2acc65c0434f57b0fc11aff" integrity sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA== +webrtc-adapter@9.0.5: + version "9.0.5" + resolved "https://registry.yarnpkg.com/webrtc-adapter/-/webrtc-adapter-9.0.5.tgz#fb036c3db06e1a0d86c264da649e130187783b3f" + integrity sha512-U9vjByy/sK2OMXu5mmfuZFKTMIUQe34c0JXRO+oDrxJTsntdYT2iIFwYMOV7HhMTuktcZLGf2W1N/OcSf9ssWg== + dependencies: + sdp "^3.2.0" + websocket-driver@>=0.5.1: version "0.7.4" resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.4.tgz#89ad5295bbf64b480abcba31e4953aca706f5760" @@ -6032,6 +6828,14 @@ websocket-extensions@>=0.1.1: resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.4.tgz#7f8473bc839dfd87608adb95d7eb075211578a42" integrity sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg== +well-known-symbols@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/well-known-symbols/-/well-known-symbols-4.1.0.tgz#7f748817d7bfdd4a95395127a056ce5205910624" + integrity sha512-lKhCpGfPkaJnPKyep1Uj44pNmyrdupYHtxci2ThUCC/Y0px44d9BWt2dbewDif4PwOqSI6KkCdwuL22CDUj4rw== + dependencies: + get-intrinsic "^1.2.7" + has-symbols "^1.1.0" + whatwg-fetch@^3.0.0: version "3.6.20" resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz#580ce6d791facec91d37c72890995a0b48d31c70" @@ -6046,6 +6850,46 @@ whatwg-url-without-unicode@8.0.0-3: punycode "^2.1.1" webidl-conversions "^5.0.0" +which-boxed-primitive@^1.1.0, which-boxed-primitive@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz#d76ec27df7fa165f18d5808374a5fe23c29b176e" + integrity sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA== + dependencies: + is-bigint "^1.1.0" + is-boolean-object "^1.2.1" + is-number-object "^1.1.1" + is-string "^1.1.1" + is-symbol "^1.1.1" + +which-builtin-type@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/which-builtin-type/-/which-builtin-type-1.2.1.tgz#89183da1b4907ab089a6b02029cc5d8d6574270e" + integrity sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q== + dependencies: + call-bound "^1.0.2" + function.prototype.name "^1.1.6" + has-tostringtag "^1.0.2" + is-async-function "^2.0.0" + is-date-object "^1.1.0" + is-finalizationregistry "^1.1.0" + is-generator-function "^1.0.10" + is-regex "^1.2.1" + is-weakref "^1.0.2" + isarray "^2.0.5" + which-boxed-primitive "^1.1.0" + which-collection "^1.0.2" + which-typed-array "^1.1.16" + +which-collection@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/which-collection/-/which-collection-1.0.2.tgz#627ef76243920a107e7ce8e96191debe4b16c2a0" + integrity sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw== + dependencies: + is-map "^2.0.3" + is-set "^2.0.3" + is-weakmap "^2.0.2" + is-weakset "^2.0.3" + which-typed-array@^1.1.16, which-typed-array@^1.1.2: version "1.1.20" resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.20.tgz#3fdb7adfafe0ea69157b1509f3a1cd892bd1d122" @@ -6059,6 +6903,19 @@ which-typed-array@^1.1.16, which-typed-array@^1.1.2: gopd "^1.2.0" has-tostringtag "^1.0.2" +which-typed-array@^1.1.19: + version "1.1.21" + resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.21.tgz#ea7aab68168079646af06b4a36a6f7d7b72e1c0a" + integrity sha512-zbRA8cVm6io/d5W8uIe2hblzN76/Wm3v/yiythQvr+dpBWeqhPSWIDNj4zOyHi4zKbMK6DN34Xsr9jPHJERAEw== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.9" + call-bound "^1.0.4" + for-each "^0.3.5" + get-proto "^1.0.1" + gopd "^1.2.0" + has-tostringtag "^1.0.2" + which@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" -- 2.54.0 From 64f02d1c3bad2cd5588fa81a44773c7b4ba0989d Mon Sep 17 00:00:00 2001 From: Arjun Patel Date: Tue, 26 May 2026 15:37:35 -0700 Subject: [PATCH 2/4] feat: ship a web app (#218) * feat(orion): add endpoint for link metadata * refactor: cleanup comments * wip: plumbing for building a web app * setup deployment materials for web app * fix(web): favicon --- .github/workflows/deploy-llink-web.yml | 72 ++++++ go/cmd/orion/main.go | 17 +- go/internal/handler/metadata.go | 209 ++++++++++++++++++ go/k8s/dev/orion.yaml | 3 + go/k8s/prod/orion.yaml | 3 + js/desktop/.dockerignore | 8 + js/desktop/Dockerfile | 17 ++ js/desktop/k8s/dev/llink-web.yaml | 98 ++++++++ js/desktop/k8s/prod/llink-web.yaml | 95 ++++++++ js/desktop/nginx.conf | 23 ++ js/desktop/package.json | 8 +- js/desktop/skaffold.yaml | 38 ++++ js/desktop/src/App.tsx | 12 +- js/desktop/src/api/client.ts | 11 + .../src/autoplay_window/AutoplayApp.tsx | 99 ++------- .../src/components/autoplay-card-content.tsx | 90 ++++++++ .../src/components/in-app-autoplay-card.tsx | 42 ++++ .../src/components/link-preview-card.tsx | 3 +- js/desktop/src/components/window-controls.tsx | 11 +- js/desktop/src/electron.d.ts | 2 - .../attachments/attachment-lightbox.tsx | 3 +- js/desktop/src/features/auth/email-step.tsx | 5 +- .../src/features/compose/attachment-strip.tsx | 3 +- .../src/features/compose/compose-overlay.tsx | 5 +- .../features/compose/use-screen-recorder.ts | 15 +- js/desktop/src/features/network-billing.tsx | 5 +- .../particles/particle-attachments.tsx | 7 +- .../src/features/particles/stream-top-bar.tsx | 5 +- .../src/features/particles/stream-view.tsx | 8 +- js/desktop/src/features/settings-page.tsx | 7 +- js/desktop/src/hooks/use-dock-badge.ts | 5 +- js/desktop/src/hooks/use-link-metadata.ts | 5 +- js/desktop/src/hooks/use-stream-autoplay.ts | 3 +- js/desktop/src/lib/platform.ts | 5 - js/desktop/src/lib/platform/desktop-only.ts | 18 ++ js/desktop/src/lib/platform/electron.ts | 57 +++++ js/desktop/src/lib/platform/index.ts | 8 + js/desktop/src/lib/platform/index.web.ts | 8 + js/desktop/src/lib/platform/types.ts | 63 ++++++ js/desktop/src/lib/platform/web.ts | 107 +++++++++ js/desktop/src/lib/router-shell.tsx | 6 + js/desktop/src/lib/router-shell.web.tsx | 6 + js/desktop/src/lib/sentry.web.ts | 25 +++ js/desktop/src/main.ts | 111 ---------- js/desktop/src/preload.ts | 1 - .../src/stores/autoplay-payload-store.ts | 17 ++ js/desktop/src/web/index.html | 13 ++ js/desktop/src/web/public/icon.png | Bin 0 -> 35911 bytes js/desktop/src/web/renderer.tsx | 9 + js/desktop/vite.web.config.mts | 40 ++++ js/desktop/yarn.lock | 67 ++++++ 51 files changed, 1260 insertions(+), 238 deletions(-) create mode 100644 .github/workflows/deploy-llink-web.yml create mode 100644 go/internal/handler/metadata.go create mode 100644 js/desktop/.dockerignore create mode 100644 js/desktop/Dockerfile create mode 100644 js/desktop/k8s/dev/llink-web.yaml create mode 100644 js/desktop/k8s/prod/llink-web.yaml create mode 100644 js/desktop/nginx.conf create mode 100644 js/desktop/skaffold.yaml create mode 100644 js/desktop/src/components/autoplay-card-content.tsx create mode 100644 js/desktop/src/components/in-app-autoplay-card.tsx delete mode 100644 js/desktop/src/lib/platform.ts create mode 100644 js/desktop/src/lib/platform/desktop-only.ts create mode 100644 js/desktop/src/lib/platform/electron.ts create mode 100644 js/desktop/src/lib/platform/index.ts create mode 100644 js/desktop/src/lib/platform/index.web.ts create mode 100644 js/desktop/src/lib/platform/types.ts create mode 100644 js/desktop/src/lib/platform/web.ts create mode 100644 js/desktop/src/lib/router-shell.tsx create mode 100644 js/desktop/src/lib/router-shell.web.tsx create mode 100644 js/desktop/src/lib/sentry.web.ts create mode 100644 js/desktop/src/stores/autoplay-payload-store.ts create mode 100644 js/desktop/src/web/index.html create mode 100644 js/desktop/src/web/public/icon.png create mode 100644 js/desktop/src/web/renderer.tsx create mode 100644 js/desktop/vite.web.config.mts diff --git a/.github/workflows/deploy-llink-web.yml b/.github/workflows/deploy-llink-web.yml new file mode 100644 index 0000000..43087b6 --- /dev/null +++ b/.github/workflows/deploy-llink-web.yml @@ -0,0 +1,72 @@ +name: Deploy llink-web + +on: + workflow_dispatch: + inputs: + environment: + description: "Target environment" + required: true + type: choice + options: [dev, prod] + +concurrency: + group: deploy-llink-web-${{ inputs.environment }} + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + environment: ${{ inputs.environment }} + defaults: + run: + working-directory: js/desktop + steps: + - uses: actions/checkout@v4 + + - name: Resolve environment settings + id: env + run: | + case "${{ inputs.environment }}" in + dev) + echo "project=flowy-dev-440017" >> "$GITHUB_OUTPUT" + echo "cluster=cluster" >> "$GITHUB_OUTPUT" + ;; + prod) + echo "project=flowy-prod-440017" >> "$GITHUB_OUTPUT" + echo "cluster=prod-cluster" >> "$GITHUB_OUTPUT" + ;; + *) + echo "Unknown environment: ${{ inputs.environment }}" >&2; exit 1 + ;; + esac + echo "region=us-west2" >> "$GITHUB_OUTPUT" + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@v2 + with: + credentials_json: ${{ inputs.environment == 'prod' && secrets.PROD_GKE_SERVICE_ACCOUNT_KEY || secrets.DEV_GKE_SERVICE_ACCOUNT_KEY }} + + - uses: google-github-actions/setup-gcloud@v2 + + - name: Install gke-gcloud-auth-plugin + run: gcloud components install gke-gcloud-auth-plugin --quiet + + - name: Configure Docker for Artifact Registry + run: gcloud auth configure-docker us-west2-docker.pkg.dev --quiet + + - name: Get GKE credentials + run: | + gcloud container clusters get-credentials "${{ steps.env.outputs.cluster }}" \ + --region "${{ steps.env.outputs.region }}" \ + --project "${{ steps.env.outputs.project }}" + + - name: Install skaffold + run: | + curl -fsSLo skaffold https://storage.googleapis.com/skaffold/releases/latest/skaffold-linux-amd64 + sudo install skaffold /usr/local/bin/ + skaffold version + + - name: Deploy + env: + SKAFFOLD_DEFAULT_REPO: us-west2-docker.pkg.dev/${{ steps.env.outputs.project }}/deployments + run: skaffold run -p ${{ inputs.environment }} diff --git a/go/cmd/orion/main.go b/go/cmd/orion/main.go index d6a5be6..b261048 100644 --- a/go/cmd/orion/main.go +++ b/go/cmd/orion/main.go @@ -6,6 +6,7 @@ import ( "log/slog" "net/http" "os" + "strings" "cloud.google.com/go/firestore" "cloud.google.com/go/storage" @@ -173,6 +174,9 @@ func main() { // Particles mux.Handle("GET /particles/{id}/download", withAuth(h.DownloadParticleMedia)) + // Link metadata + mux.Handle("GET /metadata", withAuth(h.GetLinkMetadata)) + // Depot mux.Handle("POST /depot/upload", withAuth(h.PrepareUpload)) mux.Handle("POST /depot/objects/{id}/confirm", withAuth(h.ConfirmUpload)) @@ -185,8 +189,17 @@ func main() { mux.Handle("GET /waitlist/{email}", withAuth(h.GetWaitlistEntry)) mux.Handle("POST /waitlist/invite", withAuth(h.InviteWaitlistEntrant)) - // nil = allow all origins (Electron app needs it). - muxWithCors := middleware.CORS(nil)(mux) + // CORS_ALLOWED_ORIGINS is a comma-separated whitelist for the web client. + // Empty / unset = allow all + var allowedOrigins []string + if raw := os.Getenv("CORS_ALLOWED_ORIGINS"); raw != "" { + for _, o := range strings.Split(raw, ",") { + if o = strings.TrimSpace(o); o != "" { + allowedOrigins = append(allowedOrigins, o) + } + } + } + muxWithCors := middleware.CORS(allowedOrigins)(mux) addr := fmt.Sprintf("0.0.0.0:%s", port) slog.Info("running server", "addr", addr) diff --git a/go/internal/handler/metadata.go b/go/internal/handler/metadata.go new file mode 100644 index 0000000..cccae0f --- /dev/null +++ b/go/internal/handler/metadata.go @@ -0,0 +1,209 @@ +package handler + +import ( + "context" + "errors" + "io" + "log/slog" + "net" + "net/http" + "net/url" + "regexp" + "strings" + "sync" + "time" +) + +// LinkMetadata mirrors the TS shape in `js/desktop/src/lib/link-metadata.ts`. +// All clients (web and Electron) call this endpoint — the Electron main-process +// fetcher was retired so both clients share one parser and the server-side cache. +type LinkMetadata struct { + URL string `json:"url"` + Title *string `json:"title"` + Description *string `json:"description"` + Image *string `json:"image"` + Favicon *string `json:"favicon"` + Domain string `json:"domain"` +} + +const ( + metadataFetchTimeout = 5 * time.Second + metadataMaxBytes = 50 * 1024 + metadataUserAgent = "Mozilla/5.0 (compatible; llink/1.0)" +) + +// In-process cache, unbounded but only successful results are stored. +// URL space is bounded in practice. +var metadataCache sync.Map // map[string]LinkMetadata + +func (h *Handler) GetLinkMetadata(w http.ResponseWriter, r *http.Request) { + raw := r.URL.Query().Get("url") + if raw == "" { + http.Error(w, "url is required", http.StatusBadRequest) + return + } + + parsed, err := url.Parse(raw) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { + http.Error(w, "invalid url", http.StatusBadRequest) + return + } + + if cached, ok := metadataCache.Load(raw); ok { + writeJSON(w, cached) + return + } + + if err := guardSSRF(parsed.Hostname()); err != nil { + // Treat unresolvable / private hosts as "no metadata" rather than 4xx — the + // client treats a null body as a progressive-enhancement miss. + writeJSON(w, nil) + return + } + + meta, err := fetchLinkMetadata(r.Context(), parsed) + if err != nil { + slog.Warn("link metadata fetch failed", "url", raw, "error", err) + writeJSON(w, nil) + return + } + + metadataCache.Store(raw, *meta) + writeJSON(w, meta) +} + +// guardSSRF resolves the host and rejects loopback, private, link-local, and +// unspecified addresses so the endpoint can't be turned into an internal-network +// probe. +func guardSSRF(host string) error { + ips, err := net.LookupIP(host) + if err != nil { + return err + } + if len(ips) == 0 { + return errors.New("no addresses for host") + } + for _, ip := range ips { + if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || ip.IsUnspecified() { + return errors.New("private or local address") + } + } + return nil +} + +func fetchLinkMetadata(ctx context.Context, target *url.URL) (*LinkMetadata, error) { + ctx, cancel := context.WithTimeout(ctx, metadataFetchTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", metadataUserAgent) + req.Header.Set("Accept", "text/html") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, errors.New("upstream non-2xx") + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, metadataMaxBytes)) + if err != nil { + return nil, err + } + html := string(body) + + domain := strings.TrimPrefix(target.Hostname(), "www.") + title := firstNonNil(metaContent(html, "og:title"), parseTitle(html)) + description := firstNonNil( + metaContent(html, "og:description"), + metaContent(html, "description"), + ) + image := resolveURL(metaContent(html, "og:image"), target) + favicon := parseFavicon(html, target) + + return &LinkMetadata{ + URL: target.String(), + Title: title, + Description: description, + Image: image, + Favicon: favicon, + Domain: domain, + }, nil +} + +var titleRe = regexp.MustCompile(`(?i)]*>([^<]*)`) + +func parseTitle(html string) *string { + m := titleRe.FindStringSubmatch(html) + if len(m) < 2 { + return nil + } + s := strings.TrimSpace(m[1]) + if s == "" { + return nil + } + return &s +} + +// metaContent finds in either attribute order. +func metaContent(html, prop string) *string { + escaped := regexp.QuoteMeta(prop) + re := regexp.MustCompile( + `(?i)]*(?:property|name)=["']` + escaped + `["'][^>]*content=["']([^"']*)["']` + + `|]*content=["']([^"']*)["'][^>]*(?:property|name)=["']` + escaped + `["']`, + ) + m := re.FindStringSubmatch(html) + if len(m) == 0 { + return nil + } + for _, g := range m[1:] { + if g != "" { + return &g + } + } + return nil +} + +var ( + faviconRe1 = regexp.MustCompile(`(?i)]*rel=["'](?:shortcut )?icon["'][^>]*href=["']([^"']*)["']`) + faviconRe2 = regexp.MustCompile(`(?i)]*href=["']([^"']*)["'][^>]*rel=["'](?:shortcut )?icon["']`) +) + +func parseFavicon(html string, base *url.URL) *string { + for _, re := range []*regexp.Regexp{faviconRe1, faviconRe2} { + if m := re.FindStringSubmatch(html); len(m) >= 2 { + return resolveURL(&m[1], base) + } + } + // Fall back to /favicon.ico + fallback := (&url.URL{Scheme: base.Scheme, Host: base.Host, Path: "/favicon.ico"}).String() + return &fallback +} + +func resolveURL(src *string, base *url.URL) *string { + if src == nil || *src == "" { + return nil + } + parsed, err := url.Parse(*src) + if err != nil { + return src + } + resolved := base.ResolveReference(parsed).String() + return &resolved +} + +func firstNonNil(values ...*string) *string { + for _, v := range values { + if v != nil && *v != "" { + return v + } + } + return nil +} diff --git a/go/k8s/dev/orion.yaml b/go/k8s/dev/orion.yaml index 0596ed6..e6f7b97 100644 --- a/go/k8s/dev/orion.yaml +++ b/go/k8s/dev/orion.yaml @@ -79,6 +79,9 @@ spec: value: "llink://billing/success" - name: "BILLING_CANCEL_URL" value: "llink://billing/cancel" + # Web client origins + - name: "CORS_ALLOWED_ORIGINS" + value: "http://localhost:5173,http://localhost:5174,http://localhost:5175,https://llink.dev.flowy.live" --- diff --git a/go/k8s/prod/orion.yaml b/go/k8s/prod/orion.yaml index aaee2e2..b9b2dd2 100644 --- a/go/k8s/prod/orion.yaml +++ b/go/k8s/prod/orion.yaml @@ -76,6 +76,9 @@ spec: value: "llink://billing/success" - name: "BILLING_CANCEL_URL" value: "llink://billing/cancel" + # Web client origins + - name: "CORS_ALLOWED_ORIGINS" + value: "https://llink.flowy.live" --- diff --git a/js/desktop/.dockerignore b/js/desktop/.dockerignore new file mode 100644 index 0000000..15d123e --- /dev/null +++ b/js/desktop/.dockerignore @@ -0,0 +1,8 @@ +node_modules +dist-web +.vite +out +.git +.gitignore +.DS_Store +*.log diff --git a/js/desktop/Dockerfile b/js/desktop/Dockerfile new file mode 100644 index 0000000..29e90f9 --- /dev/null +++ b/js/desktop/Dockerfile @@ -0,0 +1,17 @@ +# Two-stage build for the llink web SPA. APP_ENV is baked into the bundle +# at build time via vite's `define` (see vite.env.ts). +FROM node:22-alpine AS builder + +WORKDIR /app +ARG APP_ENV=prod + +COPY package.json yarn.lock ./ +RUN yarn install --frozen-lockfile + +COPY . . +RUN APP_ENV=$APP_ENV yarn web:build:ci + +FROM nginx:1.27-alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=builder /app/dist-web /usr/share/nginx/html +EXPOSE 80 diff --git a/js/desktop/k8s/dev/llink-web.yaml b/js/desktop/k8s/dev/llink-web.yaml new file mode 100644 index 0000000..1f89a3e --- /dev/null +++ b/js/desktop/k8s/dev/llink-web.yaml @@ -0,0 +1,98 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: llink-web +spec: + selector: + matchLabels: + app: llink-web + replicas: 1 + template: + metadata: + labels: + app: llink-web + spec: + serviceAccountName: default-service-account + nodeSelector: + cloud.google.com/gke-spot: "true" + terminationGracePeriodSeconds: 15 + containers: + - name: llink-web + image: "llink-web" + ports: + - containerPort: 80 + resources: + requests: + memory: "64Mi" + cpu: 20m + limits: + memory: "64Mi" + cpu: 50m + readinessProbe: + httpGet: + path: /health + port: 80 + initialDelaySeconds: 2 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /health + port: 80 + initialDelaySeconds: 10 + periodSeconds: 30 + +--- + +apiVersion: v1 +kind: Service +metadata: + name: llink-web +spec: + selector: + app: llink-web + ports: + - port: 80 + targetPort: 80 + protocol: TCP + +--- + +kind: HTTPRoute +apiVersion: gateway.networking.k8s.io/v1beta1 +metadata: + name: llink-web +spec: + parentRefs: + - kind: Gateway + name: external-gateway + hostnames: + - llink.dev.flowy.live + rules: + - backendRefs: + - name: llink-web + port: 80 + +--- + +apiVersion: networking.gke.io/v1 +kind: HealthCheckPolicy +metadata: + name: llink-web-service-health-check +spec: + default: + checkIntervalSec: 15 + timeoutSec: 15 + healthyThreshold: 1 + unhealthyThreshold: 2 + logConfig: + enabled: true + config: + type: HTTP + httpHealthCheck: + portSpecification: USE_FIXED_PORT + port: 80 + requestPath: /health + targetRef: + group: "" + kind: Service + name: llink-web diff --git a/js/desktop/k8s/prod/llink-web.yaml b/js/desktop/k8s/prod/llink-web.yaml new file mode 100644 index 0000000..d22514e --- /dev/null +++ b/js/desktop/k8s/prod/llink-web.yaml @@ -0,0 +1,95 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: llink-web +spec: + selector: + matchLabels: + app: llink-web + replicas: 2 + template: + metadata: + labels: + app: llink-web + spec: + serviceAccountName: default-service-account + containers: + - name: llink-web + image: "llink-web" + ports: + - containerPort: 80 + resources: + requests: + memory: "64Mi" + cpu: 20m + limits: + memory: "64Mi" + cpu: 50m + readinessProbe: + httpGet: + path: /health + port: 80 + initialDelaySeconds: 2 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /health + port: 80 + initialDelaySeconds: 10 + periodSeconds: 30 + +--- + +apiVersion: v1 +kind: Service +metadata: + name: llink-web +spec: + selector: + app: llink-web + ports: + - port: 80 + targetPort: 80 + protocol: TCP + +--- + +kind: HTTPRoute +apiVersion: gateway.networking.k8s.io/v1beta1 +metadata: + name: llink-web +spec: + parentRefs: + - kind: Gateway + name: external-gateway + hostnames: + - llink.flowy.live + rules: + - backendRefs: + - name: llink-web + port: 80 + +--- + +apiVersion: networking.gke.io/v1 +kind: HealthCheckPolicy +metadata: + name: llink-web-service-health-check +spec: + default: + checkIntervalSec: 15 + timeoutSec: 15 + healthyThreshold: 1 + unhealthyThreshold: 2 + logConfig: + enabled: true + config: + type: HTTP + httpHealthCheck: + portSpecification: USE_FIXED_PORT + port: 80 + requestPath: /health + targetRef: + group: "" + kind: Service + name: llink-web diff --git a/js/desktop/nginx.conf b/js/desktop/nginx.conf new file mode 100644 index 0000000..7be8dfe --- /dev/null +++ b/js/desktop/nginx.conf @@ -0,0 +1,23 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location = /health { + access_log off; + add_header Content-Type text/plain; + return 200 "ok\n"; + } + + location /assets/ { + expires 1y; + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + location / { + try_files $uri $uri/ /index.html; + add_header Cache-Control "no-cache"; + } +} diff --git a/js/desktop/package.json b/js/desktop/package.json index b14bf13..8ef87c0 100644 --- a/js/desktop/package.json +++ b/js/desktop/package.json @@ -14,7 +14,11 @@ "publish:mac": "echo '\n⚠️ Have you bumped the version in package.json? (current: '$(node -p \"require('./package.json').version\")') [y/N]' && read -r answer && [ \"$answer\" = \"y\" ] && APP_ENV=prod electron-forge publish --arch=arm64 && APP_ENV=prod electron-forge publish --arch=x64", "invalidate-gcs-cache": "gsutil setmeta -h 'Cache-Control:no-cache, no-store, must-revalidate' gs://flowy-releases/llink/darwin/arm64/RELEASES.json && gsutil setmeta -h 'Cache-Control:no-cache, no-store, must-revalidate' gs://flowy-releases/llink/darwin/x64/RELEASES.json && gsutil setmeta -h 'Cache-Control:no-cache, no-store, must-revalidate' gs://flowy-releases/llink/win32/x64/RELEASES", "lint": "eslint --ext .ts,.tsx .", - "compile": "npx tsc --noEmit 2>&1 | grep '^src/'" + "compile": "npx tsc --noEmit 2>&1 | grep '^src/'", + "web:dev": "cross-env APP_ENV=dev vite --config vite.web.config.mts", + "web:build": "cross-env APP_ENV=prod vite build --config vite.web.config.mts", + "web:build:ci": "vite build --config vite.web.config.mts", + "web:preview": "vite preview --config vite.web.config.mts" }, "keywords": [], "author": { @@ -43,6 +47,7 @@ "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", "@vitejs/plugin-react": "^5.1.4", + "cross-env": "^10.1.0", "electron": "40.6.0", "eslint": "^8.57.1", "eslint-plugin-import": "^2.32.0", @@ -54,6 +59,7 @@ "@livekit/components-react": "^2.9.20", "@livekit/components-styles": "^1.2.0", "@sentry/electron": "^7.11.0", + "@sentry/react": "^10.54.0", "@tanstack/react-query": "^5.90.21", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/js/desktop/skaffold.yaml b/js/desktop/skaffold.yaml new file mode 100644 index 0000000..992b41e --- /dev/null +++ b/js/desktop/skaffold.yaml @@ -0,0 +1,38 @@ +apiVersion: skaffold/v4beta11 +kind: Config +metadata: + name: llink-web +build: + local: {} + tagPolicy: + gitCommit: + variant: AbbrevCommitSha +profiles: + - name: dev + build: + artifacts: + - image: llink-web + context: . + docker: + dockerfile: Dockerfile + buildArgs: + APP_ENV: dev + manifests: + rawYaml: + - k8s/dev/llink-web.yaml + deploy: + kubectl: {} + - name: prod + build: + artifacts: + - image: llink-web + context: . + docker: + dockerfile: Dockerfile + buildArgs: + APP_ENV: prod + manifests: + rawYaml: + - k8s/prod/llink-web.yaml + deploy: + kubectl: {} diff --git a/js/desktop/src/App.tsx b/js/desktop/src/App.tsx index 7c131b1..aca5249 100644 --- a/js/desktop/src/App.tsx +++ b/js/desktop/src/App.tsx @@ -1,5 +1,6 @@ import { useEffect } from "react"; -import { HashRouter, Routes, Route, useNavigate } from "react-router-dom"; +import { Routes, Route, useNavigate } from "react-router-dom"; +import { RouterShell } from "@/lib/router-shell"; import { TooltipProvider } from "@/components/ui/tooltip"; import { useAuthStore } from "@/stores/auth-store"; import { LoginPage } from "@/features/auth/login-page"; @@ -19,6 +20,8 @@ import { TopLevelErrorBoundary, } from "@/components/app-error-boundary"; import { SoundEffectsProvider } from "@/lib/sound-effects/sound-effects-provider"; +import { platform } from "@/lib/platform"; +import { InAppAutoplayCard } from "@/components/in-app-autoplay-card"; const queryClient = createQueryClient(); @@ -53,7 +56,7 @@ function AutoplayNavigationListener() { const navigate = useNavigate(); useEffect(() => { - return window.electronAutoplay.onNavigate((data) => { + return platform.autoplay.onNavigate((data) => { navigate(`/${data.networkId}/${data.streamId}`); }); }, [navigate]); @@ -63,8 +66,9 @@ function AutoplayNavigationListener() { function AuthenticatedApp() { return ( - + + } /> @@ -80,7 +84,7 @@ function AuthenticatedApp() { - + ); } diff --git a/js/desktop/src/api/client.ts b/js/desktop/src/api/client.ts index 27ef599..08d5835 100644 --- a/js/desktop/src/api/client.ts +++ b/js/desktop/src/api/client.ts @@ -27,6 +27,7 @@ import type { RevokeInvitationRequest, SignInRequest, } from "./types"; +import type { LinkMetadata } from "@/lib/link-metadata"; interface ApiClientConfig { baseUrl: string; @@ -256,6 +257,16 @@ class ApiClient { `/networks/${networkId}/usage`, ); } + + // --- Link metadata --- + + async getLinkMetadata(url: string): Promise { + const response = await this.fetch( + "GET", + `/metadata?url=${encodeURIComponent(url)}`, + ); + return (await response.json()) as LinkMetadata | null; + } } export const apiClient = new ApiClient({ diff --git a/js/desktop/src/autoplay_window/AutoplayApp.tsx b/js/desktop/src/autoplay_window/AutoplayApp.tsx index c2326d8..b6240ca 100644 --- a/js/desktop/src/autoplay_window/AutoplayApp.tsx +++ b/js/desktop/src/autoplay_window/AutoplayApp.tsx @@ -1,102 +1,43 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { X } from 'lucide-react'; -import type { AutoplayPayload } from '@/lib/autoplay-ipc'; +import { useCallback, useEffect, useState } from "react"; +import type { AutoplayPayload } from "@/lib/autoplay-ipc"; +import { AutoplayCardContent } from "@/components/autoplay-card-content"; export function AutoplayApp() { const [payload, setPayload] = useState(null); - const mediaRef = useRef(null); useEffect(() => { return window.electronAutoplay.onPlay((p) => setPayload(p)); }, []); useEffect(() => { - return window.electronAutoplay.onStop(() => { - mediaRef.current?.pause(); - setPayload(null); - }); + return window.electronAutoplay.onStop(() => setPayload(null)); }, []); - const stop = useCallback(() => { - mediaRef.current?.pause(); + const handleDismiss = useCallback(() => { setPayload(null); window.electronAutoplay.dismiss(); }, []); + const handleNavigate = useCallback(() => { + if (!payload) return; + window.electronAutoplay.navigate({ + networkId: payload.networkId, + streamId: payload.streamId, + }); + }, [payload]); + if (!payload) { return
; } - const isVideo = payload.mimeType.startsWith('video/'); - - const handleClick = () => { - mediaRef.current?.pause(); - window.electronAutoplay.navigate({ - networkId: payload.networkId, - streamId: payload.streamId, - }); - }; - - const handleClose = (e: React.MouseEvent) => { - e.stopPropagation(); - stop(); - }; - return ( -
- {isVideo ? ( - <> -