Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
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}