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

Configure Feed

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

at frontend-rewrite 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}