Live video on the AT Protocol

share sheet component with Bluesky sharing and copy options

+195
+2
js/app/components/mobile/bottom-metadata.tsx
··· 2 Button, 3 layout, 4 PlayerUI, 5 Text, 6 useAvatars, 7 useLivestreamInfo, ··· 74 <View style={[layout.flex.row, layout.flex.center, gap.all[4]]}> 75 <View style={[layout.flex.row, layout.flex.center, gap.all[2]]}> 76 <PlayerUI.Viewers /> 77 </View> 78 <Button 79 variant="outline"
··· 2 Button, 3 layout, 4 PlayerUI, 5 + ShareSheet, 6 Text, 7 useAvatars, 8 useLivestreamInfo, ··· 75 <View style={[layout.flex.row, layout.flex.center, gap.all[4]]}> 76 <View style={[layout.flex.row, layout.flex.center, gap.all[2]]}> 77 <PlayerUI.Viewers /> 78 + <ShareSheet /> 79 </View> 80 <Button 81 variant="outline"
+9
js/components/src/components/icons/bluesky-icon.tsx
···
··· 1 + import Svg, { Path } from "react-native-svg"; 2 + 3 + export function BlueskyIcon({ size = 20, color = "#000" }) { 4 + return ( 5 + <Svg width={size} height={size} viewBox="0 0 600 530" fill={color}> 6 + <Path d="M136 44c66 50 138 151 164 205 26-54 98-155 164-205 48-36 126-64 126 25 0 18-10 149-16 170-21 74-96 93-163 81 117 20 147 86 82 153-122 125-176-32-189-72-3-8-4-11-4-8 0-3-1 0-4 8-13 40-67 197-189 72-65-67-35-133 82-153-67 12-142-7-163-81-6-21-16-152-16-170 0-89 78-61 126-25z" /> 7 + </Svg> 8 + ); 9 + }
+182
js/components/src/components/share/sharesheet.tsx
···
··· 1 + import { Code, Copy, Link2, Share2 } from "lucide-react-native"; 2 + import { useCallback, useState } from "react"; 3 + import { Clipboard, Linking, Platform, View } from "react-native"; 4 + import { colors } from "../../lib/theme"; 5 + import { useLivestreamStore } from "../../livestream-store"; 6 + import { useUrl } from "../../streamplace-store"; 7 + import { BlueskyIcon } from "../icons/bluesky-icon"; 8 + import { 9 + DropdownMenu, 10 + DropdownMenuGroup, 11 + DropdownMenuItem, 12 + DropdownMenuTrigger, 13 + ResponsiveDropdownMenuContent, 14 + Text, 15 + } from "../ui"; 16 + 17 + export interface ShareSheetProps { 18 + onShare?: (action: string, success: boolean) => void; 19 + } 20 + 21 + export function ShareSheet({ onShare }: ShareSheetProps = {}) { 22 + const profile = useLivestreamStore((x) => x.profile); 23 + const [isCopying, setIsCopying] = useState(false); 24 + const url = useUrl(); 25 + 26 + // Get the current stream URL 27 + const getStreamUrl = useCallback(() => { 28 + return url + (profile ? `/@${profile.handle}` : ""); 29 + }, [profile]); 30 + 31 + // Get the embed URL 32 + const getEmbedUrl = useCallback(() => { 33 + return url + (profile ? `/embed/${profile.handle}` : ""); 34 + }, [profile]); 35 + 36 + // Get embed code 37 + const getEmbedCode = useCallback(() => { 38 + const embedUrl = getEmbedUrl(); 39 + return `<iframe src="${embedUrl}" width="640" height="360" frameborder="0" allowfullscreen></iframe>`; 40 + }, [getEmbedUrl]); 41 + 42 + // Copy to clipboard handler 43 + const copyToClipboard = useCallback( 44 + async (text: string, label: string) => { 45 + setIsCopying(true); 46 + try { 47 + if (Platform.OS === "web") { 48 + await navigator.clipboard.writeText(text); 49 + } else { 50 + Clipboard.setString(text); 51 + } 52 + onShare?.(`copy_${label.toLowerCase().replace(/\s+/g, "_")}`, true); 53 + } catch (error) { 54 + onShare?.(`copy_${label.toLowerCase().replace(/\s+/g, "_")}`, false); 55 + } finally { 56 + setIsCopying(false); 57 + } 58 + }, 59 + [onShare], 60 + ); 61 + 62 + // Share to Bluesky 63 + const shareToBluesky = useCallback(() => { 64 + const streamUrl = getStreamUrl(); 65 + const text = profile 66 + ? `Check out @${profile.handle} live on Streamplace! ${streamUrl}` 67 + : `Check out this stream on Streamplace! ${streamUrl}`; 68 + const blueskyUrl = `https://bsky.app/intent/compose?text=${encodeURIComponent(text)}`; 69 + Linking.openURL(blueskyUrl); 70 + onShare?.("share_bluesky", true); 71 + }, [profile, getStreamUrl, onShare]); 72 + 73 + // Share to Twitter/X 74 + const shareToTwitter = useCallback(() => { 75 + const streamUrl = getStreamUrl(); 76 + const text = profile 77 + ? `Check out @${profile.handle} live on Streamplace!` 78 + : `Check out this stream on Streamplace!`; 79 + const twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(streamUrl)}`; 80 + Linking.openURL(twitterUrl); 81 + onShare?.("share_twitter", true); 82 + }, [profile, getStreamUrl, onShare]); 83 + 84 + // Native share (mobile) 85 + const nativeShare = useCallback(async () => { 86 + const streamUrl = getStreamUrl(); 87 + const text = profile 88 + ? `Check out @${profile.handle} live on Streamplace!` 89 + : `Check out this stream on Streamplace!`; 90 + 91 + if (Platform.OS === "web" && navigator.share) { 92 + try { 93 + await navigator.share({ 94 + title: "Streamplace", 95 + text: text, 96 + url: streamUrl, 97 + }); 98 + onShare?.("share_native", true); 99 + } catch (error) { 100 + // User cancelled or error occurred 101 + onShare?.("share_native", false); 102 + } 103 + } 104 + }, [profile, getStreamUrl, onShare]); 105 + 106 + return ( 107 + <DropdownMenu> 108 + <DropdownMenuTrigger> 109 + <Share2 color={colors.gray[200]} /> 110 + </DropdownMenuTrigger> 111 + <ResponsiveDropdownMenuContent> 112 + <DropdownMenuGroup title="Share"> 113 + <DropdownMenuItem onPress={shareToBluesky} closeOnPress={true}> 114 + <View 115 + style={{ flexDirection: "row", alignItems: "center", gap: 12 }} 116 + > 117 + <BlueskyIcon size={20} color={colors.gray[400]} /> 118 + <Text>Share to Bluesky</Text> 119 + </View> 120 + </DropdownMenuItem> 121 + {/* <DropdownMenuItem onPress={shareToTwitter}> 122 + <View 123 + style={{ flexDirection: "row", alignItems: "center", gap: 12 }} 124 + > 125 + <MessageCircle size={20} color={colors.gray[400]} /> 126 + <Text>Share to X</Text> 127 + </View> 128 + </DropdownMenuItem> */} 129 + {/* navigator isn't on non-web */} 130 + {Platform.OS !== "web" || (navigator && (navigator as any).share) ? ( 131 + <DropdownMenuItem onPress={nativeShare}> 132 + <View 133 + style={{ flexDirection: "row", alignItems: "center", gap: 12 }} 134 + > 135 + <Share2 size={20} color={colors.gray[400]} /> 136 + <Text>More Options...</Text> 137 + </View> 138 + </DropdownMenuItem> 139 + ) : null} 140 + </DropdownMenuGroup> 141 + <DropdownMenuGroup title="Copy"> 142 + <DropdownMenuItem 143 + onPress={() => copyToClipboard(getStreamUrl(), "Stream link")} 144 + disabled={isCopying} 145 + closeOnPress={true} 146 + > 147 + <View 148 + style={{ flexDirection: "row", alignItems: "center", gap: 12 }} 149 + > 150 + <Link2 size={20} color={colors.gray[400]} /> 151 + <Text>Copy Link</Text> 152 + </View> 153 + </DropdownMenuItem> 154 + <DropdownMenuItem 155 + onPress={() => copyToClipboard(getEmbedCode(), "Embed code")} 156 + disabled={isCopying} 157 + closeOnPress={true} 158 + > 159 + <View 160 + style={{ flexDirection: "row", alignItems: "center", gap: 12 }} 161 + > 162 + <Code size={20} color={colors.gray[400]} /> 163 + <Text>Copy Embed Code</Text> 164 + </View> 165 + </DropdownMenuItem> 166 + <DropdownMenuItem 167 + closeOnPress={true} 168 + onPress={() => copyToClipboard(getEmbedUrl(), "Embed URL")} 169 + disabled={isCopying} 170 + > 171 + <View 172 + style={{ flexDirection: "row", alignItems: "center", gap: 12 }} 173 + > 174 + <Copy size={20} color={colors.gray[400]} /> 175 + <Text>Copy Embed URL</Text> 176 + </View> 177 + </DropdownMenuItem> 178 + </DropdownMenuGroup> 179 + </ResponsiveDropdownMenuContent> 180 + </DropdownMenu> 181 + ); 182 + }
+2
js/components/src/index.tsx
··· 30 export * from "./components/chat/system-message"; 31 export { default as VideoRetry } from "./components/mobile-player/video-retry"; 32 export * from "./lib/system-messages";
··· 30 export * from "./components/chat/system-message"; 31 export { default as VideoRetry } from "./components/mobile-player/video-retry"; 32 export * from "./lib/system-messages"; 33 + 34 + export * from "./components/share/sharesheet";