@brdgapp/ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +223 -0
- package/package.json +28 -0
- package/src/api.ts +173 -0
- package/src/contact-avatar.tsx +63 -0
- package/src/domain.ts +14 -0
- package/src/icons.tsx +130 -0
- package/src/index.ts +12 -0
- package/src/network-people-helper.tsx +397 -0
- package/src/network-people-nudge.tsx +98 -0
- package/src/open-helper.ts +22 -0
- package/src/person-menu.tsx +210 -0
- package/src/provider.tsx +53 -0
- package/src/styles.css +848 -0
- package/src/types.ts +30 -0
- package/src/use-network-people-list.ts +122 -0
- package/src/use-network-preview.ts +43 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useEffect, useLayoutEffect, useRef, useState } from "react"
|
|
4
|
+
import { createPortal } from "react-dom"
|
|
5
|
+
import { CheckIcon, CopyIcon, LinkIcon, MoreIcon, UserIcon, UserPlusIcon } from "./icons"
|
|
6
|
+
import type { BridgeClientConfig, NetworkContact, NetworkPersonAction } from "./types"
|
|
7
|
+
|
|
8
|
+
function introUrl(inboxUrl: string, email: string): string {
|
|
9
|
+
const url = new URL(inboxUrl)
|
|
10
|
+
url.searchParams.set("action", "make-intro")
|
|
11
|
+
url.searchParams.set("n1", email)
|
|
12
|
+
return url.toString()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function PersonMenu({
|
|
16
|
+
contact,
|
|
17
|
+
customActions,
|
|
18
|
+
config,
|
|
19
|
+
}: {
|
|
20
|
+
contact: NetworkContact
|
|
21
|
+
customActions: NetworkPersonAction[]
|
|
22
|
+
config: BridgeClientConfig
|
|
23
|
+
}) {
|
|
24
|
+
const [open, setOpen] = useState(false)
|
|
25
|
+
const [copied, setCopied] = useState(false)
|
|
26
|
+
const buttonRef = useRef<HTMLButtonElement>(null)
|
|
27
|
+
const menuRef = useRef<HTMLDivElement>(null)
|
|
28
|
+
const [position, setPosition] = useState<{ top: number; left: number } | null>(null)
|
|
29
|
+
const profileUrl = contact.username ? config.profileUrlForUsername(contact.username) : null
|
|
30
|
+
const makeIntro = contact.email ? introUrl(config.introInboxUrl, contact.email) : null
|
|
31
|
+
const hasActions = customActions.length > 0 || Boolean(makeIntro || contact.shareLink || profileUrl)
|
|
32
|
+
|
|
33
|
+
useLayoutEffect(() => {
|
|
34
|
+
if (!open || !buttonRef.current) return
|
|
35
|
+
function place() {
|
|
36
|
+
const rect = buttonRef.current?.getBoundingClientRect()
|
|
37
|
+
if (!rect) return
|
|
38
|
+
const width = 248
|
|
39
|
+
const left = Math.max(8, Math.min(rect.right - width, window.innerWidth - width - 8))
|
|
40
|
+
const estimatedHeight = menuRef.current?.offsetHeight ?? 180
|
|
41
|
+
const below = rect.bottom + 6
|
|
42
|
+
const top = below + estimatedHeight > window.innerHeight - 8 ? Math.max(8, rect.top - estimatedHeight - 6) : below
|
|
43
|
+
setPosition({ top, left })
|
|
44
|
+
}
|
|
45
|
+
place()
|
|
46
|
+
window.addEventListener("resize", place)
|
|
47
|
+
window.addEventListener("scroll", place, true)
|
|
48
|
+
return () => {
|
|
49
|
+
window.removeEventListener("resize", place)
|
|
50
|
+
window.removeEventListener("scroll", place, true)
|
|
51
|
+
}
|
|
52
|
+
}, [open])
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!open) return
|
|
56
|
+
function onPointer(event: MouseEvent) {
|
|
57
|
+
const target = event.target
|
|
58
|
+
if (!(target instanceof Node)) return
|
|
59
|
+
if (buttonRef.current?.contains(target) || menuRef.current?.contains(target)) return
|
|
60
|
+
setOpen(false)
|
|
61
|
+
setCopied(false)
|
|
62
|
+
}
|
|
63
|
+
function onKey(event: KeyboardEvent) {
|
|
64
|
+
if (event.key === "Escape") {
|
|
65
|
+
setOpen(false)
|
|
66
|
+
setCopied(false)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
document.addEventListener("mousedown", onPointer)
|
|
70
|
+
document.addEventListener("keydown", onKey)
|
|
71
|
+
return () => {
|
|
72
|
+
document.removeEventListener("mousedown", onPointer)
|
|
73
|
+
document.removeEventListener("keydown", onKey)
|
|
74
|
+
}
|
|
75
|
+
}, [open])
|
|
76
|
+
|
|
77
|
+
if (!hasActions) return null
|
|
78
|
+
|
|
79
|
+
async function copyIntroLink() {
|
|
80
|
+
if (!contact.shareLink) return
|
|
81
|
+
try {
|
|
82
|
+
await navigator.clipboard.writeText(contact.shareLink)
|
|
83
|
+
setCopied(true)
|
|
84
|
+
window.setTimeout(() => {
|
|
85
|
+
setCopied(false)
|
|
86
|
+
setOpen(false)
|
|
87
|
+
}, 1200)
|
|
88
|
+
} catch {
|
|
89
|
+
// Clipboard can fail in restricted contexts.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const menu = open ? (
|
|
94
|
+
<div
|
|
95
|
+
ref={menuRef}
|
|
96
|
+
className="bridge-network-helper__person-menu-content"
|
|
97
|
+
style={position ? { top: position.top, left: position.left } : { visibility: "hidden" }}
|
|
98
|
+
role="menu"
|
|
99
|
+
onClick={(event) => event.stopPropagation()}
|
|
100
|
+
>
|
|
101
|
+
{customActions.map((action) => (
|
|
102
|
+
<button
|
|
103
|
+
key={action.id}
|
|
104
|
+
type="button"
|
|
105
|
+
role="menuitem"
|
|
106
|
+
className={
|
|
107
|
+
action.description
|
|
108
|
+
? "bridge-network-helper__person-menu-item has-description"
|
|
109
|
+
: "bridge-network-helper__person-menu-item"
|
|
110
|
+
}
|
|
111
|
+
onClick={() => {
|
|
112
|
+
setOpen(false)
|
|
113
|
+
action.onSelect(contact)
|
|
114
|
+
}}
|
|
115
|
+
>
|
|
116
|
+
{action.icon ? <span className="bridge-network-helper__person-menu-icon">{action.icon}</span> : null}
|
|
117
|
+
<span>
|
|
118
|
+
<span className="bridge-network-helper__person-menu-label">{action.label}</span>
|
|
119
|
+
{action.description ? (
|
|
120
|
+
<span className="bridge-network-helper__person-menu-description">{action.description}</span>
|
|
121
|
+
) : null}
|
|
122
|
+
</span>
|
|
123
|
+
</button>
|
|
124
|
+
))}
|
|
125
|
+
{makeIntro ? (
|
|
126
|
+
<a className="bridge-network-helper__person-menu-item" role="menuitem" href={makeIntro} target="_blank" rel="noopener noreferrer">
|
|
127
|
+
<UserPlusIcon className="bridge-network-helper__person-menu-icon" />
|
|
128
|
+
Make intro
|
|
129
|
+
</a>
|
|
130
|
+
) : null}
|
|
131
|
+
{contact.shareLink ? (
|
|
132
|
+
<button
|
|
133
|
+
type="button"
|
|
134
|
+
role="menuitem"
|
|
135
|
+
className="bridge-network-helper__person-menu-item"
|
|
136
|
+
onClick={() => {
|
|
137
|
+
if (!copied) void copyIntroLink()
|
|
138
|
+
}}
|
|
139
|
+
>
|
|
140
|
+
{copied ? (
|
|
141
|
+
<CheckIcon className="bridge-network-helper__person-menu-icon is-copied" />
|
|
142
|
+
) : (
|
|
143
|
+
<LinkIcon className="bridge-network-helper__person-menu-icon" />
|
|
144
|
+
)}
|
|
145
|
+
{copied ? "Copied" : "Copy intro link"}
|
|
146
|
+
</button>
|
|
147
|
+
) : null}
|
|
148
|
+
{profileUrl ? (
|
|
149
|
+
<a className="bridge-network-helper__person-menu-item" role="menuitem" href={profileUrl} target="_blank" rel="noopener noreferrer">
|
|
150
|
+
<UserIcon className="bridge-network-helper__person-menu-icon" />
|
|
151
|
+
View Bridge profile
|
|
152
|
+
</a>
|
|
153
|
+
) : null}
|
|
154
|
+
</div>
|
|
155
|
+
) : null
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<>
|
|
159
|
+
<button
|
|
160
|
+
ref={buttonRef}
|
|
161
|
+
type="button"
|
|
162
|
+
className="bridge-network-helper__person-menu"
|
|
163
|
+
aria-label={`More options for ${contact.name}`}
|
|
164
|
+
aria-haspopup="menu"
|
|
165
|
+
aria-expanded={open}
|
|
166
|
+
data-state={open ? "open" : "closed"}
|
|
167
|
+
onClick={(event) => {
|
|
168
|
+
event.stopPropagation()
|
|
169
|
+
setOpen((current) => !current)
|
|
170
|
+
setCopied(false)
|
|
171
|
+
}}
|
|
172
|
+
>
|
|
173
|
+
<MoreIcon />
|
|
174
|
+
</button>
|
|
175
|
+
{menu && typeof document !== "undefined" ? createPortal(menu, document.body) : null}
|
|
176
|
+
</>
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function ShareLinkCopyControl({ shareLink }: { shareLink: string }) {
|
|
181
|
+
const [copied, setCopied] = useState(false)
|
|
182
|
+
const display = shareLink.replace(/^https?:\/\//i, "")
|
|
183
|
+
|
|
184
|
+
async function handleCopy() {
|
|
185
|
+
try {
|
|
186
|
+
await navigator.clipboard.writeText(shareLink)
|
|
187
|
+
setCopied(true)
|
|
188
|
+
window.setTimeout(() => setCopied(false), 1600)
|
|
189
|
+
} catch {
|
|
190
|
+
// Clipboard can fail in restricted contexts.
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<button
|
|
196
|
+
type="button"
|
|
197
|
+
className="bridge-network-helper__share-link"
|
|
198
|
+
onClick={handleCopy}
|
|
199
|
+
title={copied ? "Copied" : "Copy Introlink"}
|
|
200
|
+
aria-label={copied ? "Introlink copied" : `Copy Introlink ${display}`}
|
|
201
|
+
>
|
|
202
|
+
<span className="bridge-network-helper__share-link-text">{display}</span>
|
|
203
|
+
{copied ? (
|
|
204
|
+
<CheckIcon className="bridge-network-helper__share-link-icon is-copied" />
|
|
205
|
+
) : (
|
|
206
|
+
<CopyIcon className="bridge-network-helper__share-link-icon" />
|
|
207
|
+
)}
|
|
208
|
+
</button>
|
|
209
|
+
)
|
|
210
|
+
}
|
package/src/provider.tsx
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useMemo, useRef, type ReactNode } from "react"
|
|
4
|
+
import type { BridgeClientConfig } from "./types"
|
|
5
|
+
|
|
6
|
+
/** Production Bridge API, including the `/api/v4` prefix. */
|
|
7
|
+
export const DEFAULT_API_BASE_URL = "https://api.brdg.app/api/v4"
|
|
8
|
+
|
|
9
|
+
const DEFAULT_INTRO_INBOX_URL = "https://intros.brdg.app/inbox"
|
|
10
|
+
|
|
11
|
+
function defaultProfileUrl(username: string): string {
|
|
12
|
+
return `https://os.brdg.app/to/${encodeURIComponent(username.trim())}`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const BridgeUIContext = createContext<BridgeClientConfig | null>(null)
|
|
16
|
+
|
|
17
|
+
export type BridgeUIProviderProps = {
|
|
18
|
+
/** Override the production API (`https://api.brdg.app/api/v4`) for staging or local. */
|
|
19
|
+
apiBaseUrl?: string
|
|
20
|
+
getAccessToken: () => Promise<string | null> | string | null
|
|
21
|
+
/** Inbox used by the built-in Make intro action. */
|
|
22
|
+
introInboxUrl?: string
|
|
23
|
+
/** Profile URL used by the built-in View Bridge profile action. */
|
|
24
|
+
profileUrlForUsername?: (username: string) => string
|
|
25
|
+
children: ReactNode
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function BridgeUIProvider({
|
|
29
|
+
apiBaseUrl = DEFAULT_API_BASE_URL,
|
|
30
|
+
getAccessToken,
|
|
31
|
+
introInboxUrl = DEFAULT_INTRO_INBOX_URL,
|
|
32
|
+
profileUrlForUsername = defaultProfileUrl,
|
|
33
|
+
children,
|
|
34
|
+
}: BridgeUIProviderProps) {
|
|
35
|
+
const getAccessTokenRef = useRef(getAccessToken)
|
|
36
|
+
getAccessTokenRef.current = getAccessToken
|
|
37
|
+
|
|
38
|
+
const value = useMemo<BridgeClientConfig>(
|
|
39
|
+
() => ({
|
|
40
|
+
apiBaseUrl: apiBaseUrl.replace(/\/$/, ""),
|
|
41
|
+
getAccessToken: () => getAccessTokenRef.current(),
|
|
42
|
+
introInboxUrl,
|
|
43
|
+
profileUrlForUsername,
|
|
44
|
+
}),
|
|
45
|
+
[apiBaseUrl, introInboxUrl, profileUrlForUsername],
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
return <BridgeUIContext.Provider value={value}>{children}</BridgeUIContext.Provider>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function useBridgeUI(): BridgeClientConfig | null {
|
|
52
|
+
return useContext(BridgeUIContext)
|
|
53
|
+
}
|