Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 296 lines 9.5 kB view raw
1import React, { useState, useRef, useEffect } from "react"; 2import { 3 Copy, 4 ExternalLink, 5 Check, 6 Share2, 7 MoreHorizontal, 8} from "lucide-react"; 9import { 10 AturiIcon, 11 BlueskyIcon, 12 BlackskyIcon, 13 WitchskyIcon, 14 CatskyIcon, 15 DeerIcon, 16} from "../common/Icons"; 17 18const SembleLogo = () => ( 19 <img src="/semble-logo.svg" alt="Semble" className="w-4 h-4 opacity-90" /> 20); 21 22const BLUESKY_COLOR = "#1185fe"; 23 24interface ShareMenuProps { 25 uri: string; 26 text?: string; 27 customUrl?: string; 28 handle?: string; 29 type?: string; 30 url?: string; 31} 32 33export default function ShareMenu({ 34 uri, 35 text, 36 customUrl, 37 handle, 38 type, 39 url, 40}: ShareMenuProps) { 41 const [isOpen, setIsOpen] = useState(false); 42 const [copied, setCopied] = useState<string | null>(null); 43 const menuRef = useRef<HTMLDivElement>(null); 44 const buttonRef = useRef<HTMLButtonElement>(null); 45 const [menuPosition, setMenuPosition] = useState({ 46 top: 0, 47 left: 0, 48 alignRight: false, 49 }); 50 51 const getShareUrl = () => { 52 if (customUrl) return customUrl; 53 if (!uri) return ""; 54 55 const uriParts = uri.split("/"); 56 const rkey = uriParts[uriParts.length - 1]; 57 const did = uriParts[2]; 58 59 if (uri.includes("network.cosmik.card")) 60 return `${window.location.origin}/at/${did}/${rkey}`; 61 if (handle && type) 62 return `${window.location.origin}/${handle}/${type.toLowerCase()}/${rkey}`; 63 return `${window.location.origin}/at/${did}/${rkey}`; 64 }; 65 66 const shareUrl = getShareUrl(); 67 const isSemble = uri && uri.includes("network.cosmik"); 68 69 const sembleUrl = (() => { 70 if (!isSemble) return ""; 71 const parts = (uri || "").split("/"); 72 const rkey = parts[parts.length - 1]; 73 const userHandle = handle || (parts.length > 2 ? parts[2] : ""); 74 75 if (uri.includes("network.cosmik.collection")) 76 return `https://semble.so/profile/${userHandle}/collections/${rkey}`; 77 if (uri.includes("network.cosmik.card") && url) 78 return `https://semble.so/url?id=${encodeURIComponent(url)}`; 79 return `https://semble.so/profile/${userHandle}`; 80 })(); 81 82 const handleCopy = async (textToCopy: string, key: string) => { 83 try { 84 await navigator.clipboard.writeText(textToCopy); 85 setCopied(key); 86 setTimeout(() => { 87 setCopied(null); 88 setIsOpen(false); 89 }, 1000); 90 } catch { 91 prompt("Copy this link:", textToCopy); 92 } 93 }; 94 95 const handleShareToFork = (domain: string) => { 96 const composeText = text 97 ? `${text.substring(0, 200)}...\n\n${shareUrl}` 98 : shareUrl; 99 const composeUrl = `https://${domain}/intent/compose?text=${encodeURIComponent(composeText)}`; 100 window.open(composeUrl, "_blank"); 101 setIsOpen(false); 102 }; 103 104 useEffect(() => { 105 const handleClickOutside = (e: MouseEvent) => { 106 if ( 107 menuRef.current && 108 !menuRef.current.contains(e.target as Node) && 109 !buttonRef.current?.contains(e.target as Node) 110 ) { 111 setIsOpen(false); 112 } 113 }; 114 if (isOpen) { 115 document.addEventListener("mousedown", handleClickOutside); 116 window.addEventListener("scroll", () => setIsOpen(false), true); 117 window.addEventListener("resize", () => setIsOpen(false)); 118 } 119 return () => { 120 document.removeEventListener("mousedown", handleClickOutside); 121 window.removeEventListener("scroll", () => setIsOpen(false), true); 122 window.removeEventListener("resize", () => setIsOpen(false)); 123 }; 124 }, [isOpen]); 125 126 const calculatePosition = () => { 127 if (!buttonRef.current) return; 128 const rect = buttonRef.current.getBoundingClientRect(); 129 const menuWidth = 240; 130 131 let top = rect.bottom + 8; 132 let left = rect.left; 133 let alignRight = false; 134 135 if (left + menuWidth > window.innerWidth - 16) { 136 left = rect.right - menuWidth; 137 alignRight = true; 138 } 139 140 if (top + 300 > window.innerHeight) { 141 top = rect.top - 8; 142 } 143 144 setMenuPosition({ top, left, alignRight }); 145 }; 146 147 const toggleMenu = () => { 148 if (!isOpen) calculatePosition(); 149 setIsOpen(!isOpen); 150 }; 151 152 const renderMenuItem = ( 153 label: string, 154 icon: React.ReactNode, 155 onClick: () => void, 156 isCopied: boolean = false, 157 highlight: boolean = false, 158 ) => ( 159 <button 160 onClick={onClick} 161 className={`w-full flex items-center gap-3 px-3 py-2.5 text-[14px] font-medium transition-colors rounded-lg group 162 ${ 163 highlight 164 ? "text-primary-700 dark:text-primary-400 bg-primary-50/50 dark:bg-primary-900/20 hover:bg-primary-50 dark:hover:bg-primary-900/30" 165 : "text-surface-700 dark:text-surface-200 hover:bg-surface-50 dark:hover:bg-surface-800 hover:text-surface-900 dark:hover:text-white" 166 }`} 167 > 168 <span 169 className={`flex items-center justify-center w-5 h-5 ${highlight ? "text-primary-600 dark:text-primary-400" : "text-surface-400 dark:text-surface-500 group-hover:text-surface-600 dark:group-hover:text-surface-300"}`} 170 > 171 {isCopied ? ( 172 <Check size={16} className="text-green-600 dark:text-green-400" /> 173 ) : ( 174 icon 175 )} 176 </span> 177 <span className="flex-1 text-left">{isCopied ? "Copied!" : label}</span> 178 </button> 179 ); 180 181 const shareForks = [ 182 { 183 name: "Bluesky", 184 domain: "bsky.app", 185 icon: <BlueskyIcon size={18} color={BLUESKY_COLOR} />, 186 }, 187 { 188 name: "Witchsky", 189 domain: "witchsky.app", 190 icon: <WitchskyIcon size={18} />, 191 }, 192 { 193 name: "Blacksky", 194 domain: "blacksky.community", 195 icon: <BlackskyIcon size={18} />, 196 }, 197 { name: "Catsky", domain: "catsky.social", icon: <CatskyIcon size={18} /> }, 198 { name: "Deer", domain: "deer.social", icon: <DeerIcon size={18} /> }, 199 ]; 200 201 return ( 202 <div className="relative inline-block"> 203 <button 204 ref={buttonRef} 205 onClick={toggleMenu} 206 className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg transition-all ${isOpen ? "text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-900/20" : "text-surface-400 dark:text-surface-500 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-900/20"}`} 207 title="Share" 208 > 209 <Share2 size={16} /> 210 </button> 211 212 {isOpen && ( 213 <div 214 ref={menuRef} 215 className="fixed z-[1000] w-[260px] bg-white dark:bg-surface-900 rounded-xl shadow-xl ring-1 ring-black/5 dark:ring-white/5 p-1.5 animate-in fade-in zoom-in-95 duration-150 origin-top-left" 216 style={{ 217 top: menuPosition.top, 218 left: menuPosition.left, 219 transformOrigin: menuPosition.alignRight ? "top right" : "top left", 220 }} 221 > 222 <div className="flex flex-col gap-0.5"> 223 {isSemble ? ( 224 <> 225 <div className="px-3 py-2 text-[11px] font-bold text-surface-400 dark:text-surface-500 uppercase tracking-wider flex items-center gap-1.5 select-none"> 226 <SembleLogo /> 227 Semble Integration 228 </div> 229 {renderMenuItem( 230 "Open on Semble", 231 <ExternalLink size={16} />, 232 () => window.open(sembleUrl, "_blank"), 233 false, 234 true, 235 )} 236 {renderMenuItem( 237 "Copy Semble Link", 238 <Copy size={16} />, 239 () => handleCopy(sembleUrl, "semble"), 240 copied === "semble", 241 )} 242 <div className="h-px bg-surface-100 dark:bg-surface-800 my-1 mx-2" /> 243 </> 244 ) : null} 245 246 {renderMenuItem( 247 "Copy Link", 248 <Copy size={16} />, 249 () => handleCopy(shareUrl, "link"), 250 copied === "link", 251 )} 252 253 <div className="px-3 pt-3 pb-1 text-[11px] font-bold text-surface-400 dark:text-surface-500 uppercase tracking-wider select-none"> 254 Share via App 255 </div> 256 257 <div className="grid grid-cols-5 gap-1 px-1 mb-1"> 258 {shareForks.map((fork) => ( 259 <button 260 key={fork.domain} 261 onClick={() => handleShareToFork(fork.domain)} 262 className="flex items-center justify-center p-2 rounded-lg hover:bg-surface-50 dark:hover:bg-surface-800 hover:scale-105 transition-all text-surface-400 dark:text-surface-500 hover:text-surface-900 dark:hover:text-white" 263 title={`Share to ${fork.name}`} 264 > 265 {fork.icon} 266 </button> 267 ))} 268 </div> 269 270 <div className="h-px bg-surface-100 dark:bg-surface-800 my-1 mx-2" /> 271 272 {renderMenuItem( 273 "Copy Universal Link", 274 <AturiIcon size={16} />, 275 () => 276 handleCopy(uri.replace("at://", "https://aturi.to/"), "aturi"), 277 copied === "aturi", 278 )} 279 280 {navigator.share && 281 renderMenuItem( 282 "More Options...", 283 <MoreHorizontal size={16} />, 284 () => { 285 navigator 286 .share({ title: "Margin", text, url: shareUrl }) 287 .catch(() => {}); 288 setIsOpen(false); 289 }, 290 )} 291 </div> 292 </div> 293 )} 294 </div> 295 ); 296}