@flung.dev/connect 0.1.1 → 0.3.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 +222 -12
- package/dist/core/client.d.ts +9 -2
- package/dist/core/client.js +29 -10
- package/dist/core/controller.d.ts +214 -0
- package/dist/core/controller.js +407 -0
- package/dist/core/events.d.ts +25 -0
- package/dist/core/events.js +1 -0
- package/dist/core/index.d.ts +10 -4
- package/dist/core/index.js +6 -3
- package/dist/core/labels.d.ts +78 -0
- package/dist/core/labels.js +41 -0
- package/dist/core/platforms.d.ts +7 -1
- package/dist/core/platforms.js +11 -1
- package/dist/core/types.d.ts +20 -0
- package/dist/core/types.js +1 -0
- package/dist/element/copy.d.ts +3 -33
- package/dist/element/copy.js +2 -29
- package/dist/element/dom.d.ts +25 -5
- package/dist/element/dom.js +49 -5
- package/dist/element/events.d.ts +14 -18
- package/dist/element/flung-connect.d.ts +21 -1
- package/dist/element/flung-connect.js +221 -190
- package/dist/element/marks.d.ts +17 -0
- package/dist/element/marks.js +35 -0
- package/dist/element/styles.d.ts +6 -4
- package/dist/element/styles.js +195 -52
- package/dist/element/tooltip.d.ts +26 -0
- package/dist/element/tooltip.js +135 -0
- package/dist/index.d.ts +2 -4
- package/dist/index.js +0 -1
- package/dist/react/index.d.ts +53 -3
- package/dist/react/index.js +40 -2
- package/dist/react/use-flung-connect.d.ts +13 -0
- package/dist/react/use-flung-connect.js +65 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -42,7 +42,7 @@ app.post('/connect-token', async (_request, response) => {
|
|
|
42
42
|
const panel = document.querySelector('flung-connect')
|
|
43
43
|
// A function: the panel asks again when a token expires.
|
|
44
44
|
panel.token = async () => (await (await fetch('/connect-token', { method: 'POST' })).json()).token
|
|
45
|
-
panel.addEventListener('flung-connect', (event) =>
|
|
45
|
+
panel.addEventListener('flung-connect', (event) => showConnected(event.detail.accounts))
|
|
46
46
|
</script>
|
|
47
47
|
|
|
48
48
|
<flung-connect platforms="x,linkedin,bluesky,mastodon"></flung-connect>
|
|
@@ -54,7 +54,7 @@ app.post('/connect-token', async (_request, response) => {
|
|
|
54
54
|
import { FlungConnect } from '@flung.dev/connect/react'
|
|
55
55
|
|
|
56
56
|
export function Accounts() {
|
|
57
|
-
return <FlungConnect token={fetchConnectToken} platforms={['x', 'linkedin']} onConnect={({ accounts }) =>
|
|
57
|
+
return <FlungConnect token={fetchConnectToken} platforms={['x', 'linkedin']} onConnect={({ accounts }) => showConnected(accounts)} />
|
|
58
58
|
}
|
|
59
59
|
```
|
|
60
60
|
|
|
@@ -69,7 +69,14 @@ export function Accounts() {
|
|
|
69
69
|
| `platforms` | | Offer only these, comma-separated. A token made with `platforms` is narrowed to those as well. |
|
|
70
70
|
| `mode` | | `popup` (the default) opens a small window. `redirect` sends the whole page to the platform and back, for pages that cannot open windows; Flung returns to the same address, and the panel reports the result and tidies the address. |
|
|
71
71
|
| `theme` | | `light` or `dark`. Follows the page's scheme when left out. |
|
|
72
|
+
| `layout` | | `list` (the default), `grid` or `compact`. See [Make it yours](#make-it-yours). |
|
|
73
|
+
| `variants` | | How LinkedIn offers company pages, when your Flung has them set up: `choose` (the default) asks after **Connect**; `tiles` shows **LinkedIn** and **LinkedIn Page** as two platforms. See [LinkedIn company pages](#linkedin-company-pages). |
|
|
74
|
+
| `unstyled` | | Drops Flung's stylesheet, so every part is yours to style. |
|
|
75
|
+
| `branding` | | `none` hides the small "Secured by Flung" line under the list. |
|
|
72
76
|
| | `labels` | Every word the panel shows, to put it in another language. See `DEFAULT_LABELS`. |
|
|
77
|
+
| | `state` | The panel's current state (`ConnectState`), read only. |
|
|
78
|
+
| | `controller` | The headless controller behind the panel (`ConnectController`), for rows a page draws itself. A new token makes a new one. |
|
|
79
|
+
| | `connect(platform, options)` | Starts a connect as the platform's button does; `{ variant: 'pages' }` connects LinkedIn company pages without asking, `{ variant: null }` the person's profile. Call it from a click. |
|
|
73
80
|
| | `refresh()` | Reads the accounts again. |
|
|
74
81
|
|
|
75
82
|
## Events
|
|
@@ -80,30 +87,233 @@ export function Accounts() {
|
|
|
80
87
|
| `flung-connect` | `{ session, accounts }` | A connect or reconnect finished. Facebook stores one account per page. `session` is null after a redirect. |
|
|
81
88
|
| `flung-disconnect` | `{ account }` | An account was disconnected. Its past posts keep their results. |
|
|
82
89
|
| `flung-error` | `{ code, message }` | A call or a connect failed: `access_denied`, `wrong_account`, `forbidden`, `network_error` and so on. |
|
|
90
|
+
| `flung-change` | `{ state }` | Anything the panel shows changed. Does not bubble. |
|
|
83
91
|
|
|
84
|
-
|
|
92
|
+
The first four bubble and cross the shadow root.
|
|
85
93
|
|
|
86
|
-
##
|
|
94
|
+
## LinkedIn company pages
|
|
87
95
|
|
|
88
|
-
|
|
96
|
+
LinkedIn connects a person's own profile and the company pages they administer through two separate apps. When your Flung has the pages app set up, `GET /v1/platforms` lists `variants: ["pages"]` for LinkedIn and the panel offers both. A person does not need to connect their profile first.
|
|
97
|
+
|
|
98
|
+
| `variants` | What the person sees |
|
|
99
|
+
|---|---|
|
|
100
|
+
| `choose` (the default) | One LinkedIn row. **Connect** opens a small question under it: **Personal profile** (post as yourself) or **Company page** (post as a page you admin). Arrow keys move between the two, Escape or **Cancel** closes it, and focus goes back to **Connect**. |
|
|
101
|
+
| `tiles` | Two rows, **LinkedIn** and **LinkedIn Page**, each with its own Connect and its own accounts. |
|
|
102
|
+
|
|
103
|
+
A pages connect stores one account per page the person administers. Each shows under LinkedIn with the page's name and a small **Page** tag (`AccountItem.page` is true). Without the pages app, LinkedIn works as before; with only the pages app, the LinkedIn row connects pages straight away and is named **LinkedIn Page**.
|
|
104
|
+
|
|
105
|
+
```html
|
|
106
|
+
<flung-connect variants="tiles"></flung-connect>
|
|
107
|
+
<button id="pages">Connect your company page</button>
|
|
108
|
+
<script type="module">
|
|
109
|
+
// From your own button: a click opens the sign-in window.
|
|
110
|
+
document.getElementById('pages').onclick = () => document.querySelector('flung-connect').connect('linkedin', { variant: 'pages' })
|
|
111
|
+
</script>
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
In React, `<FlungConnect variants="tiles" />`; with the hook or the controller, `connect('linkedin', { variant: 'pages' })`. The chooser's words are labels like the rest: `chooseVariant`, `variantPersonal`, `variantPersonalDescription`, `variantPages`, `variantPagesDescription`, `pagesName` and `pageTag`.
|
|
115
|
+
|
|
116
|
+
## Make it yours
|
|
117
|
+
|
|
118
|
+
Six levels of control, from least to most. Each keeps the same behaviour: the popup and its blocked-window link, the Mastodon and Bluesky questions, the wait with Cancel, the confirm before a disconnect, redirects and errors.
|
|
119
|
+
|
|
120
|
+
### 1. Custom properties
|
|
121
|
+
|
|
122
|
+
Set any of these on the element. Each has a default for light and dark.
|
|
123
|
+
|
|
124
|
+
| Property | Default | What |
|
|
125
|
+
|---|---|---|
|
|
126
|
+
| `--flung-font` | `system-ui, ...` | The typeface. |
|
|
127
|
+
| `--flung-font-size` | `15px` | Body text; most other sizes are worked out from it. |
|
|
128
|
+
| `--flung-font-size-small` | `13px` | Counts, handles, labels and the footer. |
|
|
129
|
+
| `--flung-line-height` | `1.45` | Body line height. |
|
|
130
|
+
| `--flung-font-weight-medium` | `500` | Buttons and account names. |
|
|
131
|
+
| `--flung-font-weight-strong` | `600` | Platform names and initials. |
|
|
132
|
+
| `--flung-text` | `#18181b` | Text. |
|
|
133
|
+
| `--flung-muted` | `#71717a` | Secondary text. |
|
|
134
|
+
| `--flung-surface` | `#ffffff` | The panel's background. |
|
|
135
|
+
| `--flung-raised` | `#f4f4f5` | Questions, the message line, button hover. |
|
|
136
|
+
| `--flung-border` | `#e4e4e7` | Every border. |
|
|
137
|
+
| `--flung-accent` | `#18181b` | Primary buttons. |
|
|
138
|
+
| `--flung-accent-text` | `#ffffff` | Text on primary buttons. |
|
|
139
|
+
| `--flung-danger` | `#dc2626` | Errors, Disconnect on hover, the confirm button. |
|
|
140
|
+
| `--flung-warning` | `#b45309` | The reconnect status. |
|
|
141
|
+
| `--flung-success` | `#15803d` | The connected dot. |
|
|
142
|
+
| `--flung-radius` | `12px` | The panel's corners. |
|
|
143
|
+
| `--flung-radius-inner` | radius less 4px | Tiles, cards, questions and fields. |
|
|
144
|
+
| `--flung-button-radius` | `--flung-radius-inner` | Buttons. |
|
|
145
|
+
| `--flung-border-width` | `1px` | Every border. `0` for none. |
|
|
146
|
+
| `--flung-shadow` | `none` | The panel's shadow. |
|
|
147
|
+
| `--flung-card-shadow` | `none` | Each card in the `grid` layout. |
|
|
148
|
+
| `--flung-padding` | `16px` | Space at the panel's edges. |
|
|
149
|
+
| `--flung-gap` | `12px` | Space between pieces and rows. |
|
|
150
|
+
| `--flung-badge-size` | `40px` | The platform tile. |
|
|
151
|
+
| `--flung-mark-size` | `20px` | The platform mark inside it. |
|
|
152
|
+
| `--flung-mark-strength` | `100%` | How much of each platform's brand colour its mark keeps. |
|
|
153
|
+
| `--flung-tint-strength` | `8%` | How much of it the tile and the initial circle take. |
|
|
154
|
+
| `--flung-avatar-size` | `32px` | Account pictures. |
|
|
155
|
+
| `--flung-button-height` | `36px` | Every button and the handle field. |
|
|
156
|
+
| `--flung-button-padding` | `0 14px` | Inside buttons. |
|
|
157
|
+
| `--flung-focus-color` | `--flung-accent` | The focus ring. |
|
|
158
|
+
| `--flung-focus-width` | `2px` | Its width. |
|
|
159
|
+
| `--flung-focus-offset` | `2px` | Its gap from the control. |
|
|
160
|
+
| `--flung-grid-min` | `16rem` | The narrowest card in the `grid` layout. |
|
|
161
|
+
| `--flung-tooltip-background` | `--flung-text` | The tooltip that shows an account name cut short. |
|
|
162
|
+
| `--flung-tooltip-text` | `--flung-surface` | Its words. |
|
|
163
|
+
| `--flung-tooltip-radius` | `--flung-radius-inner` | Its corners. |
|
|
164
|
+
| `--flung-tooltip-shadow` | a soft shadow | Its shadow. |
|
|
165
|
+
|
|
166
|
+
```css
|
|
167
|
+
flung-connect {
|
|
168
|
+
--flung-font: "Inter", sans-serif;
|
|
169
|
+
--flung-accent: #0f766e;
|
|
170
|
+
--flung-radius: 6px;
|
|
171
|
+
--flung-shadow: 0 1px 2px rgb(0 0 0 / .06);
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### 2. Parts
|
|
176
|
+
|
|
177
|
+
Every piece the panel draws has a `part` name for `::part()`. Pieces with a kind carry a second name, so a page can style all of them or one kind.
|
|
178
|
+
|
|
179
|
+
| Part | What |
|
|
180
|
+
|---|---|
|
|
181
|
+
| `panel` | The whole panel. Has `data-layout`. |
|
|
182
|
+
| `list` | The list of platforms. |
|
|
183
|
+
| `platform`, `platform-<id>` | One platform, such as `platform-linkedin`. |
|
|
184
|
+
| `row` | Its tile, name and Connect button. |
|
|
185
|
+
| `badge`, `mark` | The tile and the mark inside it. |
|
|
186
|
+
| `title`, `name`, `count`, `count-connected` | The name and the line under it. |
|
|
187
|
+
| `accounts`, `account` | The platform's accounts, and one of them. |
|
|
188
|
+
| `avatar`, `avatar-image`, `avatar-initial` | The account's picture, or the circle with its initial when there is none or it does not load. |
|
|
189
|
+
| `account-details`, `account-names`, `account-name`, `account-username` | The display name and the `@handle` under it. |
|
|
190
|
+
| `status` | Needs reconnecting, or the date to reconnect by. |
|
|
191
|
+
| `actions` | The account's buttons. |
|
|
192
|
+
| `button` | Every button. Also `button-primary`, `button-secondary`, `button-quiet` or `button-danger`, and one of `button-connect`, `button-reconnect`, `button-disconnect`, `button-continue`, `button-cancel`, `button-confirm`, `button-keep`, `button-option`. |
|
|
193
|
+
| `platform-linkedin-pages` | With `variants="tiles"`, the LinkedIn Page row, besides `platform` and `platform-linkedin`. Its slot is `platform-linkedin-pages`. |
|
|
194
|
+
| `choose`, `choose-title`, `choose-options` | The question between a LinkedIn profile and a company page. |
|
|
195
|
+
| `button-option`, `button-option-personal`, `button-option-pages` | Its two choices. Each holds `option-icon` (with `option-mark`), `option-text`, `option-name` and `option-description`. |
|
|
196
|
+
| `tag`, `tag-page` | The **Page** tag beside a company page. |
|
|
197
|
+
| `form`, `label`, `input` | The Mastodon server or Bluesky handle question. |
|
|
198
|
+
| `note`, `spinner`, `note-text`, `link` | The wait, and the sign-in link when the browser blocked the window. |
|
|
199
|
+
| `confirm`, `confirm-text` | The question before a disconnect. |
|
|
200
|
+
| `message`, `message-error` | The status line under the list. It has `hidden` when empty. |
|
|
201
|
+
| `footer`, `footer-link`, `footer-mark` | "Secured by Flung". |
|
|
202
|
+
| `tooltip` | The full account name or `@handle`, when the panel is too narrow for it, on hover or a held finger. In the top layer where the browser has popovers. |
|
|
89
203
|
|
|
90
204
|
```css
|
|
91
|
-
flung-connect { --flung-accent: #0f766e; --flung-radius: 12px; }
|
|
92
205
|
flung-connect::part(panel) { border: 0; }
|
|
206
|
+
flung-connect::part(button-primary) { border-radius: 999px; }
|
|
207
|
+
flung-connect::part(platform-x) { background: #fafafa; }
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### 3. Layouts
|
|
211
|
+
|
|
212
|
+
`layout="list"` (the default) puts platforms one under another, `grid` makes them cards side by side, and `compact` tightens every size for a sidebar.
|
|
213
|
+
|
|
214
|
+
```html
|
|
215
|
+
<flung-connect layout="grid" style="--flung-grid-min: 14rem"></flung-connect>
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### 4. Unstyled
|
|
219
|
+
|
|
220
|
+
`unstyled` drops Flung's stylesheet. The element stays a block and keeps its `part` names, so the whole look is your CSS. Marks and avatars carry `width` and `height` attributes so they stay small until you size them.
|
|
221
|
+
|
|
222
|
+
```css
|
|
223
|
+
flung-connect::part(list) { display: flex; flex-direction: column; gap: 1rem; }
|
|
224
|
+
flung-connect::part(platform) { padding: 1rem; border: 1px solid var(--your-border); }
|
|
225
|
+
flung-connect::part(button) { font: inherit; }
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### 5. Render props
|
|
229
|
+
|
|
230
|
+
`<FlungConnect>` takes `renderPlatform` and `renderAccount`, to draw a platform's row (mark, name and Connect) or an account's row with your own components. They render in your page's DOM, through the element's named slots (`platform-<id>`, `account-<id>`), so your own CSS reaches them; the panel keeps the questions, the wait, the confirm and the messages around them.
|
|
231
|
+
|
|
232
|
+
```tsx
|
|
233
|
+
<FlungConnect
|
|
234
|
+
token={fetchConnectToken}
|
|
235
|
+
renderAccount={({ account, actions, labels }) => (
|
|
236
|
+
<UserChip name={account.displayName} picture={account.avatarUrl} handle={account.username}>
|
|
237
|
+
<Button variant="ghost" onClick={() => actions.requestDisconnect(account.account.id)}>{labels.disconnect}</Button>
|
|
238
|
+
</UserChip>
|
|
239
|
+
)}
|
|
240
|
+
/>
|
|
93
241
|
```
|
|
94
242
|
|
|
243
|
+
Without React, put an element with `slot="account-<id>"` inside `<flung-connect>` and call `panel.controller` from it; `flung-change` says when to draw again.
|
|
244
|
+
|
|
245
|
+
### 6. Headless: `useFlungConnect` and `createConnectController`
|
|
246
|
+
|
|
247
|
+
`useFlungConnect` (from `@flung.dev/connect/react`) gives the state and the actions and draws nothing: every element and class is yours. Call `connect`, `submitHandle` and `reconnect` straight from a click or submit handler, since a browser opens a window only during one.
|
|
248
|
+
|
|
249
|
+
```tsx
|
|
250
|
+
import { useFlungConnect } from '@flung.dev/connect/react'
|
|
251
|
+
|
|
252
|
+
export function Accounts() {
|
|
253
|
+
const panel = useFlungConnect({ token: fetchConnectToken, platforms: ['x', 'linkedin', 'mastodon'] })
|
|
254
|
+
return (
|
|
255
|
+
<ul className="accounts">
|
|
256
|
+
{panel.platforms.map((item) => (
|
|
257
|
+
<li key={item.platform}>
|
|
258
|
+
<h3>{item.name}</h3>
|
|
259
|
+
{item.accounts.map((account) => <p key={account.account.id}>{account.displayName}</p>)}
|
|
260
|
+
{item.step.kind === 'handle' && (
|
|
261
|
+
<form onSubmit={(event) => { event.preventDefault(); panel.submitHandle(item.platform, new FormData(event.currentTarget).get('handle') as string) }}>
|
|
262
|
+
<input name="handle" required={item.step.required} />
|
|
263
|
+
</form>
|
|
264
|
+
)}
|
|
265
|
+
{item.step.kind === 'waiting' && item.step.blockedUrl !== null && <a href={item.step.blockedUrl} target="_blank" rel="noopener">Open sign-in</a>}
|
|
266
|
+
{item.step.kind === 'confirm' && <button onClick={() => panel.confirmDisconnect(item.platform)}>Yes, disconnect</button>}
|
|
267
|
+
<button disabled={item.busy} onClick={() => panel.connect(item.platform)}>Connect</button>
|
|
268
|
+
</li>
|
|
269
|
+
))}
|
|
270
|
+
<p role="status">{panel.message.text}</p>
|
|
271
|
+
</ul>
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
`createConnectController` (from `@flung.dev/connect/core`) is the same with no framework: `getState()` and `subscribe()` fit `useSyncExternalStore`, Svelte stores, Vue's `shallowRef` and plain DOM alike. `<flung-connect>` and `useFlungConnect` are both drawn from it.
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
import { createConnectController } from '@flung.dev/connect/core'
|
|
280
|
+
|
|
281
|
+
const panel = createConnectController({ token: fetchConnectToken, onConnect: ({ accounts }) => showConnected(accounts) })
|
|
282
|
+
panel.subscribe(() => draw(panel.getState()))
|
|
283
|
+
// later: panel.destroy()
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
| Member | What |
|
|
287
|
+
|---|---|
|
|
288
|
+
| `getState()` | The current `ConnectState`, frozen; the same object until something changes. |
|
|
289
|
+
| `subscribe(listener)` | Calls `listener` after every change; returns the function that stops it. |
|
|
290
|
+
| `connect(platform, { variant })` | Starts a connect, or sets the step to `handle` for Mastodon and Bluesky, or to `choose` when LinkedIn offers pages and `variant` is left out. `variant: 'pages'` or `null` (the profile) connects without asking. |
|
|
291
|
+
| `submitHandle(platform, handle)` | Answers the `handle` step and starts the connect. |
|
|
292
|
+
| `reconnect(accountId)` | Reconnects an account. |
|
|
293
|
+
| `requestDisconnect(accountId)` | Sets the step to `confirm`. Nothing is sent. |
|
|
294
|
+
| `confirmDisconnect(platform)` | Disconnects the account the `confirm` step asks about. |
|
|
295
|
+
| `cancel(platform)` | Stops waiting, or closes the question. |
|
|
296
|
+
| `dismissNotice()` | Clears the last notice. |
|
|
297
|
+
| `refresh()` | Reads everything again. |
|
|
298
|
+
| `configure({ platforms, mode, labels })` | Changes those options while shown. |
|
|
299
|
+
| `destroy()` | Stops every wait and listener. |
|
|
300
|
+
|
|
301
|
+
`ConnectState` has `hasToken`, `loading`, `loaded`, `platforms` (each with `name`, `accounts`, `step`, `busy`, `handlePrompt`, `connectable` and `variants`), `accounts`, `notice` (the last thing that happened, as data), `message` (that notice, or the loading, empty or no-token line, in the labels' words) and `labels`. Each account item has `displayName`, `username` (the handle when it differs), `avatarUrl` (only ever `https`), `initial`, `page` (a LinkedIn company page) and `reconnect`. A step is `idle`, `handle` (with `required`), `choose` (with `variants`), `waiting` (with `blockedUrl` and `variant`) or `confirm` (with `account`).
|
|
302
|
+
|
|
95
303
|
## Behaviour worth knowing
|
|
96
304
|
|
|
97
305
|
- **Popups.** Browsers open a window only straight from a click, so the panel opens it first and fills it in once Flung answers. When a browser blocks it anyway, the panel shows a link to the consent page instead.
|
|
98
306
|
- **Waiting.** The panel waits on the connect session, not the window: some platforms' sign-in pages cut the link back to the page that opened them. Sessions last ten minutes. **Cancel** stops waiting.
|
|
99
307
|
- **Mastodon and Bluesky** ask first: the person's Mastodon server (required), or their Bluesky handle (optional; without it they sign in through bsky.social).
|
|
308
|
+
- **LinkedIn** asks whether to connect a personal profile or a company page when your Flung offers pages. A reconnect of a page goes through the pages app by itself.
|
|
100
309
|
- **Reconnect** appears beside an account that needs it, or whose platform only lets Flung renew it for a limited time and that time ends within two weeks.
|
|
101
310
|
- **Disconnect** asks to confirm. The account then drops from the panel; it stays in the API as `disconnected`.
|
|
102
|
-
- **
|
|
311
|
+
- **Accounts** show their picture, display name and `@handle`. A picture is loaded only from an `https` address, sends no referrer, and falls back to the account's initial when it is missing or does not load.
|
|
312
|
+
- **Safety.** Every name and message is set as text; nothing the API returns becomes markup. A sign-in link is only ever an `http` or `https` address. The panel sends the token to the API address only, and no cookie. The headless controller and hook keep the same rules; what your own markup does with the state is yours.
|
|
103
313
|
|
|
104
314
|
## Without the element
|
|
105
315
|
|
|
106
|
-
`@flung.dev/connect/core` has the calls (`ConnectClient`) and the flows (`connectInPopup`, `readRedirectResult`)
|
|
316
|
+
`@flung.dev/connect/core` has the headless controller (above), and underneath it the calls (`ConnectClient`) and the flows (`connectInPopup`, `readRedirectResult`).
|
|
107
317
|
|
|
108
318
|
```ts
|
|
109
319
|
import { ConnectClient, connectInPopup } from '@flung.dev/connect/core'
|
|
@@ -119,8 +329,8 @@ button.onclick = async () => {
|
|
|
119
329
|
|
|
120
330
|
| Path | Purpose |
|
|
121
331
|
|---|---|
|
|
122
|
-
| `src/core/` | `ConnectClient`, the popup and redirect flows, platform names, and the API shapes it reads. No DOM needed except `window.open` in the popup flow. |
|
|
123
|
-
| `src/element/` | `<flung-connect>`: the element, its styles,
|
|
124
|
-
| `src/react/` | `<FlungConnect>`, the element for React. |
|
|
332
|
+
| `src/core/` | `createConnectController` (the panel's behaviour with no markup), `ConnectClient`, the popup and redirect flows, the panel's words, platform names, and the API shapes it reads. No DOM needed except `window.open` in the popup flow. |
|
|
333
|
+
| `src/element/` | `<flung-connect>`: the element drawn from the controller, its styles, events and node helpers. `marks.ts` is the platforms' marks (Simple Icons; Boxicons for LinkedIn), the profile-or-page choice's icons (Boxicons) and Flung's, written by `pnpm sync:icons`. |
|
|
334
|
+
| `src/react/` | `<FlungConnect>`, the element for React, and `useFlungConnect`, the headless hook. |
|
|
125
335
|
| `src/register.ts` | The side-effect entry that registers the element. |
|
|
126
|
-
| `src/__tests__/` | The client, flows, element and React component against a fake of the seven calls (`fake-api.ts`), in jsdom. |
|
|
336
|
+
| `src/__tests__/` | The client, flows, controller, element and React component and hook against a fake of the seven calls (`fake-api.ts`), in jsdom. |
|
package/dist/core/client.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Account, ConnectSession, Platform, PlatformInfo } from './types.js';
|
|
1
|
+
import type { Account, ConnectSession, ConnectVariant, Platform, PlatformInfo } from './types.js';
|
|
2
2
|
export declare const DEFAULT_BASE_URL = "https://api.flung.dev";
|
|
3
3
|
/** A connect token (`flung_ct_...`), or a function that fetches a fresh one from your server. */
|
|
4
4
|
export type TokenSource = string | (() => Promise<string>);
|
|
@@ -24,12 +24,18 @@ export declare class FlungConnectError extends Error {
|
|
|
24
24
|
export declare class ConnectClient {
|
|
25
25
|
#private;
|
|
26
26
|
constructor(options: ConnectClientOptions);
|
|
27
|
+
/** Every platform, and whether this Flung can connect it. */
|
|
27
28
|
platforms(): Promise<PlatformInfo[]>;
|
|
29
|
+
/** The connected accounts the token may see: its profile's, or the whole workspace's. */
|
|
28
30
|
accounts(): Promise<Account[]>;
|
|
29
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* Starts connecting a new account. `handle` is the Bluesky handle, or the Mastodon instance (required there). `variant`
|
|
33
|
+
* picks another way to connect the platform: `pages` on LinkedIn connects the company pages the person administers.
|
|
34
|
+
*/
|
|
30
35
|
connect(platform: Platform, options?: {
|
|
31
36
|
handle?: string | undefined;
|
|
32
37
|
redirectUrl?: string | undefined;
|
|
38
|
+
variant?: ConnectVariant | undefined;
|
|
33
39
|
}): Promise<ConnectSession>;
|
|
34
40
|
/** Starts reconnecting an account that needs it; the person must sign in as the same platform user. */
|
|
35
41
|
reconnect(accountId: string, options?: {
|
|
@@ -37,6 +43,7 @@ export declare class ConnectClient {
|
|
|
37
43
|
}): Promise<ConnectSession>;
|
|
38
44
|
/** Deletes the account's stored tokens. It stays listed as `disconnected`. */
|
|
39
45
|
disconnect(accountId: string): Promise<Account>;
|
|
46
|
+
/** A connect session's state: pending, connected, failed or expired. */
|
|
40
47
|
session(id: string): Promise<ConnectSession>;
|
|
41
48
|
/** Resolves once the session is no longer `pending`. Rejects with an `AbortError` when `signal` aborts. */
|
|
42
49
|
waitForSession(id: string, options?: {
|
package/dist/core/client.js
CHANGED
|
@@ -22,21 +22,28 @@ export class ConnectClient {
|
|
|
22
22
|
#fetch;
|
|
23
23
|
#source;
|
|
24
24
|
#token;
|
|
25
|
+
/** The token being fetched, shared by every call that needs one meanwhile, so calls side by side ask the source once. */
|
|
26
|
+
#pending = null;
|
|
25
27
|
constructor(options) {
|
|
26
28
|
this.#baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
|
|
27
29
|
this.#fetch = options.fetch ?? ((input, init) => fetch(input, init));
|
|
28
30
|
this.#source = options.token;
|
|
29
31
|
this.#token = typeof options.token === 'string' ? options.token : null;
|
|
30
32
|
}
|
|
33
|
+
/** Every platform, and whether this Flung can connect it. */
|
|
31
34
|
async platforms() {
|
|
32
35
|
return (await this.#call('GET', '/v1/platforms')).data;
|
|
33
36
|
}
|
|
37
|
+
/** The connected accounts the token may see: its profile's, or the whole workspace's. */
|
|
34
38
|
async accounts() {
|
|
35
39
|
return (await this.#call('GET', '/v1/accounts')).data;
|
|
36
40
|
}
|
|
37
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Starts connecting a new account. `handle` is the Bluesky handle, or the Mastodon instance (required there). `variant`
|
|
43
|
+
* picks another way to connect the platform: `pages` on LinkedIn connects the company pages the person administers.
|
|
44
|
+
*/
|
|
38
45
|
async connect(platform, options = {}) {
|
|
39
|
-
return this.#call('POST', `/v1/connect/${encodeURIComponent(platform)}`, compact({ handle: options.handle, redirectUrl: options.redirectUrl }));
|
|
46
|
+
return this.#call('POST', `/v1/connect/${encodeURIComponent(platform)}`, compact({ handle: options.handle, redirectUrl: options.redirectUrl, variant: options.variant }));
|
|
40
47
|
}
|
|
41
48
|
/** Starts reconnecting an account that needs it; the person must sign in as the same platform user. */
|
|
42
49
|
async reconnect(accountId, options = {}) {
|
|
@@ -46,6 +53,7 @@ export class ConnectClient {
|
|
|
46
53
|
async disconnect(accountId) {
|
|
47
54
|
return this.#call('DELETE', `/v1/accounts/${encodeURIComponent(accountId)}`);
|
|
48
55
|
}
|
|
56
|
+
/** A connect session's state: pending, connected, failed or expired. */
|
|
49
57
|
async session(id) {
|
|
50
58
|
return this.#call('GET', `/v1/connect-sessions/${encodeURIComponent(id)}`);
|
|
51
59
|
}
|
|
@@ -60,9 +68,10 @@ export class ConnectClient {
|
|
|
60
68
|
}
|
|
61
69
|
}
|
|
62
70
|
async #call(method, path, body) {
|
|
63
|
-
|
|
71
|
+
const used = await this.#currentToken();
|
|
72
|
+
let response = await this.#send(method, path, body, used);
|
|
64
73
|
if (response.status === 401 && typeof this.#source === 'function')
|
|
65
|
-
response = await this.#send(method, path, body, await this.#currentToken(
|
|
74
|
+
response = await this.#send(method, path, body, await this.#currentToken(used));
|
|
66
75
|
const text = await response.text();
|
|
67
76
|
const parsed = parse(text);
|
|
68
77
|
if (!response.ok) {
|
|
@@ -79,12 +88,22 @@ export class ConnectClient {
|
|
|
79
88
|
throw new FlungConnectError(0, 'network_error', `Could not reach Flung: ${String(error)}`);
|
|
80
89
|
});
|
|
81
90
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
91
|
+
/**
|
|
92
|
+
* The token to send. `expired` is one the API refused: a new one is asked for unless another call already replaced it. One
|
|
93
|
+
* request to the source at a time, shared by every call waiting on it.
|
|
94
|
+
*/
|
|
95
|
+
async #currentToken(expired) {
|
|
96
|
+
const source = this.#source;
|
|
97
|
+
if (typeof source === 'string')
|
|
98
|
+
return source;
|
|
99
|
+
if (this.#token !== null && this.#token !== expired)
|
|
100
|
+
return this.#token;
|
|
101
|
+
this.#pending ??= source()
|
|
102
|
+
.then((token) => (this.#token = token))
|
|
103
|
+
.finally(() => {
|
|
104
|
+
this.#pending = null;
|
|
105
|
+
});
|
|
106
|
+
return this.#pending;
|
|
88
107
|
}
|
|
89
108
|
}
|
|
90
109
|
function compact(values) {
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import type { TokenSource } from './client.js';
|
|
2
|
+
import type { ConnectDetail, DisconnectDetail, ErrorDetail, LoadDetail } from './events.js';
|
|
3
|
+
import type { ConnectLabels } from './labels.js';
|
|
4
|
+
import type { Account, ConnectSession, ConnectVariant, Platform } from './types.js';
|
|
5
|
+
/** What one connected account looks like to a panel: the API's account and what to show for it. */
|
|
6
|
+
export interface AccountItem {
|
|
7
|
+
/** The account as the API returned it. */
|
|
8
|
+
readonly account: Account;
|
|
9
|
+
/** How messages name it: its username, or its platform id when it has none. */
|
|
10
|
+
readonly name: string;
|
|
11
|
+
/** The name to show first: the display name, else the username, else the platform id. */
|
|
12
|
+
readonly displayName: string;
|
|
13
|
+
/** The handle to show under the display name, without the `@`; null when it would repeat the display name. */
|
|
14
|
+
readonly username: string | null;
|
|
15
|
+
/** The profile picture, only when the API gave an `https` address; otherwise null, so a panel shows `initial`. */
|
|
16
|
+
readonly avatarUrl: string | null;
|
|
17
|
+
/** One letter for a picture that is missing or does not load. */
|
|
18
|
+
readonly initial: string;
|
|
19
|
+
/** True for a company page (a LinkedIn page connected with the `pages` variant), false for a person's own account. */
|
|
20
|
+
readonly page: boolean;
|
|
21
|
+
/** Why a reconnect is offered, or null when none is. `deadline` gives the ISO time it must happen by, within two weeks. */
|
|
22
|
+
readonly reconnect: {
|
|
23
|
+
readonly reason: 'needs_reconnect';
|
|
24
|
+
} | {
|
|
25
|
+
readonly reason: 'deadline';
|
|
26
|
+
readonly by: string;
|
|
27
|
+
} | null;
|
|
28
|
+
}
|
|
29
|
+
/** What a platform is doing besides listing its accounts. */
|
|
30
|
+
export type PlatformStep =
|
|
31
|
+
/** Nothing: its Connect button is ready. */
|
|
32
|
+
{
|
|
33
|
+
readonly kind: 'idle';
|
|
34
|
+
}
|
|
35
|
+
/** Asking for the Mastodon server (required) or Bluesky handle (optional) before a connect: call `submitHandle`. */
|
|
36
|
+
| {
|
|
37
|
+
readonly kind: 'handle';
|
|
38
|
+
readonly required: boolean;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Asking what to connect, when the platform offers its own connect and a variant (LinkedIn: a personal profile or a
|
|
42
|
+
* company page): call `connect(platform, { variant })` with the answer, `null` for the profile, or `cancel`.
|
|
43
|
+
*/
|
|
44
|
+
| {
|
|
45
|
+
readonly kind: 'choose';
|
|
46
|
+
readonly variants: readonly ConnectVariant[];
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Waiting on the person to finish signing in. `blockedUrl` is set when the browser refused to open the window: offer it
|
|
50
|
+
* as a link. `variant` is the variant being connected, or null.
|
|
51
|
+
*/
|
|
52
|
+
| {
|
|
53
|
+
readonly kind: 'waiting';
|
|
54
|
+
readonly blockedUrl: string | null;
|
|
55
|
+
readonly accountId: string | null;
|
|
56
|
+
readonly variant: ConnectVariant | null;
|
|
57
|
+
}
|
|
58
|
+
/** Asking whether to disconnect `account`: call `confirmDisconnect` or `cancel`. */
|
|
59
|
+
| {
|
|
60
|
+
readonly kind: 'confirm';
|
|
61
|
+
readonly account: AccountItem;
|
|
62
|
+
};
|
|
63
|
+
/** One platform the panel offers. */
|
|
64
|
+
export interface PlatformItem {
|
|
65
|
+
/** The platform's id. */
|
|
66
|
+
readonly platform: Platform;
|
|
67
|
+
/** Its name for people, such as `LinkedIn`. */
|
|
68
|
+
readonly name: string;
|
|
69
|
+
/** Its connected accounts, newest first. */
|
|
70
|
+
readonly accounts: readonly AccountItem[];
|
|
71
|
+
/** What it is doing now. */
|
|
72
|
+
readonly step: PlatformStep;
|
|
73
|
+
/** True while a connect is under way: its other buttons should be disabled. */
|
|
74
|
+
readonly busy: boolean;
|
|
75
|
+
/** Whether a connect asks first for a handle (Bluesky, optional) or a server (Mastodon, required); null when it does not. */
|
|
76
|
+
readonly handlePrompt: {
|
|
77
|
+
readonly required: boolean;
|
|
78
|
+
} | null;
|
|
79
|
+
/**
|
|
80
|
+
* Whether the platform's own connect is set up (on LinkedIn, the person's profile). False when only a variant is, and
|
|
81
|
+
* the platform is offered for that alone.
|
|
82
|
+
*/
|
|
83
|
+
readonly connectable: boolean;
|
|
84
|
+
/** The other ways it can connect here, such as LinkedIn's `pages`; empty for most platforms. */
|
|
85
|
+
readonly variants: readonly ConnectVariant[];
|
|
86
|
+
}
|
|
87
|
+
/** The last thing that happened, as data. `message` in the state words it. */
|
|
88
|
+
export type ConnectNotice =
|
|
89
|
+
/** A connect or reconnect finished. `platform` is null when a redirect came back with no account the panel can see. */
|
|
90
|
+
{
|
|
91
|
+
readonly kind: 'connected';
|
|
92
|
+
readonly platform: Platform | null;
|
|
93
|
+
readonly accounts: readonly Account[];
|
|
94
|
+
readonly session: ConnectSession | null;
|
|
95
|
+
}
|
|
96
|
+
/** An account was disconnected. */
|
|
97
|
+
| {
|
|
98
|
+
readonly kind: 'disconnected';
|
|
99
|
+
readonly account: Account;
|
|
100
|
+
}
|
|
101
|
+
/** The person took too long to sign in. */
|
|
102
|
+
| {
|
|
103
|
+
readonly kind: 'expired';
|
|
104
|
+
readonly platform: Platform;
|
|
105
|
+
}
|
|
106
|
+
/** A call or a connect failed. `during` says what was being done. */
|
|
107
|
+
| {
|
|
108
|
+
readonly kind: 'error';
|
|
109
|
+
readonly during: 'load' | 'connect' | 'disconnect' | 'redirect';
|
|
110
|
+
readonly platform: Platform | null;
|
|
111
|
+
readonly code: string;
|
|
112
|
+
readonly message: string;
|
|
113
|
+
};
|
|
114
|
+
/** Everything a panel draws, as one immutable snapshot. A new object every time anything changes. */
|
|
115
|
+
export interface ConnectState {
|
|
116
|
+
/** False until there is a connect token; no call is made without one. */
|
|
117
|
+
readonly hasToken: boolean;
|
|
118
|
+
/** True while the platforms and accounts are being read. */
|
|
119
|
+
readonly loading: boolean;
|
|
120
|
+
/** True once they have been read at least once. */
|
|
121
|
+
readonly loaded: boolean;
|
|
122
|
+
/** The platforms to offer, in the API's order. */
|
|
123
|
+
readonly platforms: readonly PlatformItem[];
|
|
124
|
+
/** Every connected account shown, newest first. */
|
|
125
|
+
readonly accounts: readonly Account[];
|
|
126
|
+
/** The last thing that happened, or null. */
|
|
127
|
+
readonly notice: ConnectNotice | null;
|
|
128
|
+
/** The panel's status line in the labels' words: the notice, or loading, empty and no-token text. `text` is empty when there is nothing to say. */
|
|
129
|
+
readonly message: {
|
|
130
|
+
readonly text: string;
|
|
131
|
+
readonly error: boolean;
|
|
132
|
+
};
|
|
133
|
+
/** The words in use, defaults filled in. */
|
|
134
|
+
readonly labels: ConnectLabels;
|
|
135
|
+
}
|
|
136
|
+
/** What `connect` takes. */
|
|
137
|
+
export interface ConnectOptions {
|
|
138
|
+
/**
|
|
139
|
+
* Which way to connect, without asking: `pages` for LinkedIn company pages, or `null` for the platform's own connect (a
|
|
140
|
+
* LinkedIn personal profile). Left out, a platform that offers both asks first (the step becomes `choose`), and one
|
|
141
|
+
* that offers only a variant connects that.
|
|
142
|
+
*/
|
|
143
|
+
variant?: ConnectVariant | null | undefined;
|
|
144
|
+
}
|
|
145
|
+
/** What a panel can do. Start a connect straight from a click or submit handler: a browser opens a window only during one. */
|
|
146
|
+
export interface ConnectActions {
|
|
147
|
+
/**
|
|
148
|
+
* Connects a new account on `platform`. It asks first when it must: for a handle (the step becomes `handle`), or, when
|
|
149
|
+
* the platform offers a variant and `options.variant` is left out, what to connect (the step becomes `choose`).
|
|
150
|
+
*/
|
|
151
|
+
connect(platform: Platform, options?: ConnectOptions): void;
|
|
152
|
+
/** Sends the Mastodon server or Bluesky handle the step asked for and starts the connect. Blank means none. */
|
|
153
|
+
submitHandle(platform: Platform, handle: string): void;
|
|
154
|
+
/** Reconnects an account that needs it. */
|
|
155
|
+
reconnect(accountId: string): void;
|
|
156
|
+
/** Asks to disconnect an account: the step becomes `confirm`. Nothing is sent yet. */
|
|
157
|
+
requestDisconnect(accountId: string): void;
|
|
158
|
+
/** Disconnects the account the platform's `confirm` step is asking about. */
|
|
159
|
+
confirmDisconnect(platform: Platform): Promise<void>;
|
|
160
|
+
/** Stops waiting on a connect, or closes the handle or confirm question. */
|
|
161
|
+
cancel(platform: Platform): void;
|
|
162
|
+
/** Clears the notice. */
|
|
163
|
+
dismissNotice(): void;
|
|
164
|
+
/** Reads the platforms and accounts again. */
|
|
165
|
+
refresh(): Promise<void>;
|
|
166
|
+
}
|
|
167
|
+
/** The headless panel: state, a way to hear it change, the actions, and the means to change options or stop. */
|
|
168
|
+
export interface ConnectController extends ConnectActions {
|
|
169
|
+
/** The current snapshot. The same object until something changes, as `useSyncExternalStore` needs. */
|
|
170
|
+
getState(): ConnectState;
|
|
171
|
+
/** Calls `listener` after every change. Returns the function that stops it. */
|
|
172
|
+
subscribe(listener: () => void): () => void;
|
|
173
|
+
/** Changes the options a panel may change while it is shown. A new platform list reads the accounts again. */
|
|
174
|
+
configure(options: {
|
|
175
|
+
platforms?: readonly Platform[] | null | undefined;
|
|
176
|
+
mode?: 'popup' | 'redirect' | undefined;
|
|
177
|
+
labels?: Partial<ConnectLabels> | undefined;
|
|
178
|
+
}): void;
|
|
179
|
+
/** Stops every wait, drops every listener, and ignores answers still on their way. */
|
|
180
|
+
destroy(): void;
|
|
181
|
+
}
|
|
182
|
+
/** What `createConnectController` takes. */
|
|
183
|
+
export interface ConnectControllerOptions {
|
|
184
|
+
/** A connect token from your server, or a function that fetches a fresh one. Null makes no call. */
|
|
185
|
+
token: TokenSource | null;
|
|
186
|
+
/** Where the API lives; production when left out. The token is sent there and nowhere else. */
|
|
187
|
+
baseUrl?: string | undefined;
|
|
188
|
+
/** Offer only these platforms. */
|
|
189
|
+
platforms?: readonly Platform[] | null | undefined;
|
|
190
|
+
/** `popup` (the default) opens a small window; `redirect` sends the whole page to the platform and back. */
|
|
191
|
+
mode?: 'popup' | 'redirect' | undefined;
|
|
192
|
+
/** A `fetch` to use instead of the global one. */
|
|
193
|
+
fetch?: typeof fetch | undefined;
|
|
194
|
+
/** Any of the words, for `message` and the notices. */
|
|
195
|
+
labels?: Partial<ConnectLabels> | undefined;
|
|
196
|
+
/** Reads, reports and removes the result Flung put on the address after a redirect. True when left out. */
|
|
197
|
+
readRedirect?: boolean | undefined;
|
|
198
|
+
/** The accounts were read. */
|
|
199
|
+
onLoad?: ((detail: LoadDetail) => void) | undefined;
|
|
200
|
+
/** A connect or reconnect finished. */
|
|
201
|
+
onConnect?: ((detail: ConnectDetail) => void) | undefined;
|
|
202
|
+
/** An account was disconnected. */
|
|
203
|
+
onDisconnect?: ((detail: DisconnectDetail) => void) | undefined;
|
|
204
|
+
/** A call or a connect failed. */
|
|
205
|
+
onError?: ((detail: ErrorDetail) => void) | undefined;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* The connect panel with no markup: it reads the platforms and accounts, runs connects, reconnects and disconnects, and
|
|
209
|
+
* keeps one immutable snapshot of it all. `<flung-connect>` and `useFlungConnect` are drawn from it; a page can draw its
|
|
210
|
+
* own panel from it too. It starts reading as soon as it has a token.
|
|
211
|
+
*/
|
|
212
|
+
export declare function createConnectController(options: ConnectControllerOptions): ConnectController;
|
|
213
|
+
/** The snapshot a panel with no controller yet shows, the same object every time for each `hasToken`. */
|
|
214
|
+
export declare function initialConnectState(hasToken: boolean): ConnectState;
|