(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
98
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 411 lines 13 kB view raw
1import React, { useState, useRef, useEffect, useCallback } from "react"; 2import { 3 Copy, 4 ExternalLink, 5 Check, 6 Share2, 7 MoreHorizontal, 8 X, 9} from "lucide-react"; 10import { 11 AturiIcon, 12 BlueskyIcon, 13 BlackskyIcon, 14 WitchskyIcon, 15 CatskyIcon, 16 DeerIcon, 17} from "../common/Icons"; 18import { analytics } from "../../lib/analytics"; 19import { useTranslation } from "react-i18next"; 20 21const SembleLogo = () => ( 22 <img src="/semble-logo.svg" alt="Semble" className="w-4 h-4 opacity-90" /> 23); 24 25const BLUESKY_COLOR = "#1185fe"; 26 27interface ShareMenuProps { 28 uri: string; 29 text?: string; 30 customUrl?: string; 31 handle?: string; 32 type?: string; 33 url?: string; 34} 35 36export default function ShareMenu({ 37 uri, 38 text, 39 customUrl, 40 handle, 41 type, 42 url, 43}: ShareMenuProps) { 44 const { t } = useTranslation(); 45 const [isOpen, setIsOpen] = useState(false); 46 const [copied, setCopied] = useState<string | null>(null); 47 const [isMobile, setIsMobile] = useState(false); 48 const menuRef = useRef<HTMLDivElement>(null); 49 const buttonRef = useRef<HTMLButtonElement>(null); 50 const sheetRef = useRef<HTMLDivElement>(null); 51 const dragStartY = useRef(0); 52 const dragCurrentY = useRef(0); 53 54 const handleTouchStart = useCallback((e: React.TouchEvent) => { 55 dragStartY.current = e.touches[0].clientY; 56 if (sheetRef.current) sheetRef.current.style.transition = "none"; 57 }, []); 58 59 const handleTouchMove = useCallback((e: React.TouchEvent) => { 60 const delta = e.touches[0].clientY - dragStartY.current; 61 dragCurrentY.current = delta; 62 if (delta > 0 && sheetRef.current) { 63 sheetRef.current.style.transform = `translateY(${delta}px)`; 64 } 65 }, []); 66 67 const handleTouchEnd = useCallback(() => { 68 if (sheetRef.current) { 69 sheetRef.current.style.transition = "transform 0.3s ease"; 70 if (dragCurrentY.current > 100) { 71 sheetRef.current.style.transform = "translateY(100%)"; 72 setTimeout(() => setIsOpen(false), 300); 73 } else { 74 sheetRef.current.style.transform = "translateY(0)"; 75 } 76 } 77 dragCurrentY.current = 0; 78 }, []); 79 const [menuPosition, setMenuPosition] = useState({ 80 top: 0, 81 left: 0, 82 alignRight: false, 83 }); 84 85 useEffect(() => { 86 const check = () => setIsMobile(window.innerWidth < 640); 87 check(); 88 window.addEventListener("resize", check); 89 return () => window.removeEventListener("resize", check); 90 }, []); 91 92 const getShareUrl = () => { 93 if (customUrl) return customUrl; 94 if (!uri) return ""; 95 96 const origin = typeof window !== "undefined" ? window.location.origin : ""; 97 const uriParts = uri.split("/"); 98 const rkey = uriParts[uriParts.length - 1]; 99 const did = uriParts[2]; 100 const collection = uriParts[3] ?? ""; 101 102 const marginSegment = collection.startsWith("at.margin.note") 103 ? "note" 104 : collection.startsWith("at.margin.highlight") 105 ? "highlight" 106 : collection.startsWith("at.margin.bookmark") 107 ? "bookmark" 108 : collection.startsWith("at.margin.annotation") 109 ? "annotation" 110 : null; 111 112 if (marginSegment && handle) { 113 return `${origin}/${handle}/${marginSegment}/${rkey}`; 114 } 115 116 if (did && collection && rkey) { 117 return `${origin}/at/${did}/${collection}/${rkey}`; 118 } 119 120 return `${origin}/at/${did}/${rkey}`; 121 }; 122 123 const shareUrl = getShareUrl(); 124 const isSemble = uri && uri.includes("network.cosmik"); 125 126 const sembleUrl = (() => { 127 if (!isSemble) return ""; 128 const parts = (uri || "").split("/"); 129 const rkey = parts[parts.length - 1]; 130 const userHandle = handle || (parts.length > 2 ? parts[2] : ""); 131 132 if (uri.includes("network.cosmik.collection")) 133 return `https://semble.so/profile/${userHandle}/collections/${rkey}`; 134 if (uri.includes("network.cosmik.card") && url) 135 return `https://semble.so/url?id=${encodeURIComponent(url)}`; 136 return `https://semble.so/profile/${userHandle}`; 137 })(); 138 139 const handleCopy = async (textToCopy: string, key: string) => { 140 try { 141 await navigator.clipboard.writeText(textToCopy); 142 setCopied(key); 143 analytics.capture("item_shared", { 144 method: "copy_link", 145 destination: key, 146 item_type: type, 147 }); 148 setTimeout(() => { 149 setCopied(null); 150 setIsOpen(false); 151 }, 1000); 152 } catch { 153 prompt("Copy this link:", textToCopy); 154 } 155 }; 156 157 const handleShareToFork = (domain: string) => { 158 const composeText = text 159 ? `${text.substring(0, 200)}...\n\n${shareUrl}` 160 : shareUrl; 161 const composeUrl = `https://${domain}/intent/compose?text=${encodeURIComponent(composeText)}`; 162 analytics.capture("item_shared", { 163 method: "social_app", 164 destination: domain, 165 item_type: type, 166 }); 167 window.open(composeUrl, "_blank"); 168 setIsOpen(false); 169 }; 170 171 useEffect(() => { 172 const handleClickOutside = (e: MouseEvent) => { 173 if ( 174 menuRef.current && 175 !menuRef.current.contains(e.target as Node) && 176 !buttonRef.current?.contains(e.target as Node) 177 ) { 178 setIsOpen(false); 179 } 180 }; 181 if (isOpen && !isMobile) { 182 document.addEventListener("mousedown", handleClickOutside); 183 window.addEventListener("scroll", () => setIsOpen(false), true); 184 window.addEventListener("resize", () => setIsOpen(false)); 185 } 186 return () => { 187 document.removeEventListener("mousedown", handleClickOutside); 188 window.removeEventListener("scroll", () => setIsOpen(false), true); 189 window.removeEventListener("resize", () => setIsOpen(false)); 190 }; 191 }, [isOpen, isMobile]); 192 193 const calculatePosition = () => { 194 if (!buttonRef.current) return; 195 const rect = buttonRef.current.getBoundingClientRect(); 196 const menuWidth = 260; 197 const padding = 8; 198 199 let top = rect.bottom + 8; 200 let left = rect.left; 201 let alignRight = false; 202 203 if (left + menuWidth > window.innerWidth - padding) { 204 left = rect.right - menuWidth; 205 alignRight = true; 206 } 207 208 left = Math.max( 209 padding, 210 Math.min(left, window.innerWidth - menuWidth - padding), 211 ); 212 213 if (top + 300 > window.innerHeight) { 214 top = rect.top - 8; 215 } 216 217 setMenuPosition({ top, left, alignRight }); 218 }; 219 220 const toggleMenu = () => { 221 if (!isOpen && !isMobile) calculatePosition(); 222 setIsOpen(!isOpen); 223 }; 224 225 const renderMenuItem = ( 226 label: string, 227 icon: React.ReactNode, 228 onClick: () => void, 229 isCopied: boolean = false, 230 highlight: boolean = false, 231 ) => ( 232 <button 233 onClick={onClick} 234 className={`w-full flex items-center gap-3 px-3 py-2.5 text-[14px] font-medium transition-colors rounded-lg group 235 ${ 236 highlight 237 ? "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" 238 : "text-surface-700 dark:text-surface-200 hover:bg-surface-50 dark:hover:bg-surface-800 hover:text-surface-900 dark:hover:text-white" 239 }`} 240 > 241 <span 242 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"}`} 243 > 244 {isCopied ? ( 245 <Check size={16} className="text-green-600 dark:text-green-400" /> 246 ) : ( 247 icon 248 )} 249 </span> 250 <span className="flex-1 text-left"> 251 {isCopied ? t("shareMenu.copied") : label} 252 </span> 253 </button> 254 ); 255 256 const shareForks = [ 257 { 258 name: "Bluesky", 259 domain: "bsky.app", 260 icon: <BlueskyIcon size={18} color={BLUESKY_COLOR} />, 261 }, 262 { 263 name: "Witchsky", 264 domain: "witchsky.app", 265 icon: <WitchskyIcon size={18} />, 266 }, 267 { 268 name: "Blacksky", 269 domain: "blacksky.community", 270 icon: <BlackskyIcon size={18} />, 271 }, 272 { name: "Catsky", domain: "catsky.social", icon: <CatskyIcon size={18} /> }, 273 { name: "Deer", domain: "deer.social", icon: <DeerIcon size={18} /> }, 274 ]; 275 276 const menuContent = ( 277 <div className="flex flex-col gap-0.5"> 278 {isSemble ? ( 279 <> 280 <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"> 281 <SembleLogo /> 282 {t("shareMenu.sembleIntegration")} 283 </div> 284 {renderMenuItem( 285 t("shareMenu.openOnSemble"), 286 <ExternalLink size={16} />, 287 () => window.open(sembleUrl, "_blank"), 288 false, 289 true, 290 )} 291 {renderMenuItem( 292 t("shareMenu.copySembleLink"), 293 <Copy size={16} />, 294 () => handleCopy(sembleUrl, "semble"), 295 copied === "semble", 296 )} 297 <div className="h-px bg-surface-100 dark:bg-surface-800 my-1 mx-2" /> 298 </> 299 ) : null} 300 301 {renderMenuItem( 302 t("shareMenu.copyLink"), 303 <Copy size={16} />, 304 () => handleCopy(shareUrl, "link"), 305 copied === "link", 306 )} 307 308 <div className="px-3 pt-3 pb-1 text-[11px] font-bold text-surface-400 dark:text-surface-500 uppercase tracking-wider select-none"> 309 {t("shareMenu.shareViaApp")} 310 </div> 311 312 <div className="grid grid-cols-5 gap-1 px-1 mb-1"> 313 {shareForks.map((fork) => ( 314 <button 315 key={fork.domain} 316 onClick={() => handleShareToFork(fork.domain)} 317 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" 318 title={`Share to ${fork.name}`} 319 > 320 {fork.icon} 321 </button> 322 ))} 323 </div> 324 325 <div className="h-px bg-surface-100 dark:bg-surface-800 my-1 mx-2" /> 326 327 {renderMenuItem( 328 t("shareMenu.copyUniversalLink"), 329 <AturiIcon size={16} />, 330 () => handleCopy(uri.replace("at://", "https://aturi.to/"), "aturi"), 331 copied === "aturi", 332 )} 333 334 {typeof navigator !== "undefined" && 335 navigator.share && 336 renderMenuItem( 337 t("shareMenu.moreOptions"), 338 <MoreHorizontal size={16} />, 339 () => { 340 navigator 341 .share({ title: "Margin", text, url: shareUrl }) 342 .catch(() => {}); 343 setIsOpen(false); 344 }, 345 )} 346 </div> 347 ); 348 349 return ( 350 <div className="relative inline-block"> 351 <button 352 ref={buttonRef} 353 onClick={toggleMenu} 354 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"}`} 355 title="Share" 356 > 357 <Share2 size={16} /> 358 </button> 359 360 {isOpen && isMobile && ( 361 <> 362 <div 363 className="fixed inset-0 bg-black/40 z-[999]" 364 onClick={() => setIsOpen(false)} 365 /> 366 <div className="fixed bottom-0 left-0 right-0 z-[1000] animate-slide-up"> 367 <div 368 ref={sheetRef} 369 className="mx-2 mb-2 bg-white dark:bg-surface-900 rounded-2xl shadow-xl border border-surface-200 dark:border-surface-700 overflow-hidden" 370 style={{ paddingBottom: "env(safe-area-inset-bottom)" }} 371 > 372 <div 373 className="flex justify-center pt-3 pb-1 cursor-grab active:cursor-grabbing touch-none" 374 onTouchStart={handleTouchStart} 375 onTouchMove={handleTouchMove} 376 onTouchEnd={handleTouchEnd} 377 > 378 <div className="w-8 h-1 bg-surface-200 dark:bg-surface-700 rounded-full" /> 379 </div> 380 <div className="flex items-center justify-between px-4 pt-1 pb-2"> 381 <span className="text-sm font-semibold text-surface-900 dark:text-white"> 382 {t("shareMenu.share", { defaultValue: "Share" })} 383 </span> 384 <button 385 onClick={() => setIsOpen(false)} 386 className="p-1 rounded-lg text-surface-400 hover:text-surface-600 dark:hover:text-surface-200 hover:bg-surface-100 dark:hover:bg-surface-700 transition-colors" 387 > 388 <X size={16} /> 389 </button> 390 </div> 391 <div className="px-2 pb-2">{menuContent}</div> 392 </div> 393 </div> 394 </> 395 )} 396 {isOpen && !isMobile && ( 397 <div 398 ref={menuRef} 399 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" 400 style={{ 401 top: menuPosition.top, 402 left: menuPosition.left, 403 transformOrigin: menuPosition.alignRight ? "top right" : "top left", 404 }} 405 > 406 {menuContent} 407 </div> 408 )} 409 </div> 410 ); 411}