(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
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}